Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b441106d2d | ||
|
|
be99e5e635 | ||
|
|
ccb675f4bc | ||
|
|
5048f865f2 | ||
|
|
7a632c5ebd | ||
|
|
4679cc70ef | ||
|
|
8a0621d1ce | ||
|
|
9b3673831a | ||
|
|
6ea501dc62 | ||
|
|
4c78500ec9 | ||
|
|
69ef3ccf72 |
@ -1,6 +1,6 @@
|
||||
# 電子報介接備忘錄(租戶端 / Wagtail)
|
||||
|
||||
最後更新:2026-02-11
|
||||
最後更新:2026-02-18
|
||||
|
||||
## 1. 目標與前提
|
||||
|
||||
@ -154,3 +154,270 @@
|
||||
3. 再完成退訂頁與退訂 API 串接。
|
||||
4. 建立電子報 app 與 HTML 編輯器資料模型。
|
||||
5. 最後接排程任務與 Send Engine 發信。
|
||||
|
||||
## 8. Member Center API 實際規格(以 member_center 程式碼 / OpenAPI 為準)
|
||||
|
||||
資料來源(2026-02-17 比對):
|
||||
- `../member_center/src/MemberCenter.Api/Controllers/NewsletterController.cs`
|
||||
- `../member_center/docs/openapi.yaml`
|
||||
|
||||
### 8.1 Base URL 與路徑
|
||||
|
||||
- OpenAPI `servers.url` 為 `/api`,部署時實際呼叫通常為:
|
||||
- `https://{member-center}/api/newsletter/...`
|
||||
- 若部署已在 gateway 做 path rewrite,也可為:
|
||||
- `https://{member-center}/newsletter/...`
|
||||
- 租戶端必須以實際部署路徑設定 `member_center_base_url`。
|
||||
|
||||
### 8.2 Endpoint / Method / Request
|
||||
|
||||
1. 訂閱
|
||||
- `POST /newsletter/subscribe`
|
||||
- JSON body(必要):
|
||||
- `list_id`(Guid)
|
||||
- `email`(string)
|
||||
- JSON body(可選):
|
||||
- `preferences`(object)
|
||||
- `source`(string)
|
||||
- 回傳包含 `confirm_token`
|
||||
|
||||
2. 訂閱確認(雙重驗證)
|
||||
- `GET /newsletter/confirm?token=...`
|
||||
- 注意:**confirm 是 GET,不是 POST**
|
||||
|
||||
3. 單一名單退訂
|
||||
- `POST /newsletter/unsubscribe`
|
||||
- JSON body(必要):
|
||||
- `token`(string)
|
||||
|
||||
4. 申請退訂 token
|
||||
- `POST /newsletter/unsubscribe-token`
|
||||
- JSON body(必要):
|
||||
- `list_id`(Guid)
|
||||
- `email`(string)
|
||||
- 回傳:
|
||||
- `unsubscribe_token`(string)
|
||||
|
||||
### 8.3 與租戶端目前實作對齊結果
|
||||
|
||||
- `subscribe`:已使用 `POST`。
|
||||
- `confirm`:已改為 `GET + query token`(修正 HTTP 405 問題)。
|
||||
- `unsubscribe`:已使用 `POST`,且 body 僅送 `token`。
|
||||
- `unsubscribe-token`:已使用 `POST`,且 body 送 `list_id + email`。
|
||||
|
||||
## 9. List-Unsubscribe One-Click 規範(發信流程階段導入)
|
||||
|
||||
說明:
|
||||
- 本節為「電子報 app + 排程發信」階段的目標規範。
|
||||
- 已完成的訂閱/退訂流程維持現況,不回頭重做;待發信流程上線時再整合 one-click。
|
||||
|
||||
### 9.1 目標
|
||||
|
||||
- 提供電子報編輯與排程。
|
||||
- 產生 `List-Unsubscribe` one-click header。
|
||||
- 呼叫發信代理 API。
|
||||
- 提供 unsubscribe endpoint(one-click + 人類點擊頁)。
|
||||
|
||||
### 9.2 Unsubscribe Token 設計
|
||||
|
||||
建議使用 JWT 或 HMAC token。
|
||||
|
||||
Token 內容建議:
|
||||
- `subscriber_id`
|
||||
- `list_id`
|
||||
- `site_id`
|
||||
- `exp`
|
||||
- `nonce`(optional)
|
||||
|
||||
安全要求:
|
||||
- 不需登入即可退訂。
|
||||
- 必須可 idempotent。
|
||||
- Token 需有過期時間。
|
||||
- Token 必須簽章驗證。
|
||||
|
||||
### 9.3 Unsubscribe Endpoint
|
||||
|
||||
1. One-click endpoint
|
||||
- `POST /u/unsubscribe`
|
||||
- Request:`token`
|
||||
- 流程:
|
||||
- 驗證 token
|
||||
- 呼叫會員平台 API
|
||||
- 回傳 `200`
|
||||
- 不可:
|
||||
- 要求登入
|
||||
- 要求二次確認
|
||||
|
||||
2. 人類點擊頁面
|
||||
- `GET /u/unsubscribe?token=xxx`
|
||||
- 流程:
|
||||
- 驗證 token
|
||||
- 顯示退訂成功頁面
|
||||
|
||||
### 9.4 發信 Header 規則
|
||||
|
||||
Newsletter 必須包含:
|
||||
- `List-Unsubscribe: <https://domain/u/unsubscribe?token=xxx>`
|
||||
- `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
|
||||
|
||||
註記:
|
||||
- Transactional email 不應包含 List-Unsubscribe。
|
||||
|
||||
### 9.5 與會員平台整合(one-click 目標介面)
|
||||
|
||||
呼叫 API:
|
||||
- `POST /api/subscriptions/unsubscribe`
|
||||
|
||||
Request:
|
||||
- `subscriber_id`
|
||||
- `list_id`
|
||||
- `source: "one_click"`
|
||||
- `campaign_id`
|
||||
|
||||
回傳:
|
||||
- `success`
|
||||
- `already_unsubscribed`
|
||||
|
||||
### 9.6 測試案例
|
||||
|
||||
1. Token 驗證
|
||||
- 正常 token -> 成功退訂
|
||||
- 過期 token -> `400` 或 `410`
|
||||
- 已退訂 -> `200`
|
||||
- 簽章錯誤 -> `400`
|
||||
|
||||
2. Header 驗證
|
||||
- Newsletter 發送時必定包含 `List-Unsubscribe`
|
||||
- Transactional email 不包含 `List-Unsubscribe`
|
||||
- Header 格式正確
|
||||
|
||||
### 9.7 安全與 UX
|
||||
|
||||
安全:
|
||||
- Unsubscribe endpoint 不受 CSRF 限制
|
||||
- Token 驗證必須 server-side
|
||||
- 記錄 audit log
|
||||
- 不顯示 `subscriber_id` 在 URL 明文
|
||||
|
||||
UX:
|
||||
- 退訂成功頁面簡潔
|
||||
- 提供重新訂閱入口
|
||||
- 不強迫填問卷
|
||||
|
||||
### 9.8 目前實作狀態(Wagtail)
|
||||
|
||||
- 已提供 one-click endpoint:
|
||||
- `POST /u/unsubscribe`(token 可由 query/body 提供)
|
||||
- `GET /u/unsubscribe?token=...`(人類點擊頁)
|
||||
- 已實作 token 驗證(HMAC + `exp`)與 idempotent 行為(重複退訂維持 200)。
|
||||
- 已實作 server-side audit log(`OneClickUnsubscribeAudit`)。
|
||||
- 已提供 one-click URL + Header 產生工具:
|
||||
- `List-Unsubscribe`
|
||||
- `List-Unsubscribe-Post`
|
||||
|
||||
### 9.9 Send Engine 發送介接(依 `../mass_mail_engine/docs/openapi.yaml` 對齊)
|
||||
|
||||
已對齊的 API:
|
||||
1. 建立 send job
|
||||
- `POST /api/send-jobs`
|
||||
- request 以 `CreateSendJobRequest` 為主(`list_id`、`subject`、`body_html/body_text/template`)
|
||||
- response 讀取:
|
||||
- `send_job_id`
|
||||
- `status`
|
||||
|
||||
2. 查詢 send job
|
||||
- `GET /api/send-jobs/{id}`
|
||||
- 若建立後狀態非最終態,持續輪詢直到最終態或超過輪詢上限。
|
||||
|
||||
狀態對應(Wagtail campaign):
|
||||
- Send Engine `completed` -> campaign `sent`
|
||||
- Send Engine `failed` / `cancelled` -> campaign `failed`
|
||||
- 建立失敗 / 輪詢逾時 / 輪詢錯誤 -> campaign `failed`
|
||||
|
||||
Send Engine 最終態(terminal):
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `cancelled`
|
||||
|
||||
備註:
|
||||
- 目前 dispatch 紀錄改為「每次送 job / 重試一次一筆」,不再是逐收件者一筆。
|
||||
- 若要做投遞到單一收件者的最終監控(delivery/bounce/complaint),仍建議接 Send Engine webhook 或事件回寫機制處理。
|
||||
|
||||
### 9.10 One-Click Token 模式(目前採用)
|
||||
|
||||
目前採用:`MemberCenter token relay`
|
||||
|
||||
流程:
|
||||
1. CMS 發送 payload 時同時提供:
|
||||
- `template.list_unsubscribe_url_template`(給 Send Engine 產生 `List-Unsubscribe` header)
|
||||
信內可點連結由 `body_html/body_text` 直接使用 `{{unsubscribe_token}}` 組 URL。
|
||||
例如:`https://{cms}/u/unsubscribe?token={{unsubscribe_token}}`
|
||||
2. Send Engine 於發送時向 Member Center 取得每位收件者的退訂 token,並替換 `{{unsubscribe_token}}`。
|
||||
3. 使用者點擊信內退訂(或 mailbox one-click)後,進入 CMS `/u/unsubscribe`。
|
||||
4. CMS 不自行驗簽 token,直接以 S2S 呼叫 Member Center `unsubscribe(token)` 完成退訂。
|
||||
|
||||
備註:
|
||||
- 舊的 CMS 自簽 HMAC one-click token 保留工具函式作為備援,但主流程已切到 relay 模式。
|
||||
|
||||
## 10. 下一階段演進備忘(先記錄,待試用回饋後再排)
|
||||
|
||||
說明:
|
||||
- 本節為「規劃中」項目,先記錄方向,不立即實作。
|
||||
- 優先等待:電子報排版產出 + 實際 user 試用回饋。
|
||||
|
||||
### 10.1 編輯器能力演進
|
||||
|
||||
現況:
|
||||
- 目前使用 Wagtail Draftail 作為 HTML 編輯器,已可插入圖片(圖庫選擇 / 上傳)。
|
||||
|
||||
待回饋後評估:
|
||||
- 若編輯需求提升(table、欄位版型、拖拉區塊、進階 email 元件),再評估導入更完整的 newsletter editor。
|
||||
- 原則:先確認真實編輯痛點,再導入新編輯器,避免過早複雜化。
|
||||
|
||||
### 10.2 電子報樣式(CSS)策略
|
||||
|
||||
建議方向:
|
||||
- 採「固定樣式基底 + 內容編輯」模式。
|
||||
- CSS 不建議只放 `<head><style>`,實務上需考慮 email client 相容性,通常會在發送前做 CSS inlining(將重要樣式轉為 inline style)。
|
||||
- 可保留少量 head style(例如 media query),但核心排版建議以 inline 為主。
|
||||
|
||||
### 10.3 外框模板(Header / Footer / Shell)管理
|
||||
|
||||
目標:
|
||||
- 提供可編輯的 newsletter shell(例如 `layout.html`),內含 head、header、footer 與內容插槽。
|
||||
|
||||
建議實作概念:
|
||||
- 內容編輯區僅負責 body 內文。
|
||||
- 發送時由 backend 組裝:`shell + content + one-click headers/links`。
|
||||
- 預覽也走同一組裝流程,避免「預覽長得跟實際寄出不一致」。
|
||||
|
||||
### 10.4 退訂連結與個人化參數責任分工
|
||||
|
||||
共識方向:
|
||||
- 退訂連結需與收件者身分綁定(每位收件者唯一 token / URL)。
|
||||
- 內容模板需預留替換位(例如 `{{unsubscribe_url}}`)。
|
||||
|
||||
責任分工建議:
|
||||
- CMS:產內容、產 placeholder、組裝信件與 one-click header。
|
||||
- Send Engine:執行發送、接收投遞回報(bounce/complaint 等)。
|
||||
- SES(或下游 provider):最終投遞與回執來源。
|
||||
|
||||
### 10.5 文章區塊直接引入
|
||||
|
||||
需求方向:
|
||||
- 支援從既有文章中挑選區塊或全文片段匯入電子報。
|
||||
|
||||
建議:
|
||||
- 第一階段可先做「選文章 -> 帶入標題/摘要/首圖/連結」。
|
||||
- 第二階段再考慮「可編輯快照」或「保持動態同步」策略。
|
||||
|
||||
### 10.6 預覽能力
|
||||
|
||||
需求方向:
|
||||
- 內文編輯預覽。
|
||||
- 樣式(shell)編輯預覽。
|
||||
- 能注入假資料(假收件者、假 token、假文章)看最終結果。
|
||||
|
||||
建議:
|
||||
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
|
||||
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。
|
||||
|
||||
@ -10,7 +10,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /code
|
||||
|
||||
# Create an unprivileged user to run the app
|
||||
RUN adduser --disabled-password --gecos '' app
|
||||
RUN set -ex && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
adduser --disabled-password --gecos '' app
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN set -ex && \
|
||||
|
||||
@ -1,3 +1,20 @@
|
||||
from django.contrib import admin
|
||||
from .models import ContactFormSubmission
|
||||
|
||||
# Register your models here.
|
||||
|
||||
@admin.register(ContactFormSubmission)
|
||||
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ("created_at", "category", "name", "email", "contact")
|
||||
list_filter = ("category", "created_at")
|
||||
search_fields = ("name", "email", "contact", "message", "source_page")
|
||||
readonly_fields = (
|
||||
"name",
|
||||
"email",
|
||||
"contact",
|
||||
"category",
|
||||
"message",
|
||||
"source_page",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
29
innovedus_cms/base/forms.py
Normal file
29
innovedus_cms/base/forms.py
Normal file
@ -0,0 +1,29 @@
|
||||
from django import forms
|
||||
from .models import ContactFormSubmission
|
||||
|
||||
|
||||
class NewsletterSubscribeForm(forms.Form):
|
||||
email = forms.EmailField(max_length=254)
|
||||
|
||||
|
||||
class NewsletterUnsubscribeForm(forms.Form):
|
||||
email = forms.EmailField(
|
||||
max_length=254,
|
||||
required=False,
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
"readonly": "readonly",
|
||||
"aria-readonly": "true",
|
||||
}
|
||||
),
|
||||
)
|
||||
token = forms.CharField(max_length=1024, widget=forms.HiddenInput())
|
||||
|
||||
|
||||
class ContactForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ContactFormSubmission
|
||||
fields = ["name", "contact", "email", "category", "message", "source_page"]
|
||||
widgets = {
|
||||
"source_page": forms.HiddenInput(),
|
||||
}
|
||||
0
innovedus_cms/base/management/__init__.py
Normal file
0
innovedus_cms/base/management/__init__.py
Normal file
0
innovedus_cms/base/management/commands/__init__.py
Normal file
0
innovedus_cms/base/management/commands/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from base.newsletter_scheduler import dispatch_due_campaigns
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Dispatch due newsletter campaigns"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
result = dispatch_due_campaigns(limit=options["limit"])
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"processed_campaigns={processed_campaigns} sent_recipients={sent_recipients} failed_campaigns={failed_campaigns}".format(
|
||||
**result
|
||||
)
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-12 07:23
|
||||
|
||||
import wagtail.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('base', '0004_remove_headersettings_logo_headersettings_logo_dark_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NewsletterSystemSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('member_center_base_url', models.URLField(blank=True)),
|
||||
('member_center_subscribe_path', models.CharField(blank=True, default='/newsletter/subscribe', max_length=255)),
|
||||
('member_center_confirm_path', models.CharField(blank=True, default='/newsletter/confirm', max_length=255)),
|
||||
('member_center_unsubscribe_token_path', models.CharField(blank=True, default='/newsletter/unsubscribe-token', max_length=255)),
|
||||
('member_center_unsubscribe_path', models.CharField(blank=True, default='/newsletter/unsubscribe', max_length=255)),
|
||||
('member_center_tenant_id', models.CharField(blank=True, max_length=128)),
|
||||
('member_center_list_id', models.CharField(blank=True, max_length=128)),
|
||||
('member_center_timeout_seconds', models.PositiveIntegerField(default=10)),
|
||||
('send_engine_base_url', models.URLField(blank=True)),
|
||||
('send_engine_oauth_scope', models.CharField(blank=True, max_length=255)),
|
||||
('send_engine_timeout_seconds', models.PositiveIntegerField(default=10)),
|
||||
('smtp_relay_host', models.CharField(blank=True, max_length=255)),
|
||||
('smtp_relay_port', models.PositiveIntegerField(default=587)),
|
||||
('smtp_use_tls', models.BooleanField(default=True)),
|
||||
('smtp_use_ssl', models.BooleanField(default=False, help_text='465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。')),
|
||||
('smtp_timeout_seconds', models.PositiveIntegerField(default=15)),
|
||||
('smtp_username', models.CharField(blank=True, max_length=255)),
|
||||
('smtp_password', models.TextField(blank=True)),
|
||||
('sender_name', models.CharField(blank=True, max_length=255)),
|
||||
('sender_email', models.EmailField(blank=True, max_length=254)),
|
||||
('reply_to_email', models.EmailField(blank=True, max_length=254)),
|
||||
('default_charset', models.CharField(default='utf-8', max_length=50)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Newsletter System Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewsletterTemplateSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subscribe_subject_template', models.CharField(default='請確認您的電子報訂閱', max_length=255)),
|
||||
('subscribe_html_template', models.TextField(default="<p>您好,請點擊以下連結完成訂閱:</p><p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>")),
|
||||
('subscribe_text_template', models.TextField(default='您好,請點擊以下連結完成訂閱:{{confirm_url}}')),
|
||||
('confirm_success_template', wagtail.fields.RichTextField(blank=True, default='<p>歡迎加入debute AI 新聞</p><p>與我們一起探索發掘未來科技更多可能性</p>')),
|
||||
('confirm_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>訂閱確認失敗,請稍後再試。</p>')),
|
||||
('unsubscribe_intro_template', wagtail.fields.RichTextField(blank=True, default='<p>感謝您這陣子與我們並肩同行,</p><p>請在下方輸入您的電子郵件,即可完成退訂</p>')),
|
||||
('unsubscribe_success_template', wagtail.fields.RichTextField(blank=True, default='<p>已完成退訂。</p>')),
|
||||
('unsubscribe_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>退訂失敗,請稍後再試。</p>')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Newsletter Template Settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,143 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-19 08:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('base', '0005_newslettersystemsettings_newslettertemplatesettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OneClickUnsubscribeAudit',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('subscriber_id', models.CharField(blank=True, max_length=128)),
|
||||
('list_id', models.CharField(blank=True, max_length=128)),
|
||||
('site_id', models.CharField(blank=True, max_length=128)),
|
||||
('campaign_id', models.CharField(blank=True, max_length=128)),
|
||||
('status', models.CharField(blank=True, max_length=32)),
|
||||
('response_status', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('response_payload', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('processed_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_one_click_unsubscribe_path',
|
||||
field=models.CharField(blank=True, default='/api/subscriptions/unsubscribe', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='one_click_endpoint_path',
|
||||
field=models.CharField(blank=True, default='/u/unsubscribe', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='one_click_token_secret',
|
||||
field=models.CharField(blank=True, help_text='One-click token 簽章 secret;留空則使用 Django SECRET_KEY。', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='one_click_token_ttl_seconds',
|
||||
field=models.PositiveIntegerField(default=2592000),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewsletterCampaign',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('list_id', models.CharField(blank=True, max_length=128)),
|
||||
('subject_template', models.CharField(max_length=255)),
|
||||
('html_template', models.TextField()),
|
||||
('text_template', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('sent', 'Sent'), ('failed', 'Failed')], default='draft', max_length=16)),
|
||||
('scheduled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_subscriptions_path',
|
||||
field=models.CharField(blank=True, default='/newsletter/subscriptions', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_oauth_token_path',
|
||||
field=models.CharField(blank=True, default='/oauth/token', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_oauth_client_id',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_oauth_client_secret',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_oauth_scope',
|
||||
field=models.CharField(blank=True, default='newsletter:list.read', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='member_center_oauth_audience',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='send_engine_send_jobs_path',
|
||||
field=models.CharField(blank=True, default='/api/send-jobs', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='send_engine_retry_interval_seconds',
|
||||
field=models.PositiveIntegerField(default=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='send_engine_retry_max_attempts',
|
||||
field=models.PositiveIntegerField(default=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersystemsettings',
|
||||
name='site_base_url',
|
||||
field=models.URLField(blank=True, help_text='排程發送使用的站台網址(例如 https://news.example.com)。'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewsletterDispatchRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subscriber_id', models.CharField(blank=True, max_length=128)),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('status', models.CharField(blank=True, max_length=32)),
|
||||
('retry_count', models.PositiveIntegerField(default=0)),
|
||||
('next_retry_at', models.DateTimeField(blank=True, null=True)),
|
||||
('response_status', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('response_payload', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dispatch_records', to='base.newslettercampaign')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
117
innovedus_cms/base/migrations/0007_contactformsubmission.py
Normal file
117
innovedus_cms/base/migrations/0007_contactformsubmission.py
Normal file
@ -0,0 +1,117 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("base", "0006_oneclickunsubscribeaudit_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="MailSmtpSettings",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("smtp_relay_host", models.CharField(blank=True, max_length=255)),
|
||||
("smtp_relay_port", models.PositiveIntegerField(default=587)),
|
||||
("smtp_use_tls", models.BooleanField(default=True)),
|
||||
(
|
||||
"smtp_use_ssl",
|
||||
models.BooleanField(default=False, help_text="465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。"),
|
||||
),
|
||||
("smtp_timeout_seconds", models.PositiveIntegerField(default=15)),
|
||||
("smtp_username", models.CharField(blank=True, max_length=255)),
|
||||
("smtp_password", models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SMTP Settings",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SystemNotificationMailSettings",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("contact_form_from_name", models.CharField(blank=True, max_length=255)),
|
||||
("contact_form_from_email", models.EmailField(blank=True, max_length=254)),
|
||||
("contact_form_reply_to_email", models.EmailField(blank=True, max_length=254)),
|
||||
("contact_form_to_emails", models.TextField(blank=True, help_text="可填多個收件人,以逗號或換行分隔。")),
|
||||
("contact_form_subject_prefix", models.CharField(blank=True, default="[Contact Us]", max_length=255)),
|
||||
("contact_form_user_subject_template", models.CharField(blank=True, default="已收到您的聯絡表單", max_length=255)),
|
||||
(
|
||||
"contact_form_user_text_template",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="您好 {{name}}:\n\n我們已收到您的來信:\nEmail: {{email}}\n聯絡方式: {{contact}}\n問題類別: {{category}}\n\n留言內容:\n{{message}}\n\n我們會儘快與您聯繫",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contact_form_user_html_template",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="<p>您好 {{name}}:</p><p>我們已收到您的來信:</p><ul><li>Email: {{email}}</li><li>聯絡方式: {{contact}}</li><li>問題類別: {{category}}</li></ul><p>留言內容:</p><p>{{message}}</p><p>我們會儘快與您聯繫</p>",
|
||||
),
|
||||
),
|
||||
("default_charset", models.CharField(default="utf-8", max_length=50)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "System Notification Mail Settings",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ContactFormSubmission",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("email", models.EmailField(blank=True, max_length=254)),
|
||||
("contact", models.CharField(max_length=255)),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("collaboration", "合作邀約"),
|
||||
("website_issue", "網站問題回報"),
|
||||
("career", "求職專區"),
|
||||
("other", "其他"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("message", models.TextField()),
|
||||
("source_page", models.CharField(blank=True, max_length=512)),
|
||||
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||
("user_agent", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_password",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_relay_host",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_relay_port",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_timeout_seconds",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_use_ssl",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_use_tls",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="newslettersystemsettings",
|
||||
name="smtp_username",
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,6 @@
|
||||
from django import forms as django_forms
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from wagtail.admin.panels import (
|
||||
FieldPanel,
|
||||
MultiFieldPanel,
|
||||
@ -27,6 +29,9 @@ from wagtail.snippets.models import register_snippet
|
||||
from wagtail.fields import StreamField
|
||||
from wagtail import blocks
|
||||
|
||||
from .security import encrypt_text
|
||||
|
||||
|
||||
@register_setting
|
||||
class HeaderSettings(BaseGenericSetting):
|
||||
logo_light = models.ForeignKey(
|
||||
@ -35,7 +40,8 @@ class HeaderSettings(BaseGenericSetting):
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
help_text="深色底用(亮色 logo)",
|
||||
help_text=_("Use on dark background (light logo)."),
|
||||
verbose_name=_("Light Logo"),
|
||||
)
|
||||
logo_dark = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
@ -43,15 +49,16 @@ class HeaderSettings(BaseGenericSetting):
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
help_text="淺色底用(深色 logo)",
|
||||
help_text=_("Use on light background (dark logo)."),
|
||||
verbose_name=_("Dark Logo"),
|
||||
)
|
||||
site_name = models.CharField(max_length=255, blank=True)
|
||||
site_name = models.CharField(max_length=255, blank=True, verbose_name=_("Site Name"))
|
||||
extra_links = StreamField([
|
||||
("link", blocks.StructBlock([
|
||||
("label", blocks.CharBlock()),
|
||||
("url", blocks.URLBlock())
|
||||
("label", blocks.CharBlock(label=_("Label"))),
|
||||
("url", blocks.URLBlock(label=_("URL")))
|
||||
]))
|
||||
], use_json_field=True, blank=True, null=True)
|
||||
], use_json_field=True, blank=True, null=True, verbose_name=_("Extra Links"))
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
@ -61,61 +68,520 @@ class HeaderSettings(BaseGenericSetting):
|
||||
FieldPanel("site_name"),
|
||||
FieldPanel("extra_links"),
|
||||
],
|
||||
heading="Header Settings",
|
||||
heading=_("Header Settings"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Header Settings"
|
||||
verbose_name = _("Header Settings")
|
||||
|
||||
@register_setting
|
||||
class NavigationSettings(BaseGenericSetting):
|
||||
footer_links = StreamField([
|
||||
("section", blocks.StructBlock([
|
||||
("title", blocks.CharBlock(required=False)),
|
||||
("title", blocks.CharBlock(required=False, label=_("Section Title"))),
|
||||
("links", blocks.ListBlock(blocks.StructBlock([
|
||||
("label", blocks.CharBlock()),
|
||||
("url", blocks.URLBlock())
|
||||
("label", blocks.CharBlock(label=_("Label"))),
|
||||
("url", blocks.URLBlock(label=_("URL")))
|
||||
]))),
|
||||
]))
|
||||
], use_json_field=True, blank=True, null=True)
|
||||
], use_json_field=True, blank=True, null=True, verbose_name=_("Footer Links"))
|
||||
|
||||
panels = [
|
||||
FieldPanel("footer_links"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Footer Navigation"
|
||||
verbose_name = _("Footer Navigation")
|
||||
|
||||
class SocialLinkBlock(blocks.StructBlock):
|
||||
SOCIAL_MEDIA_CHOICES = [
|
||||
("facebook", "Facebook"),
|
||||
("twitter", "Twitter"),
|
||||
("instagram", "Instagram"),
|
||||
("threads", "Threads"),
|
||||
("linkedin", "LinkedIn"),
|
||||
("youtube", "YouTube"),
|
||||
("facebook", _("Facebook")),
|
||||
("twitter", _("Twitter")),
|
||||
("instagram", _("Instagram")),
|
||||
("threads", _("Threads")),
|
||||
("linkedin", _("LinkedIn")),
|
||||
("youtube", _("YouTube")),
|
||||
]
|
||||
|
||||
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES)
|
||||
url = blocks.URLBlock()
|
||||
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES, label=_("Platform"))
|
||||
url = blocks.URLBlock(label=_("URL"))
|
||||
|
||||
class Meta:
|
||||
icon = "link"
|
||||
label = "Social Link"
|
||||
label = _("Social Link")
|
||||
|
||||
@register_setting
|
||||
class SocialMediaSettings(BaseGenericSetting):
|
||||
links = StreamField([
|
||||
("link", SocialLinkBlock()),
|
||||
], use_json_field=True)
|
||||
], use_json_field=True, verbose_name=_("Social Links"))
|
||||
|
||||
panels = [FieldPanel("links")]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Social Media Settings")
|
||||
|
||||
|
||||
@register_setting
|
||||
class MailSmtpSettings(BaseGenericSetting):
|
||||
smtp_relay_host = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Relay Host"))
|
||||
smtp_relay_port = models.PositiveIntegerField(default=587, verbose_name=_("SMTP Relay Port"))
|
||||
smtp_use_tls = models.BooleanField(default=True, verbose_name=_("Use TLS"))
|
||||
smtp_use_ssl = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."),
|
||||
verbose_name=_("Use SSL"),
|
||||
)
|
||||
smtp_timeout_seconds = models.PositiveIntegerField(default=15, verbose_name=_("SMTP Timeout Seconds"))
|
||||
smtp_username = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Username"))
|
||||
smtp_password = models.TextField(blank=True, verbose_name=_("SMTP Password"))
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("smtp_relay_host"),
|
||||
FieldPanel("smtp_relay_port"),
|
||||
FieldPanel("smtp_use_tls"),
|
||||
FieldPanel("smtp_use_ssl"),
|
||||
FieldPanel("smtp_timeout_seconds"),
|
||||
FieldPanel("smtp_username"),
|
||||
FieldPanel(
|
||||
"smtp_password",
|
||||
widget=django_forms.PasswordInput(render_value=False),
|
||||
),
|
||||
],
|
||||
heading=_("SMTP Settings"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SMTP Settings")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.smtp_use_tls and self.smtp_use_ssl:
|
||||
raise ValueError("smtp_use_tls and smtp_use_ssl cannot both be True.")
|
||||
|
||||
if self.pk and not self.smtp_password:
|
||||
previous = type(self).objects.filter(pk=self.pk).only("smtp_password").first()
|
||||
self.smtp_password = previous.smtp_password if previous else ""
|
||||
elif self.smtp_password:
|
||||
self.smtp_password = encrypt_text(self.smtp_password)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@register_setting
|
||||
class NewsletterSystemSettings(BaseGenericSetting):
|
||||
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
|
||||
member_center_subscribe_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/newsletter/subscribe",
|
||||
verbose_name=_("Subscribe Path"),
|
||||
)
|
||||
member_center_confirm_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/newsletter/confirm",
|
||||
verbose_name=_("Confirm Path"),
|
||||
)
|
||||
member_center_unsubscribe_token_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/newsletter/unsubscribe-token",
|
||||
verbose_name=_("Unsubscribe Token Path"),
|
||||
)
|
||||
member_center_unsubscribe_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/newsletter/unsubscribe",
|
||||
verbose_name=_("Unsubscribe Path"),
|
||||
)
|
||||
member_center_subscriptions_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/newsletter/subscriptions",
|
||||
verbose_name=_("Subscriptions Path"),
|
||||
)
|
||||
member_center_oauth_token_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/oauth/token",
|
||||
verbose_name=_("OAuth Token Path"),
|
||||
)
|
||||
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
|
||||
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
|
||||
member_center_oauth_scope = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="newsletter:list.read",
|
||||
verbose_name=_("OAuth Scope"),
|
||||
)
|
||||
member_center_oauth_audience = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Audience"))
|
||||
member_center_one_click_unsubscribe_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/api/subscriptions/unsubscribe",
|
||||
verbose_name=_("One-Click Unsubscribe Path"),
|
||||
)
|
||||
member_center_tenant_id = models.CharField(max_length=128, blank=True, verbose_name=_("Tenant ID"))
|
||||
member_center_list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
||||
member_center_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Member Center Timeout Seconds"))
|
||||
|
||||
send_engine_base_url = models.URLField(blank=True, verbose_name=_("Send Engine Base URL"))
|
||||
send_engine_send_jobs_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/api/send-jobs",
|
||||
verbose_name=_("Send Jobs Path"),
|
||||
)
|
||||
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
|
||||
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
|
||||
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
|
||||
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3, verbose_name=_("Retry Max Attempts"))
|
||||
|
||||
sender_name = models.CharField(max_length=255, blank=True, verbose_name=_("Sender Name"))
|
||||
sender_email = models.EmailField(blank=True, verbose_name=_("Sender Email"))
|
||||
reply_to_email = models.EmailField(blank=True, verbose_name=_("Reply-To Email"))
|
||||
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
||||
one_click_endpoint_path = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="/u/unsubscribe",
|
||||
verbose_name=_("One-Click Endpoint Path"),
|
||||
)
|
||||
one_click_token_secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text=_("One-click token signing secret. Leave blank to use Django SECRET_KEY."),
|
||||
verbose_name=_("One-Click Token Secret"),
|
||||
)
|
||||
one_click_token_ttl_seconds = models.PositiveIntegerField(default=60 * 60 * 24 * 30, verbose_name=_("One-Click Token TTL Seconds"))
|
||||
site_base_url = models.URLField(
|
||||
blank=True,
|
||||
help_text=_("Site base URL for scheduler sends, e.g. https://news.example.com"),
|
||||
verbose_name=_("Site Base URL"),
|
||||
)
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("member_center_base_url"),
|
||||
FieldPanel("member_center_subscribe_path"),
|
||||
FieldPanel("member_center_confirm_path"),
|
||||
FieldPanel("member_center_unsubscribe_token_path"),
|
||||
FieldPanel("member_center_unsubscribe_path"),
|
||||
FieldPanel("member_center_subscriptions_path"),
|
||||
FieldPanel("member_center_oauth_token_path"),
|
||||
FieldPanel("member_center_oauth_client_id"),
|
||||
FieldPanel(
|
||||
"member_center_oauth_client_secret",
|
||||
widget=django_forms.PasswordInput(render_value=False),
|
||||
),
|
||||
FieldPanel("member_center_oauth_scope"),
|
||||
FieldPanel("member_center_oauth_audience"),
|
||||
FieldPanel("member_center_one_click_unsubscribe_path"),
|
||||
FieldPanel("member_center_tenant_id"),
|
||||
FieldPanel("member_center_list_id"),
|
||||
FieldPanel("member_center_timeout_seconds"),
|
||||
],
|
||||
heading=_("Member Center"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("send_engine_base_url"),
|
||||
FieldPanel("send_engine_send_jobs_path"),
|
||||
FieldPanel("send_engine_oauth_scope"),
|
||||
FieldPanel("send_engine_timeout_seconds"),
|
||||
FieldPanel("send_engine_retry_interval_seconds"),
|
||||
FieldPanel("send_engine_retry_max_attempts"),
|
||||
],
|
||||
heading=_("Send Engine"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("one_click_endpoint_path"),
|
||||
FieldPanel("one_click_token_secret"),
|
||||
FieldPanel("one_click_token_ttl_seconds"),
|
||||
FieldPanel("site_base_url"),
|
||||
],
|
||||
heading=_("List-Unsubscribe One-Click"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("sender_name"),
|
||||
FieldPanel("sender_email"),
|
||||
FieldPanel("reply_to_email"),
|
||||
FieldPanel("default_charset"),
|
||||
],
|
||||
heading=_("Newsletter Mail"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Newsletter System Settings")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk and not self.member_center_oauth_client_secret:
|
||||
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
||||
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
||||
elif self.member_center_oauth_client_secret:
|
||||
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@register_setting
|
||||
class SystemNotificationMailSettings(BaseGenericSetting):
|
||||
contact_form_from_name = models.CharField(max_length=255, blank=True, verbose_name=_("Contact Form Sender Name"))
|
||||
contact_form_from_email = models.EmailField(blank=True, verbose_name=_("Contact Form Sender Email"))
|
||||
contact_form_reply_to_email = models.EmailField(blank=True, verbose_name=_("Contact Form Reply-To Email"))
|
||||
contact_form_to_emails = models.TextField(
|
||||
blank=True,
|
||||
help_text=_("Multiple recipients separated by comma or newline."),
|
||||
verbose_name=_("Contact Form Notification Recipients"),
|
||||
)
|
||||
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]", verbose_name=_("Contact Form Subject Prefix"))
|
||||
contact_form_user_subject_template = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="已收到您的聯絡表單",
|
||||
verbose_name=_("User Copy Subject Template"),
|
||||
)
|
||||
contact_form_user_text_template = models.TextField(
|
||||
blank=True,
|
||||
default=(
|
||||
"您好 {{name}}:\n\n"
|
||||
"我們已收到您的來信,以下為存檔資訊:\n"
|
||||
"Email: {{email}}\n"
|
||||
"聯絡方式: {{contact}}\n"
|
||||
"問題類別: {{category}}\n\n"
|
||||
"留言內容:\n{{message}}\n"
|
||||
),
|
||||
verbose_name=_("User Copy Text Template"),
|
||||
)
|
||||
contact_form_user_html_template = models.TextField(
|
||||
blank=True,
|
||||
default=(
|
||||
"<p>您好 {{name}}:</p>"
|
||||
"<p>我們已收到您的來信,以下為存檔資訊:</p>"
|
||||
"<ul>"
|
||||
"<li>Email: {{email}}</li>"
|
||||
"<li>聯絡方式: {{contact}}</li>"
|
||||
"<li>問題類別: {{category}}</li>"
|
||||
"</ul>"
|
||||
"<p>留言內容:</p>"
|
||||
"<p>{{message}}</p>"
|
||||
),
|
||||
verbose_name=_("User Copy HTML Template"),
|
||||
)
|
||||
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("contact_form_from_name"),
|
||||
FieldPanel("contact_form_from_email"),
|
||||
FieldPanel("contact_form_reply_to_email"),
|
||||
FieldPanel("contact_form_to_emails"),
|
||||
FieldPanel("contact_form_subject_prefix"),
|
||||
FieldPanel("contact_form_user_subject_template"),
|
||||
FieldPanel("contact_form_user_text_template"),
|
||||
FieldPanel("contact_form_user_html_template"),
|
||||
FieldPanel("default_charset"),
|
||||
],
|
||||
heading=_("Contact Us Notification Mail"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("System Notification Mail Settings")
|
||||
|
||||
def contact_form_recipient_list(self) -> list[str]:
|
||||
raw = (self.contact_form_to_emails or "").replace("\n", ",")
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
class OneClickUnsubscribeAudit(models.Model):
|
||||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
subscriber_id = models.CharField(max_length=128, blank=True)
|
||||
list_id = models.CharField(max_length=128, blank=True)
|
||||
site_id = models.CharField(max_length=128, blank=True)
|
||||
campaign_id = models.CharField(max_length=128, blank=True)
|
||||
status = models.CharField(max_length=32, blank=True)
|
||||
response_status = models.PositiveIntegerField(null=True, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
processed_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class NewsletterCampaign(models.Model):
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_SCHEDULED = "scheduled"
|
||||
STATUS_SENDING = "sending"
|
||||
STATUS_SENT = "sent"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_DRAFT, _("Draft")),
|
||||
(STATUS_SCHEDULED, _("Scheduled")),
|
||||
(STATUS_SENDING, _("Sending")),
|
||||
(STATUS_SENT, _("Sent")),
|
||||
(STATUS_FAILED, _("Failed")),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
|
||||
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
|
||||
html_template = models.TextField(verbose_name=_("HTML Template"))
|
||||
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
|
||||
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
|
||||
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
|
||||
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
||||
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel("list_id"),
|
||||
FieldPanel("subject_template"),
|
||||
FieldPanel("html_template"),
|
||||
FieldPanel("text_template"),
|
||||
FieldPanel("scheduled_at"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = _("Newsletter Campaign")
|
||||
verbose_name_plural = _("Newsletter Campaigns")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding and not (self.list_id or "").strip():
|
||||
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
||||
if default_list_id:
|
||||
self.list_id = default_list_id
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class NewsletterDispatchRecord(models.Model):
|
||||
campaign = models.ForeignKey(
|
||||
NewsletterCampaign,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="dispatch_records",
|
||||
verbose_name=_("Campaign"),
|
||||
)
|
||||
subscriber_id = models.CharField(max_length=128, blank=True, verbose_name=_("Subscriber ID"))
|
||||
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
||||
status = models.CharField(max_length=32, blank=True, verbose_name=_("Status"))
|
||||
retry_count = models.PositiveIntegerField(default=0, verbose_name=_("Retry Count"))
|
||||
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Next Retry At"))
|
||||
response_status = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Response Status"))
|
||||
response_payload = models.JSONField(default=dict, blank=True, verbose_name=_("Response Payload"))
|
||||
error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||
|
||||
panels = [
|
||||
FieldPanel("campaign"),
|
||||
FieldPanel("subscriber_id"),
|
||||
FieldPanel("email"),
|
||||
FieldPanel("status"),
|
||||
FieldPanel("retry_count"),
|
||||
FieldPanel("next_retry_at"),
|
||||
FieldPanel("response_status"),
|
||||
FieldPanel("response_payload"),
|
||||
FieldPanel("error_message"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = _("Newsletter Dispatch Record")
|
||||
verbose_name_plural = _("Newsletter Dispatch Records")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
|
||||
|
||||
|
||||
@register_setting
|
||||
class NewsletterTemplateSettings(BaseGenericSetting):
|
||||
subscribe_subject_template = models.CharField(
|
||||
max_length=255,
|
||||
default="請確認您的電子報訂閱",
|
||||
verbose_name=_("Subscribe Subject Template"),
|
||||
)
|
||||
subscribe_html_template = models.TextField(
|
||||
default=(
|
||||
"<p>您好,請點擊以下連結完成訂閱:</p>"
|
||||
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
|
||||
),
|
||||
verbose_name=_("Subscribe HTML Template"),
|
||||
)
|
||||
subscribe_text_template = models.TextField(
|
||||
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
|
||||
verbose_name=_("Subscribe Text Template"),
|
||||
)
|
||||
confirm_success_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>訂閱確認成功。</p>",
|
||||
verbose_name=_("Confirm Success Template"),
|
||||
)
|
||||
confirm_failure_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>訂閱確認失敗,請稍後再試。</p>",
|
||||
verbose_name=_("Confirm Failure Template"),
|
||||
)
|
||||
unsubscribe_intro_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>確認要退訂電子報嗎?</p>",
|
||||
verbose_name=_("Unsubscribe Intro Template"),
|
||||
)
|
||||
unsubscribe_success_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>已完成退訂。</p>",
|
||||
verbose_name=_("Unsubscribe Success Template"),
|
||||
)
|
||||
unsubscribe_failure_template = RichTextField(
|
||||
blank=True,
|
||||
default="<p>退訂失敗,請稍後再試。</p>",
|
||||
verbose_name=_("Unsubscribe Failure Template"),
|
||||
)
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("subscribe_subject_template"),
|
||||
FieldPanel("subscribe_html_template"),
|
||||
FieldPanel("subscribe_text_template"),
|
||||
],
|
||||
heading=_("Subscribe Confirmation Email"),
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("confirm_success_template"),
|
||||
FieldPanel("confirm_failure_template"),
|
||||
FieldPanel("unsubscribe_intro_template"),
|
||||
FieldPanel("unsubscribe_success_template"),
|
||||
FieldPanel("unsubscribe_failure_template"),
|
||||
],
|
||||
heading=_("Page Templates"),
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Newsletter Template Settings")
|
||||
|
||||
|
||||
@register_snippet
|
||||
class BannerSnippet(models.Model):
|
||||
key = models.CharField(max_length=50, blank=True, help_text="識別用 key(例如 home / category)")
|
||||
title = models.CharField(max_length=255, blank=True)
|
||||
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))
|
||||
title = models.CharField(max_length=255, blank=True, verbose_name=_("Title"))
|
||||
image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
@ -124,9 +590,9 @@ class BannerSnippet(models.Model):
|
||||
related_name="+",
|
||||
)
|
||||
link_url = models.URLField(blank=True)
|
||||
link_text = models.CharField(max_length=100, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
link_text = models.CharField(max_length=100, blank=True, verbose_name=_("Link Text"))
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
||||
sort_order = models.PositiveIntegerField(default=0, verbose_name=_("Sort Order"))
|
||||
|
||||
panels = [
|
||||
FieldPanel("key"),
|
||||
@ -140,8 +606,8 @@ class BannerSnippet(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "id"]
|
||||
verbose_name = "Banner"
|
||||
verbose_name_plural = "Banners"
|
||||
verbose_name = _("Banner")
|
||||
verbose_name_plural = _("Banners")
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Banner {self.pk}"
|
||||
@ -172,4 +638,35 @@ class FooterText(
|
||||
return {"footer_text": self.body}
|
||||
|
||||
class Meta(TranslatableMixin.Meta):
|
||||
verbose_name_plural = "Footer Text"
|
||||
verbose_name_plural = _("Footer Text")
|
||||
|
||||
|
||||
class ContactFormSubmission(models.Model):
|
||||
CATEGORY_COLLABORATION = "collaboration"
|
||||
CATEGORY_WEBSITE_ISSUE = "website_issue"
|
||||
CATEGORY_CAREER = "career"
|
||||
CATEGORY_OTHER = "other"
|
||||
CATEGORY_CHOICES = [
|
||||
(CATEGORY_COLLABORATION, _("Collaboration")),
|
||||
(CATEGORY_WEBSITE_ISSUE, _("Website Issue")),
|
||||
(CATEGORY_CAREER, _("Career")),
|
||||
(CATEGORY_OTHER, _("Other")),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
||||
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
|
||||
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, verbose_name=_("Category"))
|
||||
message = models.TextField(verbose_name=_("Message"))
|
||||
source_page = models.CharField(max_length=512, blank=True, verbose_name=_("Source Page"))
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
|
||||
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = _("Contact Form Submission")
|
||||
verbose_name_plural = _("Contact Form Submissions")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_category_display()} - {self.name}"
|
||||
|
||||
589
innovedus_cms/base/newsletter.py
Normal file
589
innovedus_cms/base/newsletter.py
Normal file
@ -0,0 +1,589 @@
|
||||
import json
|
||||
import logging
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from email.utils import formataddr
|
||||
from hashlib import sha256
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from wagtail.rich_text import expand_db_html
|
||||
|
||||
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||
from .security import decrypt_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLACEHOLDER_KEYS = (
|
||||
"token",
|
||||
"email",
|
||||
"list_id",
|
||||
"tenant_id",
|
||||
"confirm_url",
|
||||
"unsubscribe_url",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIResult:
|
||||
ok: bool
|
||||
status: int
|
||||
data: dict
|
||||
error: str = ""
|
||||
|
||||
|
||||
class MemberCenterClient:
|
||||
def __init__(self, config: NewsletterSystemSettings):
|
||||
self.config = config
|
||||
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
||||
|
||||
def subscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_subscribe_path, payload)
|
||||
|
||||
def confirm(self, token: str) -> APIResult:
|
||||
return self._get(self.config.member_center_confirm_path, {"token": token})
|
||||
|
||||
def request_unsubscribe_token(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_unsubscribe_token_path, payload)
|
||||
|
||||
def unsubscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_unsubscribe_path, payload)
|
||||
|
||||
def one_click_unsubscribe(self, payload: dict) -> APIResult:
|
||||
return self._post(self.config.member_center_one_click_unsubscribe_path, payload)
|
||||
|
||||
def list_subscriptions(self, list_id: str) -> APIResult:
|
||||
auth_headers, auth_error = self._auth_headers()
|
||||
if auth_error:
|
||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||
return self._get(
|
||||
self.config.member_center_subscriptions_path,
|
||||
{"list_id": list_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult:
|
||||
base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||
if query:
|
||||
endpoint = f"{endpoint}?{urlencode(query)}"
|
||||
|
||||
request = Request(
|
||||
endpoint,
|
||||
headers={"Accept": "application/json", **(headers or {})},
|
||||
method="GET",
|
||||
)
|
||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult:
|
||||
base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
|
||||
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def _auth_headers(self) -> tuple[dict, str]:
|
||||
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read"
|
||||
token, error = self._get_oauth_access_token(required_scope=scope)
|
||||
if not token:
|
||||
return {}, error or "member center oauth token is empty"
|
||||
return {"Authorization": f"Bearer {token}"}, ""
|
||||
|
||||
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
|
||||
scope = (required_scope or "").strip()
|
||||
now = int(time.time())
|
||||
cached = self._oauth_token_cache.get(scope)
|
||||
if cached and now < cached[1] - 30:
|
||||
return cached[0], ""
|
||||
|
||||
base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not base_url:
|
||||
return "", "member_center_base_url is empty (cannot resolve oauth token url)"
|
||||
|
||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||
client_secret = ""
|
||||
if encrypted_secret:
|
||||
try:
|
||||
client_secret = decrypt_text(encrypted_secret)
|
||||
except Exception:
|
||||
client_secret = encrypted_secret
|
||||
if not client_id or not client_secret:
|
||||
return "", "member center oauth client_id/client_secret is empty"
|
||||
|
||||
body = urlencode(
|
||||
{
|
||||
k: v
|
||||
for k, v in {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": scope,
|
||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = Request(
|
||||
token_url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
|
||||
result = self._send(request, timeout=timeout)
|
||||
if not result.ok:
|
||||
return "", f"oauth token request failed: HTTP {result.status} {result.error}".strip()
|
||||
|
||||
access_token = str(result.data.get("access_token") or "").strip()
|
||||
if not access_token:
|
||||
return "", "oauth token response missing access_token"
|
||||
expires_in = int(result.data.get("expires_in") or 300)
|
||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
return access_token, ""
|
||||
|
||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
data = json.loads(raw) if raw else {}
|
||||
status = getattr(response, "status", 200)
|
||||
return APIResult(ok=200 <= status < 300, status=status, data=data)
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8") if exc.fp else ""
|
||||
try:
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
return APIResult(ok=False, status=exc.code, data=data, error=str(exc))
|
||||
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
||||
return APIResult(ok=False, status=0, data={}, error=str(exc))
|
||||
|
||||
|
||||
def render_placeholders(template: str, values: dict) -> str:
|
||||
rendered = template or ""
|
||||
for key in PLACEHOLDER_KEYS:
|
||||
rendered = rendered.replace(f"{{{{{key}}}}}", str(values.get(key, "")))
|
||||
return rendered
|
||||
|
||||
|
||||
def _absolutize_links(html: str, site_base_url: str) -> str:
|
||||
base = (site_base_url or "").strip().rstrip("/")
|
||||
if not base:
|
||||
return html
|
||||
|
||||
def _replace(match):
|
||||
prefix = match.group("prefix")
|
||||
url = match.group("url")
|
||||
return f"{prefix}{urljoin(f'{base}/', url.lstrip('/'))}"
|
||||
|
||||
pattern = re.compile(r"(?P<prefix>\b(?:src|href)=['\"])(?P<url>/[^'\"]*)")
|
||||
return pattern.sub(_replace, html)
|
||||
|
||||
|
||||
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
|
||||
rendered = render_placeholders(template, values)
|
||||
try:
|
||||
rendered = expand_db_html(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
return _absolutize_links(rendered, site_base_url)
|
||||
|
||||
|
||||
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
|
||||
rendered = template or ""
|
||||
try:
|
||||
rendered = expand_db_html(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
return _absolutize_links(rendered, site_base_url)
|
||||
|
||||
|
||||
def extract_token(payload: dict) -> str:
|
||||
if not payload:
|
||||
return ""
|
||||
|
||||
for key in ("token", "confirm_token", "unsubscribe_token"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
for key in ("token", "confirm_token", "unsubscribe_token"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def build_from_email(sender_name: str, sender_email: str) -> str:
|
||||
if sender_name and sender_email:
|
||||
return formataddr((sender_name, sender_email))
|
||||
return sender_email
|
||||
|
||||
|
||||
def resolve_one_click_secret(config: NewsletterSystemSettings) -> str:
|
||||
return (config.one_click_token_secret or "").strip() or settings.SECRET_KEY
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
import base64
|
||||
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
import base64
|
||||
|
||||
padding = "=" * ((4 - len(data) % 4) % 4)
|
||||
return base64.urlsafe_b64decode((data + padding).encode("ascii"))
|
||||
|
||||
|
||||
def generate_one_click_token(*, subscriber_id: str, list_id: str, site_id: str, campaign_id: str, secret: str, ttl_seconds: int) -> str:
|
||||
payload = {
|
||||
"subscriber_id": subscriber_id,
|
||||
"list_id": list_id,
|
||||
"site_id": site_id,
|
||||
"campaign_id": campaign_id,
|
||||
"exp": int(time.time()) + max(1, int(ttl_seconds)),
|
||||
"nonce": secrets.token_urlsafe(8),
|
||||
}
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
payload_part = _b64url_encode(payload_json)
|
||||
sig = hmac.new(secret.encode("utf-8"), payload_part.encode("ascii"), sha256).digest()
|
||||
sig_part = _b64url_encode(sig)
|
||||
return f"{payload_part}.{sig_part}"
|
||||
|
||||
|
||||
def verify_one_click_token(token: str, secret: str) -> tuple[dict | None, str | None]:
|
||||
if not token or "." not in token:
|
||||
return None, "invalid"
|
||||
payload_part, sig_part = token.split(".", 1)
|
||||
try:
|
||||
received_sig = _b64url_decode(sig_part)
|
||||
except Exception:
|
||||
return None, "invalid"
|
||||
|
||||
expected_sig = hmac.new(secret.encode("utf-8"), payload_part.encode("ascii"), sha256).digest()
|
||||
if not hmac.compare_digest(received_sig, expected_sig):
|
||||
return None, "invalid"
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_part).decode("utf-8"))
|
||||
except Exception:
|
||||
return None, "invalid"
|
||||
|
||||
required_keys = ("subscriber_id", "list_id", "site_id", "exp")
|
||||
if any(not payload.get(key) for key in required_keys):
|
||||
return None, "invalid"
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None, "expired"
|
||||
return payload, None
|
||||
|
||||
|
||||
def build_one_click_unsubscribe_url(*, site_base_url: str, endpoint_path: str, token: str) -> str:
|
||||
base = site_base_url.rstrip("/")
|
||||
endpoint = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
|
||||
return f"{base}{endpoint}?{urlencode({'token': token})}"
|
||||
|
||||
|
||||
def build_list_unsubscribe_headers(*, one_click_url: str) -> dict:
|
||||
return {
|
||||
"List-Unsubscribe": f"<{one_click_url}>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
|
||||
|
||||
class SendEngineClient:
|
||||
def __init__(self, config: NewsletterSystemSettings):
|
||||
self.config = config
|
||||
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
|
||||
|
||||
def create_send_job(self, payload: dict) -> APIResult:
|
||||
base_url = (self.config.send_engine_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="send_engine_base_url is empty")
|
||||
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", self.config.send_engine_send_jobs_path.lstrip("/"))
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
auth_headers, auth_error = self._auth_headers(required_scope="newsletter:send.write")
|
||||
if auth_error:
|
||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||
|
||||
headers = {"Content-Type": "application/json", **auth_headers}
|
||||
|
||||
request = Request(endpoint, data=body, headers=headers, method="POST")
|
||||
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def get_send_job(self, send_job_id: str) -> APIResult:
|
||||
base_url = (self.config.send_engine_base_url or "").strip()
|
||||
if not base_url:
|
||||
return APIResult(ok=False, status=0, data={}, error="send_engine_base_url is empty")
|
||||
if not send_job_id:
|
||||
return APIResult(ok=False, status=0, data={}, error="send_job_id is empty")
|
||||
|
||||
endpoint = urljoin(
|
||||
f"{base_url.rstrip('/')}/",
|
||||
f"{self.config.send_engine_send_jobs_path.lstrip('/')}/{send_job_id}",
|
||||
)
|
||||
auth_headers, auth_error = self._auth_headers(required_scope="newsletter:send.read")
|
||||
if auth_error:
|
||||
return APIResult(ok=False, status=0, data={}, error=auth_error)
|
||||
|
||||
headers = {"Accept": "application/json", **auth_headers}
|
||||
|
||||
request = Request(endpoint, headers=headers, method="GET")
|
||||
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
|
||||
return self._send(request, timeout=timeout)
|
||||
|
||||
def _auth_headers(self, required_scope: str = "") -> tuple[dict, str]:
|
||||
token, error = self._get_member_center_access_token(required_scope=required_scope)
|
||||
if error:
|
||||
return {}, error
|
||||
return {"Authorization": f"Bearer {token}"}, ""
|
||||
|
||||
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
|
||||
scope = (
|
||||
(required_scope or "").strip()
|
||||
or (self.config.send_engine_oauth_scope or "").strip()
|
||||
or "newsletter:send.write"
|
||||
)
|
||||
now = int(time.time())
|
||||
cached = self._oauth_token_cache.get(scope)
|
||||
if cached and now < cached[1] - 30:
|
||||
return cached[0], ""
|
||||
|
||||
member_center_base_url = (self.config.member_center_base_url or "").strip()
|
||||
if not member_center_base_url:
|
||||
return "", "member_center_base_url is empty (cannot resolve oauth token url)"
|
||||
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
|
||||
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
|
||||
|
||||
client_id = (self.config.member_center_oauth_client_id or "").strip()
|
||||
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
|
||||
client_secret = ""
|
||||
if encrypted_secret:
|
||||
try:
|
||||
client_secret = decrypt_text(encrypted_secret)
|
||||
except Exception:
|
||||
client_secret = encrypted_secret
|
||||
if not client_id or not client_secret:
|
||||
return ("", "member center oauth client_id/client_secret is empty")
|
||||
body = urlencode(
|
||||
{
|
||||
k: v
|
||||
for k, v in {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": scope,
|
||||
"audience": (self.config.member_center_oauth_audience or "").strip(),
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = Request(
|
||||
token_url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
|
||||
result = self._send(request, timeout=timeout)
|
||||
if not result.ok:
|
||||
return "", f"oauth token request failed: HTTP {result.status} {result.error}".strip()
|
||||
|
||||
access_token = str(result.data.get("access_token") or "").strip()
|
||||
if not access_token:
|
||||
return "", "oauth token response missing access_token"
|
||||
|
||||
expires_in = int(result.data.get("expires_in") or 300)
|
||||
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
|
||||
return access_token, ""
|
||||
|
||||
def _send(self, request: Request, timeout: int) -> APIResult:
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
data = json.loads(raw) if raw else {}
|
||||
status = getattr(response, "status", 200)
|
||||
return APIResult(ok=200 <= status < 300, status=status, data=data)
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8") if exc.fp else ""
|
||||
try:
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
return APIResult(ok=False, status=exc.code, data=data, error=str(exc))
|
||||
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
||||
return APIResult(ok=False, status=0, data={}, error=str(exc))
|
||||
|
||||
|
||||
def _build_smtp_connection(smtp_config: MailSmtpSettings):
|
||||
if not smtp_config.smtp_relay_host:
|
||||
raise ValueError("SMTP relay host is empty. Please save SMTP settings first.")
|
||||
if smtp_config.smtp_use_tls and smtp_config.smtp_use_ssl:
|
||||
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
|
||||
|
||||
password = ""
|
||||
encrypted_password = (smtp_config.smtp_password or "").strip()
|
||||
if encrypted_password:
|
||||
try:
|
||||
password = decrypt_text(encrypted_password)
|
||||
except Exception:
|
||||
password = ""
|
||||
|
||||
return get_connection(
|
||||
backend="django.core.mail.backends.smtp.EmailBackend",
|
||||
host=smtp_config.smtp_relay_host,
|
||||
port=smtp_config.smtp_relay_port,
|
||||
username=smtp_config.smtp_username or None,
|
||||
password=password or None,
|
||||
use_tls=bool(smtp_config.smtp_use_tls),
|
||||
use_ssl=bool(smtp_config.smtp_use_ssl),
|
||||
timeout=max(1, int(smtp_config.smtp_timeout_seconds or 15)),
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
def send_subscribe_email(
|
||||
*,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: str,
|
||||
config: NewsletterSystemSettings,
|
||||
smtp_config: MailSmtpSettings | None = None,
|
||||
) -> int:
|
||||
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||
from_email = build_from_email(config.sender_name, config.sender_email)
|
||||
reply_to = [config.reply_to_email] if config.reply_to_email else None
|
||||
connection = _build_smtp_connection(smtp_config)
|
||||
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email],
|
||||
reply_to=reply_to,
|
||||
connection=connection,
|
||||
)
|
||||
if html_body:
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
|
||||
charset = (config.default_charset or "utf-8").strip() or "utf-8"
|
||||
message.encoding = charset
|
||||
sent_count = message.send(fail_silently=False)
|
||||
logger.info(
|
||||
"newsletter email send result sent_count=%s smtp_host=%s smtp_port=%s smtp_tls=%s smtp_ssl=%s smtp_timeout=%s from_email=%s to_email=%s",
|
||||
sent_count,
|
||||
smtp_config.smtp_relay_host,
|
||||
smtp_config.smtp_relay_port,
|
||||
bool(smtp_config.smtp_use_tls),
|
||||
bool(smtp_config.smtp_use_ssl),
|
||||
max(1, int(smtp_config.smtp_timeout_seconds or 15)),
|
||||
from_email or settings.DEFAULT_FROM_EMAIL,
|
||||
to_email,
|
||||
)
|
||||
return sent_count
|
||||
|
||||
|
||||
def send_contact_notification_email(
|
||||
*,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: str,
|
||||
notification_config: SystemNotificationMailSettings,
|
||||
smtp_config: MailSmtpSettings | None = None,
|
||||
) -> int:
|
||||
recipients = notification_config.contact_form_recipient_list()
|
||||
if not recipients:
|
||||
return 0
|
||||
|
||||
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||
from_email = build_from_email(
|
||||
notification_config.contact_form_from_name,
|
||||
notification_config.contact_form_from_email,
|
||||
)
|
||||
reply_to = (
|
||||
[notification_config.contact_form_reply_to_email]
|
||||
if notification_config.contact_form_reply_to_email
|
||||
else None
|
||||
)
|
||||
connection = _build_smtp_connection(smtp_config)
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
|
||||
to=recipients,
|
||||
reply_to=reply_to,
|
||||
connection=connection,
|
||||
)
|
||||
if html_body:
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
|
||||
message.encoding = charset
|
||||
return message.send(fail_silently=False)
|
||||
|
||||
|
||||
def send_contact_user_email(
|
||||
*,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: str,
|
||||
notification_config: SystemNotificationMailSettings,
|
||||
smtp_config: MailSmtpSettings | None = None,
|
||||
) -> int:
|
||||
if not (to_email or "").strip():
|
||||
return 0
|
||||
|
||||
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||
from_email = build_from_email(
|
||||
notification_config.contact_form_from_name,
|
||||
notification_config.contact_form_from_email,
|
||||
)
|
||||
reply_to = (
|
||||
[notification_config.contact_form_reply_to_email]
|
||||
if notification_config.contact_form_reply_to_email
|
||||
else None
|
||||
)
|
||||
connection = _build_smtp_connection(smtp_config)
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
|
||||
to=[to_email.strip()],
|
||||
reply_to=reply_to,
|
||||
connection=connection,
|
||||
)
|
||||
if html_body:
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
|
||||
message.encoding = charset
|
||||
return message.send(fail_silently=False)
|
||||
350
innovedus_cms/base/newsletter_scheduler.py
Normal file
350
innovedus_cms/base/newsletter_scheduler.py
Normal file
@ -0,0 +1,350 @@
|
||||
import time
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
|
||||
|
||||
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
|
||||
SUCCESS_SEND_JOB_STATUSES = {"completed"}
|
||||
|
||||
|
||||
def _normalize_status(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _is_terminal_status(value: str) -> bool:
|
||||
return _normalize_status(value) in TERMINAL_SEND_JOB_STATUSES
|
||||
|
||||
|
||||
def _is_success_status(value: str) -> bool:
|
||||
return _normalize_status(value) in SUCCESS_SEND_JOB_STATUSES
|
||||
|
||||
|
||||
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
|
||||
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
|
||||
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
|
||||
body_text = (campaign.text_template or "").strip()
|
||||
|
||||
payload = {
|
||||
"list_id": list_id,
|
||||
"name": campaign.title,
|
||||
"subject": campaign.subject_template,
|
||||
}
|
||||
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
|
||||
if tenant_id:
|
||||
payload["tenant_id"] = tenant_id
|
||||
if body_html:
|
||||
payload["body_html"] = body_html
|
||||
if body_text:
|
||||
payload["body_text"] = body_text
|
||||
if site_base_url:
|
||||
one_click_path = (settings_obj.one_click_endpoint_path or "/u/unsubscribe").strip()
|
||||
if not one_click_path.startswith("/"):
|
||||
one_click_path = f"/{one_click_path}"
|
||||
unsubscribe_url_template = f"{site_base_url}{one_click_path}?token={{{{unsubscribe_token}}}}"
|
||||
payload["template"] = {
|
||||
# Send Engine uses this key to emit List-Unsubscribe headers.
|
||||
"list_unsubscribe_url_template": unsubscribe_url_template,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _create_dispatch_record(
|
||||
*,
|
||||
campaign: NewsletterCampaign,
|
||||
status: str,
|
||||
retry_count: int,
|
||||
response_status: int | None,
|
||||
response_payload: dict,
|
||||
error_message: str = "",
|
||||
):
|
||||
NewsletterDispatchRecord.objects.create(
|
||||
campaign=campaign,
|
||||
status=status,
|
||||
retry_count=max(0, int(retry_count)),
|
||||
response_status=response_status,
|
||||
response_payload=response_payload or {},
|
||||
error_message=error_message or "",
|
||||
)
|
||||
|
||||
|
||||
def _schedule_retry(*, campaign: NewsletterCampaign, attempt: int, response_status: int, response_payload: dict, error_message: str, settings_obj: NewsletterSystemSettings):
|
||||
max_attempts = max(0, int(settings_obj.send_engine_retry_max_attempts or 0))
|
||||
interval = max(1, int(settings_obj.send_engine_retry_interval_seconds or 300))
|
||||
if attempt >= max_attempts:
|
||||
return
|
||||
|
||||
NewsletterDispatchRecord.objects.create(
|
||||
campaign=campaign,
|
||||
status="retry_scheduled",
|
||||
retry_count=attempt + 1,
|
||||
next_retry_at=timezone.now() + timezone.timedelta(seconds=interval),
|
||||
response_status=response_status,
|
||||
response_payload=response_payload or {},
|
||||
error_message=error_message or "",
|
||||
)
|
||||
|
||||
|
||||
def _poll_send_job_to_terminal(*, send_engine: SendEngineClient, send_job_id: str, poll_interval_seconds: int = 3, max_polls: int = 40) -> tuple[bool, int, dict, str]:
|
||||
history = []
|
||||
last_status = ""
|
||||
for _ in range(max(1, int(max_polls))):
|
||||
poll = send_engine.get_send_job(send_job_id)
|
||||
if not poll.ok:
|
||||
error = poll.error
|
||||
if poll.status in (401, 403):
|
||||
error = (
|
||||
f"send engine auth failed when polling send job (HTTP {poll.status}); "
|
||||
"token may miss scope newsletter:send.read"
|
||||
)
|
||||
return False, poll.status, {"send_job_id": send_job_id, "poll_history": history, "last_response": poll.data}, error
|
||||
|
||||
status = _normalize_status(str(poll.data.get("status", "")))
|
||||
last_status = status
|
||||
history.append(
|
||||
{
|
||||
"polled_at": timezone.now().isoformat(),
|
||||
"status": status,
|
||||
"response_status": poll.status,
|
||||
}
|
||||
)
|
||||
if _is_terminal_status(status):
|
||||
return True, poll.status, {"send_job_id": send_job_id, "send_job_status": status, "poll_history": history, "send_job": poll.data}, ""
|
||||
|
||||
time.sleep(max(1, int(poll_interval_seconds)))
|
||||
|
||||
return (
|
||||
False,
|
||||
0,
|
||||
{"send_job_id": send_job_id, "send_job_status": last_status or "unknown", "poll_history": history},
|
||||
f"send job did not reach terminal status after {max(1, int(max_polls))} polls",
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_once(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings) -> dict:
|
||||
send_engine = SendEngineClient(settings_obj)
|
||||
list_id = (campaign.list_id or settings_obj.member_center_list_id or "").strip()
|
||||
if not list_id:
|
||||
return {
|
||||
"ok": False,
|
||||
"response_status": 0,
|
||||
"response_payload": {},
|
||||
"error": "list_id is empty",
|
||||
"send_job_status": "",
|
||||
}
|
||||
|
||||
payload = _build_send_job_payload(campaign=campaign, settings_obj=settings_obj, list_id=list_id)
|
||||
if not payload.get("body_html") and not payload.get("body_text") and not payload.get("template"):
|
||||
return {
|
||||
"ok": False,
|
||||
"response_status": 0,
|
||||
"response_payload": {},
|
||||
"error": "body_html/body_text/template must provide at least one value",
|
||||
"send_job_status": "",
|
||||
}
|
||||
|
||||
create_result = send_engine.create_send_job(payload)
|
||||
if not create_result.ok:
|
||||
create_error = create_result.error
|
||||
if create_result.status in (401, 403):
|
||||
create_error = (
|
||||
f"send engine auth failed when creating send job (HTTP {create_result.status}); "
|
||||
"verify bearer token and scope newsletter:send.write"
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"response_status": create_result.status,
|
||||
"response_payload": {"create_response": create_result.data, "payload": payload},
|
||||
"error": create_error,
|
||||
"send_job_status": "",
|
||||
}
|
||||
|
||||
send_job_id = str(create_result.data.get("send_job_id") or create_result.data.get("id") or "").strip()
|
||||
create_status = _normalize_status(str(create_result.data.get("status", "")))
|
||||
response_payload = {
|
||||
"payload": payload,
|
||||
"create_response": create_result.data,
|
||||
"send_job_id": send_job_id,
|
||||
"send_job_status": create_status,
|
||||
}
|
||||
if not send_job_id:
|
||||
return {
|
||||
"ok": False,
|
||||
"response_status": create_result.status,
|
||||
"response_payload": response_payload,
|
||||
"error": "send_job_id missing from create response",
|
||||
"send_job_status": create_status,
|
||||
}
|
||||
|
||||
if _is_terminal_status(create_status):
|
||||
return {
|
||||
"ok": _is_success_status(create_status),
|
||||
"response_status": create_result.status,
|
||||
"response_payload": response_payload,
|
||||
"error": "" if _is_success_status(create_status) else f"send job terminal status: {create_status}",
|
||||
"send_job_status": create_status,
|
||||
}
|
||||
|
||||
poll_ok, poll_status, poll_payload, poll_error = _poll_send_job_to_terminal(
|
||||
send_engine=send_engine,
|
||||
send_job_id=send_job_id,
|
||||
)
|
||||
merged_payload = {**response_payload, **poll_payload}
|
||||
final_status = _normalize_status(str(merged_payload.get("send_job_status", "")))
|
||||
if poll_ok and _is_success_status(final_status):
|
||||
return {
|
||||
"ok": True,
|
||||
"response_status": poll_status,
|
||||
"response_payload": merged_payload,
|
||||
"error": "",
|
||||
"send_job_status": final_status,
|
||||
}
|
||||
|
||||
error_message = poll_error or f"send job terminal status: {final_status or 'unknown'}"
|
||||
return {
|
||||
"ok": False,
|
||||
"response_status": poll_status,
|
||||
"response_payload": merged_payload,
|
||||
"error": error_message,
|
||||
"send_job_status": final_status,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_campaign(campaign: NewsletterCampaign, schedule_retry_on_failure: bool = True) -> dict:
|
||||
settings_obj = NewsletterSystemSettings.load()
|
||||
campaign.status = NewsletterCampaign.STATUS_SENDING
|
||||
campaign.last_error = ""
|
||||
campaign.save(update_fields=["status", "last_error", "updated_at"])
|
||||
|
||||
result = _dispatch_once(campaign=campaign, settings_obj=settings_obj)
|
||||
if result["ok"]:
|
||||
campaign.sent_at = timezone.now()
|
||||
campaign.status = NewsletterCampaign.STATUS_SENT
|
||||
campaign.last_error = ""
|
||||
campaign.save(update_fields=["sent_at", "status", "last_error", "updated_at"])
|
||||
_create_dispatch_record(
|
||||
campaign=campaign,
|
||||
status="sent",
|
||||
retry_count=0,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
)
|
||||
return {"sent": 1, "failed": 0, "error": "", "send_job_status": result["send_job_status"]}
|
||||
|
||||
campaign.status = NewsletterCampaign.STATUS_FAILED
|
||||
campaign.last_error = result["error"]
|
||||
campaign.save(update_fields=["status", "last_error", "updated_at"])
|
||||
_create_dispatch_record(
|
||||
campaign=campaign,
|
||||
status="failed",
|
||||
retry_count=0,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
error_message=result["error"],
|
||||
)
|
||||
if schedule_retry_on_failure:
|
||||
_schedule_retry(
|
||||
campaign=campaign,
|
||||
attempt=0,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
error_message=result["error"],
|
||||
settings_obj=settings_obj,
|
||||
)
|
||||
return {"sent": 0, "failed": 1, "error": result["error"], "send_job_status": result["send_job_status"]}
|
||||
|
||||
|
||||
def process_due_retries(limit: int = 200) -> dict:
|
||||
settings_obj = NewsletterSystemSettings.load()
|
||||
now = timezone.now()
|
||||
due_retries = list(
|
||||
NewsletterDispatchRecord.objects.filter(
|
||||
status="retry_scheduled",
|
||||
next_retry_at__isnull=False,
|
||||
next_retry_at__lte=now,
|
||||
)
|
||||
.select_related("campaign")
|
||||
.order_by("next_retry_at", "id")[: max(1, int(limit))]
|
||||
)
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
processed = 0
|
||||
|
||||
for retry in due_retries:
|
||||
processed += 1
|
||||
campaign = retry.campaign
|
||||
retry.status = "retry_consumed"
|
||||
retry.save(update_fields=["status"])
|
||||
|
||||
if campaign.status == NewsletterCampaign.STATUS_SENT:
|
||||
continue
|
||||
|
||||
result = _dispatch_once(campaign=campaign, settings_obj=settings_obj)
|
||||
if result["ok"]:
|
||||
sent += 1
|
||||
campaign.sent_at = timezone.now()
|
||||
campaign.status = NewsletterCampaign.STATUS_SENT
|
||||
campaign.last_error = ""
|
||||
campaign.save(update_fields=["sent_at", "status", "last_error", "updated_at"])
|
||||
_create_dispatch_record(
|
||||
campaign=campaign,
|
||||
status="sent",
|
||||
retry_count=retry.retry_count,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
)
|
||||
else:
|
||||
failed += 1
|
||||
campaign.status = NewsletterCampaign.STATUS_FAILED
|
||||
campaign.last_error = result["error"]
|
||||
campaign.save(update_fields=["status", "last_error", "updated_at"])
|
||||
_create_dispatch_record(
|
||||
campaign=campaign,
|
||||
status="failed",
|
||||
retry_count=retry.retry_count,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
error_message=result["error"],
|
||||
)
|
||||
_schedule_retry(
|
||||
campaign=campaign,
|
||||
attempt=retry.retry_count,
|
||||
response_status=result["response_status"],
|
||||
response_payload=result["response_payload"],
|
||||
error_message=result["error"],
|
||||
settings_obj=settings_obj,
|
||||
)
|
||||
|
||||
return {"processed_retries": processed, "sent_retries": sent, "failed_retries": failed}
|
||||
|
||||
|
||||
def dispatch_due_campaigns(limit: int = 20) -> dict:
|
||||
retry_result = process_due_retries(limit=200)
|
||||
now = timezone.now()
|
||||
due_campaigns = list(
|
||||
NewsletterCampaign.objects.filter(
|
||||
status=NewsletterCampaign.STATUS_SCHEDULED,
|
||||
scheduled_at__isnull=False,
|
||||
scheduled_at__lte=now,
|
||||
)
|
||||
.order_by("scheduled_at", "id")[: max(1, int(limit))]
|
||||
)
|
||||
|
||||
total_sent = 0
|
||||
total_failed = 0
|
||||
processed = 0
|
||||
for campaign in due_campaigns:
|
||||
processed += 1
|
||||
result = dispatch_campaign(campaign)
|
||||
total_sent += int(result.get("sent", 0))
|
||||
if result.get("failed", 0):
|
||||
total_failed += 1
|
||||
|
||||
return {
|
||||
"processed_campaigns": processed,
|
||||
"sent_recipients": total_sent,
|
||||
"failed_campaigns": total_failed,
|
||||
**retry_result,
|
||||
}
|
||||
71
innovedus_cms/base/security.py
Normal file
71
innovedus_cms/base/security.py
Normal file
@ -0,0 +1,71 @@
|
||||
import base64
|
||||
import hmac
|
||||
import os
|
||||
from hashlib import sha256
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.crypto import pbkdf2
|
||||
|
||||
|
||||
_VERSION = b"v1"
|
||||
_SALT_SIZE = 16
|
||||
_TAG_SIZE = 32
|
||||
_PREFIX = "enc1:"
|
||||
|
||||
|
||||
def _derive_key(salt: bytes) -> bytes:
|
||||
return pbkdf2(settings.SECRET_KEY, salt, 260000, digest=sha256)
|
||||
|
||||
|
||||
def _keystream(key: bytes, length: int) -> bytes:
|
||||
out = bytearray()
|
||||
counter = 0
|
||||
while len(out) < length:
|
||||
block = sha256(key + counter.to_bytes(4, "big")).digest()
|
||||
out.extend(block)
|
||||
counter += 1
|
||||
return bytes(out[:length])
|
||||
|
||||
|
||||
def encrypt_text(plaintext: str) -> str:
|
||||
if not plaintext:
|
||||
return ""
|
||||
|
||||
raw = plaintext.encode("utf-8")
|
||||
salt = os.urandom(_SALT_SIZE)
|
||||
key = _derive_key(salt)
|
||||
stream = _keystream(key, len(raw))
|
||||
ciphertext = bytes(a ^ b for a, b in zip(raw, stream))
|
||||
tag = hmac.new(key, ciphertext, sha256).digest()
|
||||
payload = _VERSION + salt + tag + ciphertext
|
||||
return _PREFIX + base64.urlsafe_b64encode(payload).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_text(token: str) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
if not token.startswith(_PREFIX):
|
||||
return token
|
||||
|
||||
payload = base64.urlsafe_b64decode(token[len(_PREFIX) :].encode("ascii"))
|
||||
version = payload[:2]
|
||||
if version != _VERSION:
|
||||
raise ValueError("Unsupported encrypted payload version")
|
||||
|
||||
salt_start = 2
|
||||
salt_end = salt_start + _SALT_SIZE
|
||||
salt = payload[salt_start:salt_end]
|
||||
|
||||
tag_start = salt_end
|
||||
tag_end = tag_start + _TAG_SIZE
|
||||
tag = payload[tag_start:tag_end]
|
||||
|
||||
ciphertext = payload[tag_end:]
|
||||
key = _derive_key(salt)
|
||||
expected = hmac.new(key, ciphertext, sha256).digest()
|
||||
if not hmac.compare_digest(tag, expected):
|
||||
raise ValueError("Encrypted payload validation failed")
|
||||
|
||||
stream = _keystream(key, len(ciphertext))
|
||||
raw = bytes(a ^ b for a, b in zip(ciphertext, stream))
|
||||
return raw.decode("utf-8")
|
||||
173
innovedus_cms/base/static/css/newsletter.css
Normal file
173
innovedus_cms/base/static/css/newsletter.css
Normal file
@ -0,0 +1,173 @@
|
||||
.newsletter-page {
|
||||
background: #0e1b42 url("../img/newsletter_bg.png") center center / cover no-repeat fixed;
|
||||
}
|
||||
|
||||
.newsletter-page .site-main > .site-container {
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.newsletter-status {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.newsletter-status h1 {
|
||||
font-size: 32px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.newsletter-status p {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.newsletter-status-message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form input[type="email"],
|
||||
.newsletter-unsubscribe-form input[type="text"] {
|
||||
width: 300px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
background: #ffffff4d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form button {
|
||||
width: 131px;
|
||||
height: 49px;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.newsletter-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: #ffffff80;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::before,
|
||||
.newsletter-back-link__icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
width: 15px;
|
||||
height: 1.5px;
|
||||
background: #0e1b42;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::before {
|
||||
top: 21px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::after {
|
||||
top: 31px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.newsletter-back-link__text {
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 575px) and (max-width: 767px) {
|
||||
.newsletter-page .site-main > .site-container {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.newsletter-status {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.newsletter-status h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.newsletter-status p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form input[type="email"],
|
||||
.newsletter-unsubscribe-form input[type="text"] {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 574px) {
|
||||
.newsletter-page .site-main > .site-container {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.newsletter-status {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.newsletter-status h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.newsletter-status p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form input[type="email"],
|
||||
.newsletter-unsubscribe-form input[type="text"] {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.newsletter-unsubscribe-form button {
|
||||
width: 90px;
|
||||
height: 31px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::before {
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::after {
|
||||
top: 25px;
|
||||
}
|
||||
|
||||
.newsletter-back-link__icon::before,
|
||||
.newsletter-back-link__icon::after {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.newsletter-back-link__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
BIN
innovedus_cms/base/static/img/newsletter_bg.png
Normal file
BIN
innovedus_cms/base/static/img/newsletter_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
19
innovedus_cms/base/templates/base/newsletter/status.html
Normal file
19
innovedus_cms/base/templates/base/newsletter/status.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block body_class %}template-darkbackground newsletter-page{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/newsletter.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="newsletter-status{% if success %} is-success{% else %} is-failure{% endif %}">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="newsletter-status-message">{{ message|safe }}</div>
|
||||
<a class="newsletter-back-link" href="/">
|
||||
<span class="newsletter-back-link__icon" aria-hidden="true"></span>
|
||||
<span class="newsletter-back-link__text">回到首頁</span>
|
||||
</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block body_class %}template-darkbackground newsletter-page{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/newsletter.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="newsletter-status{% if can_submit %} is-warning{% else %} is-failure{% endif %}">
|
||||
<h1>很遺憾聽到您取消電子報訂閱</h1>
|
||||
<div class="newsletter-status-message">{{ intro_message|safe }}</div>
|
||||
|
||||
{% if can_submit %}
|
||||
<form method="post" action="{% url 'newsletter_unsubscribe' %}" class="newsletter-unsubscribe-form">
|
||||
{% csrf_token %}
|
||||
{{ form.email }}
|
||||
{{ form.token }}
|
||||
<button type="submit">確認退訂</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>退訂連結已失效或缺少必要參數。</p>
|
||||
{% endif %}
|
||||
|
||||
<a class="newsletter-back-link" href="/">
|
||||
<span class="newsletter-back-link__icon" aria-hidden="true"></span>
|
||||
<span class="newsletter-back-link__text">回到首頁</span>
|
||||
</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -1,3 +1,114 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from .newsletter import (
|
||||
extract_token,
|
||||
generate_one_click_token,
|
||||
render_placeholders,
|
||||
render_newsletter_html,
|
||||
verify_one_click_token,
|
||||
)
|
||||
from .models import ContactFormSubmission
|
||||
from .security import decrypt_text, encrypt_text
|
||||
|
||||
|
||||
class NewsletterTemplateTests(TestCase):
|
||||
def test_render_placeholders_replaces_known_keys(self):
|
||||
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
|
||||
rendered = render_placeholders(
|
||||
template,
|
||||
{
|
||||
"confirm_url": "https://example.com/confirm?token=abc",
|
||||
"email": "demo@example.com",
|
||||
"token": "abc",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
rendered,
|
||||
"confirm=https://example.com/confirm?token=abc email=demo@example.com token=abc",
|
||||
)
|
||||
|
||||
def test_extract_token_supports_top_level_and_nested_data(self):
|
||||
self.assertEqual(extract_token({"token": "t1"}), "t1")
|
||||
self.assertEqual(extract_token({"data": {"unsubscribe_token": "t2"}}), "t2")
|
||||
self.assertEqual(extract_token({}), "")
|
||||
|
||||
def test_encrypt_decrypt_roundtrip(self):
|
||||
secret = "mailrelay-secret"
|
||||
encrypted = encrypt_text(secret)
|
||||
self.assertTrue(encrypted.startswith("enc1:"))
|
||||
self.assertNotEqual(encrypted, secret)
|
||||
self.assertEqual(decrypt_text(encrypted), secret)
|
||||
|
||||
def test_one_click_token_roundtrip(self):
|
||||
token = generate_one_click_token(
|
||||
subscriber_id="sub-1",
|
||||
list_id="list-1",
|
||||
site_id="site-1",
|
||||
campaign_id="cmp-1",
|
||||
secret="test-secret",
|
||||
ttl_seconds=60,
|
||||
)
|
||||
payload, error = verify_one_click_token(token, "test-secret")
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(payload["subscriber_id"], "sub-1")
|
||||
self.assertEqual(payload["list_id"], "list-1")
|
||||
|
||||
def test_one_click_token_invalid_signature(self):
|
||||
token = generate_one_click_token(
|
||||
subscriber_id="sub-1",
|
||||
list_id="list-1",
|
||||
site_id="site-1",
|
||||
campaign_id="cmp-1",
|
||||
secret="test-secret",
|
||||
ttl_seconds=60,
|
||||
)
|
||||
payload, error = verify_one_click_token(token, "other-secret")
|
||||
self.assertIsNone(payload)
|
||||
self.assertEqual(error, "invalid")
|
||||
|
||||
def test_render_newsletter_html_absolutizes_relative_links(self):
|
||||
rendered = render_newsletter_html(
|
||||
'<p><a href="/a">A</a><img src="/media/x.jpg"></p>',
|
||||
values={},
|
||||
site_base_url="https://news.example.com",
|
||||
)
|
||||
self.assertIn('href="https://news.example.com/a"', rendered)
|
||||
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
||||
|
||||
def test_contact_form_submit_saves_submission(self):
|
||||
response = self.client.post(
|
||||
reverse("contact_form_submit"),
|
||||
data={
|
||||
"name": "Tester",
|
||||
"contact": "tester@example.com",
|
||||
"email": "tester@example.com",
|
||||
"category": "other",
|
||||
"message": "hello",
|
||||
"source_page": "/",
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(ContactFormSubmission.objects.count(), 1)
|
||||
submission = ContactFormSubmission.objects.first()
|
||||
self.assertEqual(submission.name, "Tester")
|
||||
self.assertEqual(submission.email, "tester@example.com")
|
||||
self.assertEqual(submission.category, "other")
|
||||
|
||||
def test_contact_form_submit_rejects_invalid_email(self):
|
||||
response = self.client.post(
|
||||
reverse("contact_form_submit"),
|
||||
data={
|
||||
"name": "Tester",
|
||||
"contact": "123",
|
||||
"email": "not-an-email",
|
||||
"category": "other",
|
||||
"message": "hello",
|
||||
"source_page": "/",
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(ContactFormSubmission.objects.count(), 0)
|
||||
|
||||
@ -1,3 +1,520 @@
|
||||
from django.shortcuts import render
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
# Create your views here.
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
||||
from .models import (
|
||||
MailSmtpSettings,
|
||||
NewsletterCampaign,
|
||||
NewsletterSystemSettings,
|
||||
NewsletterTemplateSettings,
|
||||
OneClickUnsubscribeAudit,
|
||||
SystemNotificationMailSettings,
|
||||
)
|
||||
from .newsletter import (
|
||||
MemberCenterClient,
|
||||
build_from_email,
|
||||
extract_token,
|
||||
render_placeholders,
|
||||
send_contact_notification_email,
|
||||
send_contact_user_email,
|
||||
send_subscribe_email,
|
||||
)
|
||||
from .newsletter_scheduler import dispatch_campaign
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_settings(request_or_site=None):
|
||||
return (
|
||||
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||
NewsletterTemplateSettings.load(request_or_site=request_or_site),
|
||||
MailSmtpSettings.load(request_or_site=request_or_site),
|
||||
)
|
||||
|
||||
|
||||
def _build_context(*, title: str, message: str, success: bool):
|
||||
return {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"success": success,
|
||||
}
|
||||
|
||||
|
||||
def _render_contact_template(template: str, values: dict[str, str]) -> str:
|
||||
rendered = template or ""
|
||||
for key, value in values.items():
|
||||
rendered = rendered.replace(f"{{{{{key}}}}}", value)
|
||||
return rendered
|
||||
|
||||
|
||||
@require_POST
|
||||
def contact_form_submit(request):
|
||||
form = ContactForm(request.POST)
|
||||
if not form.is_valid():
|
||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
||||
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
||||
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||
|
||||
submission = form.save(commit=False)
|
||||
submission.ip_address = request.META.get("REMOTE_ADDR") or ""
|
||||
submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
submission.save()
|
||||
notification_settings = SystemNotificationMailSettings.load(request_or_site=request)
|
||||
smtp_settings = MailSmtpSettings.load(request_or_site=request)
|
||||
subject_prefix = (notification_settings.contact_form_subject_prefix or "").strip()
|
||||
subject = f"{subject_prefix} {submission.get_category_display()}".strip()
|
||||
escaped_message_html = escape(submission.message).replace("\n", "<br>")
|
||||
text_body = (
|
||||
f"Name: {submission.name}\n"
|
||||
f"Email: {submission.email}\n"
|
||||
f"Contact: {submission.contact}\n"
|
||||
f"Category: {submission.get_category_display()}\n"
|
||||
f"Source Page: {submission.source_page}\n\n"
|
||||
f"Message:\n{submission.message}\n"
|
||||
)
|
||||
html_body = (
|
||||
"<p><strong>Name:</strong> "
|
||||
f"{escape(submission.name)}</p>"
|
||||
"<p><strong>Email:</strong> "
|
||||
f"{escape(submission.email)}</p>"
|
||||
"<p><strong>Contact:</strong> "
|
||||
f"{escape(submission.contact)}</p>"
|
||||
"<p><strong>Category:</strong> "
|
||||
f"{escape(submission.get_category_display())}</p>"
|
||||
"<p><strong>Source Page:</strong> "
|
||||
f"{escape(submission.source_page or '')}</p>"
|
||||
"<p><strong>Message:</strong></p>"
|
||||
f"<p>{escaped_message_html}</p>"
|
||||
)
|
||||
try:
|
||||
send_contact_notification_email(
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
notification_config=notification_settings,
|
||||
smtp_config=smtp_settings,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("contact form admin notification email failed: %s", exc)
|
||||
|
||||
if submission.email:
|
||||
values_text = {
|
||||
"name": submission.name,
|
||||
"email": submission.email,
|
||||
"contact": submission.contact,
|
||||
"category": submission.get_category_display(),
|
||||
"message": submission.message,
|
||||
"source_page": submission.source_page or "",
|
||||
}
|
||||
values_html = {
|
||||
"name": escape(submission.name),
|
||||
"email": escape(submission.email),
|
||||
"contact": escape(submission.contact),
|
||||
"category": escape(submission.get_category_display()),
|
||||
"message": escape(submission.message).replace("\n", "<br>"),
|
||||
"source_page": escape(submission.source_page or ""),
|
||||
}
|
||||
user_subject = _render_contact_template(
|
||||
notification_settings.contact_form_user_subject_template,
|
||||
values_text,
|
||||
)
|
||||
user_text = _render_contact_template(
|
||||
notification_settings.contact_form_user_text_template,
|
||||
values_text,
|
||||
)
|
||||
user_html = _render_contact_template(
|
||||
notification_settings.contact_form_user_html_template,
|
||||
values_html,
|
||||
)
|
||||
try:
|
||||
send_contact_user_email(
|
||||
to_email=submission.email,
|
||||
subject=user_subject,
|
||||
text_body=user_text,
|
||||
html_body=user_html,
|
||||
notification_config=notification_settings,
|
||||
smtp_config=smtp_settings,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("contact form user copy email failed: %s", exc)
|
||||
|
||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||
return JsonResponse({"success": True})
|
||||
|
||||
messages.success(request, "感謝您的來信,我們已收到您的表單。")
|
||||
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||
|
||||
|
||||
def _extract_one_click_token(request):
|
||||
token = (request.GET.get("token") or "").strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
if request.method == "POST":
|
||||
token = (request.POST.get("token") or "").strip()
|
||||
if token:
|
||||
return token
|
||||
if request.body:
|
||||
try:
|
||||
body = json.loads(request.body.decode("utf-8"))
|
||||
token = (body.get("token") or "").strip()
|
||||
if token:
|
||||
return token
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _handle_one_click_unsubscribe(*, request, token):
|
||||
system_settings, _, _ = _load_settings(request)
|
||||
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else ""
|
||||
|
||||
if not token:
|
||||
return False, 400, "退訂連結無效。"
|
||||
|
||||
existing = OneClickUnsubscribeAudit.objects.filter(
|
||||
token_hash=token_hash,
|
||||
status__in=["success", "already_unsubscribed"],
|
||||
).first()
|
||||
if existing:
|
||||
return True, 200, "您已完成退訂。"
|
||||
|
||||
result = MemberCenterClient(system_settings).unsubscribe({"token": token})
|
||||
response_data = result.data if isinstance(result.data, dict) else {}
|
||||
|
||||
if result.ok:
|
||||
already = bool(response_data.get("already_unsubscribed"))
|
||||
success = bool(response_data.get("success", True)) or already
|
||||
if success:
|
||||
OneClickUnsubscribeAudit.objects.update_or_create(
|
||||
token_hash=token_hash,
|
||||
defaults={
|
||||
"subscriber_id": "",
|
||||
"list_id": "",
|
||||
"site_id": "",
|
||||
"campaign_id": "",
|
||||
"status": "already_unsubscribed" if already else "success",
|
||||
"response_status": result.status,
|
||||
"response_payload": response_data,
|
||||
},
|
||||
)
|
||||
return True, 200, "您已完成退訂。"
|
||||
|
||||
OneClickUnsubscribeAudit.objects.update_or_create(
|
||||
token_hash=token_hash,
|
||||
defaults={
|
||||
"subscriber_id": "",
|
||||
"list_id": "",
|
||||
"site_id": "",
|
||||
"campaign_id": "",
|
||||
"status": "failed",
|
||||
"response_status": result.status,
|
||||
"response_payload": response_data,
|
||||
"error_message": result.error,
|
||||
},
|
||||
)
|
||||
return False, 502, "退訂服務暫時無法使用。"
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def newsletter_subscribe(request):
|
||||
form = NewsletterSubscribeForm(request.POST)
|
||||
system_settings, template_settings, smtp_settings = _load_settings(request)
|
||||
|
||||
if not form.is_valid():
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱失敗",
|
||||
message="請輸入正確 email。",
|
||||
success=False,
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
email = form.cleaned_data["email"].strip().lower()
|
||||
client = MemberCenterClient(system_settings)
|
||||
|
||||
subscribe_payload = {
|
||||
"email": email,
|
||||
"list_id": system_settings.member_center_list_id,
|
||||
"source": "wagtail",
|
||||
}
|
||||
subscribe_result = client.subscribe(subscribe_payload)
|
||||
if not subscribe_result.ok:
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱失敗",
|
||||
message="訂閱服務暫時無法使用,請稍後再試。",
|
||||
success=False,
|
||||
),
|
||||
status=502,
|
||||
)
|
||||
|
||||
token = extract_token(subscribe_result.data)
|
||||
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm"))
|
||||
unsubscribe_url = request.build_absolute_uri(reverse("newsletter_unsubscribe"))
|
||||
if token:
|
||||
query = urlencode({"token": token, "email": email})
|
||||
confirm_url = f"{confirm_url}?{query}"
|
||||
unsubscribe_url = f"{unsubscribe_url}?{query}"
|
||||
|
||||
values = {
|
||||
"token": token,
|
||||
"email": email,
|
||||
"list_id": system_settings.member_center_list_id,
|
||||
"tenant_id": system_settings.member_center_tenant_id,
|
||||
"confirm_url": confirm_url,
|
||||
"unsubscribe_url": unsubscribe_url,
|
||||
}
|
||||
|
||||
try:
|
||||
send_subscribe_email(
|
||||
to_email=email,
|
||||
subject=render_placeholders(template_settings.subscribe_subject_template, values),
|
||||
text_body=render_placeholders(template_settings.subscribe_text_template, values),
|
||||
html_body=render_placeholders(template_settings.subscribe_html_template, values),
|
||||
config=system_settings,
|
||||
smtp_config=smtp_settings,
|
||||
)
|
||||
except Exception:
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱失敗",
|
||||
message="確認信寄送失敗,請稍後再試。",
|
||||
success=False,
|
||||
),
|
||||
status=502,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱確認已送出!",
|
||||
message="感謝您的訂閱<br>下一步,請前往訂閱的信箱收取確認信函<br><br>在信函中點擊連結",
|
||||
success=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@require_GET
|
||||
def newsletter_confirm(request):
|
||||
token = (request.GET.get("token") or "").strip()
|
||||
email = (request.GET.get("email") or "").strip().lower()
|
||||
system_settings, template_settings, _ = _load_settings(request)
|
||||
|
||||
if not token:
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱認證失敗",
|
||||
message=template_settings.confirm_failure_template,
|
||||
success=False,
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
result = MemberCenterClient(system_settings).confirm(token)
|
||||
if result.ok:
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱認證成功!",
|
||||
message=template_settings.confirm_success_template,
|
||||
success=True,
|
||||
),
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="訂閱認證失敗",
|
||||
message=template_settings.confirm_failure_template,
|
||||
success=False,
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def newsletter_unsubscribe(request):
|
||||
system_settings, template_settings, _ = _load_settings(request)
|
||||
client = MemberCenterClient(system_settings)
|
||||
|
||||
if request.method == "GET":
|
||||
token = (request.GET.get("token") or "").strip()
|
||||
email = (request.GET.get("email") or "").strip().lower()
|
||||
|
||||
if not token and email:
|
||||
token_result = client.request_unsubscribe_token(
|
||||
{
|
||||
"email": email,
|
||||
"list_id": system_settings.member_center_list_id,
|
||||
}
|
||||
)
|
||||
if token_result.ok:
|
||||
token = extract_token(token_result.data)
|
||||
|
||||
form = NewsletterUnsubscribeForm(initial={"email": email, "token": token})
|
||||
can_submit = bool(token)
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/unsubscribe.html",
|
||||
{
|
||||
"form": form,
|
||||
"can_submit": can_submit,
|
||||
"intro_message": template_settings.unsubscribe_intro_template,
|
||||
},
|
||||
status=200 if can_submit else 400,
|
||||
)
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["GET", "POST"])
|
||||
|
||||
form = NewsletterUnsubscribeForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="退訂失敗",
|
||||
message=template_settings.unsubscribe_failure_template,
|
||||
success=False,
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"token": form.cleaned_data["token"],
|
||||
}
|
||||
result = client.unsubscribe(payload)
|
||||
|
||||
if result.ok:
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="退訂成功",
|
||||
message=template_settings.unsubscribe_success_template,
|
||||
success=True,
|
||||
),
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="退訂失敗",
|
||||
message=template_settings.unsubscribe_failure_template,
|
||||
success=False,
|
||||
),
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def one_click_unsubscribe(request):
|
||||
token = _extract_one_click_token(request)
|
||||
ok, status_code, message = _handle_one_click_unsubscribe(request=request, token=token)
|
||||
|
||||
if request.method == "POST":
|
||||
body = {"success": ok}
|
||||
if not ok and status_code in (400, 410):
|
||||
body["error"] = message
|
||||
return JsonResponse(body, status=status_code)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"base/newsletter/status.html",
|
||||
_build_context(
|
||||
title="退訂成功" if ok else "退訂失敗",
|
||||
message=f"<p>{message}</p>",
|
||||
success=ok,
|
||||
),
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_GET
|
||||
def newsletter_campaign_send_now(request, campaign_id: int):
|
||||
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
|
||||
if campaign.status == NewsletterCampaign.STATUS_SENDING:
|
||||
messages.error(request, "Campaign is currently sending.")
|
||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||
|
||||
result = dispatch_campaign(campaign, schedule_retry_on_failure=False)
|
||||
if result.get("failed", 0):
|
||||
messages.error(
|
||||
request,
|
||||
f"Send now completed with failures. sent={result.get('sent', 0)} failed={result.get('failed', 0)}",
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"Send now completed. sent={result.get('sent', 0)} failed={result.get('failed', 0)}",
|
||||
)
|
||||
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def newsletter_smtp_test(request):
|
||||
to_email = (request.POST.get("smtp_test_email") or "").strip().lower()
|
||||
redirect_to = request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home")
|
||||
|
||||
if not to_email:
|
||||
messages.error(request, "請輸入測試收件 Email。")
|
||||
return redirect(redirect_to)
|
||||
|
||||
try:
|
||||
validate_email(to_email)
|
||||
except ValidationError:
|
||||
messages.error(request, "測試收件 Email 格式不正確。")
|
||||
return redirect(redirect_to)
|
||||
|
||||
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
|
||||
smtp_settings = MailSmtpSettings.load(request_or_site=request)
|
||||
try:
|
||||
sent_count = send_subscribe_email(
|
||||
to_email=to_email,
|
||||
subject="[SMTP Test] Newsletter SMTP 設定測試",
|
||||
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
|
||||
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
|
||||
config=settings_obj,
|
||||
smtp_config=smtp_settings,
|
||||
)
|
||||
except Exception as exc:
|
||||
messages.error(request, f"測試信寄送失敗:{exc}")
|
||||
return redirect(redirect_to)
|
||||
|
||||
from_email = build_from_email(settings_obj.sender_name, settings_obj.sender_email)
|
||||
messages.success(
|
||||
request,
|
||||
f"SMTP 已接受請求(sent_count={sent_count}),from={from_email or 'settings.DEFAULT_FROM_EMAIL'},to={to_email}",
|
||||
)
|
||||
return redirect(redirect_to)
|
||||
|
||||
103
innovedus_cms/base/wagtail_hooks.py
Normal file
103
innovedus_cms/base/wagtail_hooks.py
Normal file
@ -0,0 +1,103 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from wagtail import hooks
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from wagtail.admin.rich_text import DraftailRichTextArea
|
||||
from wagtail.admin.widgets import Button
|
||||
from wagtail.permission_policies import ModelPermissionPolicy
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
|
||||
|
||||
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
|
||||
|
||||
|
||||
class NewsletterCampaignCreateView(CreateView):
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
if (initial.get("list_id") or "").strip():
|
||||
return initial
|
||||
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
|
||||
if default_list_id:
|
||||
initial["list_id"] = default_list_id
|
||||
return initial
|
||||
|
||||
|
||||
class NewsletterCampaignViewSet(SnippetViewSet):
|
||||
model = NewsletterCampaign
|
||||
icon = "mail"
|
||||
menu_label = _("Newsletter campaigns")
|
||||
menu_order = 250
|
||||
add_to_admin_menu = True
|
||||
add_view_class = NewsletterCampaignCreateView
|
||||
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["title", "list_id", "subject_template"]
|
||||
panels = [
|
||||
FieldPanel("title"),
|
||||
FieldPanel(
|
||||
"list_id",
|
||||
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
|
||||
),
|
||||
FieldPanel("subject_template"),
|
||||
FieldPanel(
|
||||
"html_template",
|
||||
widget=DraftailRichTextArea(
|
||||
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
|
||||
),
|
||||
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
|
||||
),
|
||||
FieldPanel("text_template"),
|
||||
FieldPanel("scheduled_at"),
|
||||
]
|
||||
|
||||
|
||||
class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
|
||||
def user_has_permission(self, user, action):
|
||||
if action in {"add", "change", "delete"}:
|
||||
return False
|
||||
return super().user_has_permission(user, action)
|
||||
|
||||
|
||||
class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||
model = NewsletterDispatchRecord
|
||||
icon = "tasks"
|
||||
menu_label = _("Newsletter dispatch records")
|
||||
menu_order = 251
|
||||
add_to_admin_menu = True
|
||||
inspect_view_enabled = True
|
||||
copy_view_enabled = False
|
||||
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
|
||||
list_display = [
|
||||
"campaign",
|
||||
"email",
|
||||
"subscriber_id",
|
||||
"status",
|
||||
"retry_count",
|
||||
"next_retry_at",
|
||||
"response_status",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["status", "created_at"]
|
||||
search_fields = ["email", "subscriber_id", "campaign__title"]
|
||||
|
||||
|
||||
register_snippet(NewsletterCampaignViewSet)
|
||||
register_snippet(NewsletterDispatchRecordViewSet)
|
||||
|
||||
|
||||
@hooks.register("register_snippet_listing_buttons")
|
||||
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
|
||||
if not isinstance(snippet, NewsletterCampaign):
|
||||
return
|
||||
if not user.is_staff:
|
||||
return
|
||||
if snippet.status == NewsletterCampaign.STATUS_SENDING:
|
||||
return
|
||||
|
||||
label = _("Send now")
|
||||
yield Button(
|
||||
label,
|
||||
reverse("newsletter_campaign_send_now", args=[snippet.pk]),
|
||||
icon_name="mail",
|
||||
priority=15,
|
||||
)
|
||||
@ -1,8 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Run pending migrations and collect static assets before starting the app
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
APP_ROLE="${APP_ROLE:-web}"
|
||||
|
||||
exec "$@"
|
||||
# Run pending migrations before starting any role.
|
||||
python manage.py migrate --noinput
|
||||
|
||||
if [ "$APP_ROLE" = "scheduler" ]; then
|
||||
SCHEDULER_INTERVAL_SECONDS="${SCHEDULER_INTERVAL_SECONDS:-60}"
|
||||
while true; do
|
||||
python manage.py run_newsletter_scheduler --limit 20
|
||||
sleep "$SCHEDULER_INTERVAL_SECONDS"
|
||||
done
|
||||
else
|
||||
python manage.py collectstatic --noinput
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
@ -204,9 +204,9 @@ class LatestPage(Page, CategoryMixin, BreadcrumbMixin):
|
||||
def get_context(self, request):
|
||||
context = super().get_context(request)
|
||||
context["category_sections"] = [self.get_latest_articles(request)]
|
||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
context["breadcrumbs"] = breadcrumbs
|
||||
context["breadcrumb_root"] = site_root
|
||||
# breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
# context["breadcrumbs"] = breadcrumbs
|
||||
# context["breadcrumb_root"] = site_root
|
||||
return context
|
||||
|
||||
|
||||
@ -216,9 +216,9 @@ class TrendingPage(Page, CategoryMixin, BreadcrumbMixin):
|
||||
def get_context(self, request):
|
||||
context = super().get_context(request)
|
||||
context["category_sections"] = [self.get_trending_articles(request)]
|
||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
context["breadcrumbs"] = breadcrumbs
|
||||
context["breadcrumb_root"] = site_root
|
||||
# breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
# context["breadcrumbs"] = breadcrumbs
|
||||
# context["breadcrumb_root"] = site_root
|
||||
return context
|
||||
|
||||
|
||||
@ -311,8 +311,8 @@ class ArticlePage(Page, BreadcrumbMixin):
|
||||
context = super().get_context(request)
|
||||
|
||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||
context["breadcrumbs"] = breadcrumbs
|
||||
context["breadcrumb_root"] = site_root
|
||||
# context["breadcrumbs"] = breadcrumbs
|
||||
# context["breadcrumb_root"] = site_root
|
||||
category_crumbs = [
|
||||
crumb
|
||||
for crumb in breadcrumbs
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.page-article-list{
|
||||
/* .page-article-list{
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
} */
|
||||
|
||||
.first-article {
|
||||
display: grid;
|
||||
@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
.first-article .first-article-title {
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #f4a41c;
|
||||
margin: 0;
|
||||
@ -123,7 +123,7 @@
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 40px;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
@ -152,6 +152,15 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pagination a.disabled {
|
||||
pointer-events: none;
|
||||
color: #0e1b4266;
|
||||
}
|
||||
|
||||
.pagination a.disabled button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pagination .prev-page span,
|
||||
.pagination .next-page span {
|
||||
height: 44px;
|
||||
@ -199,6 +208,10 @@
|
||||
grid-template-columns: 318px 1fr;
|
||||
}
|
||||
|
||||
.first-article .first-article-title {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.first-article .first-article-image {
|
||||
width: 318px;
|
||||
}
|
||||
@ -208,6 +221,14 @@
|
||||
height: 290px;
|
||||
}
|
||||
|
||||
.first-article .first-article-intro {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.first-article .first-article-body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-list-item {
|
||||
grid-template-columns: 318px 1fr;
|
||||
}
|
||||
@ -219,6 +240,10 @@
|
||||
.article-thumb img{
|
||||
width: 318px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@ -235,10 +260,18 @@
|
||||
"image"
|
||||
"content";
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 575px) and (max-width: 767px) {
|
||||
.first-article .first-article-image {
|
||||
.first-article .first-article-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.first-article .first-article-image {
|
||||
width: 426px;
|
||||
}
|
||||
|
||||
@ -247,6 +280,14 @@
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.first-article .first-article-intro {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.first-article .first-article-body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-thumb {
|
||||
width: 426px;
|
||||
}
|
||||
@ -258,6 +299,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 574px) {
|
||||
.first-article .first-article-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.first-article .first-article-image {
|
||||
width: 300px;
|
||||
}
|
||||
@ -267,6 +312,18 @@
|
||||
height: 218px;
|
||||
}
|
||||
|
||||
.first-article .first-article-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.first-article .first-article-intro {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.first-article .first-article-body {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.article-thumb {
|
||||
width: 300px;
|
||||
}
|
||||
@ -275,4 +332,8 @@
|
||||
width: 300px;
|
||||
height: 218px;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@
|
||||
|
||||
.article-content p {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.article-content .intro {
|
||||
@ -319,6 +320,10 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
bottom: 20px;
|
||||
}
|
||||
@ -337,6 +342,10 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.article-content .intro {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"image content";
|
||||
gap: 16px 40px;
|
||||
align-items: start;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-image {
|
||||
@ -41,9 +42,10 @@
|
||||
}
|
||||
|
||||
.block-first-article .first-article-title {
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
line-height: 1.25;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
color: #0e1b42;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@ -97,8 +99,16 @@
|
||||
}
|
||||
|
||||
.block-first-article .first-article-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-intro {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-body {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@ -121,9 +131,20 @@
|
||||
}
|
||||
|
||||
.block-first-article .first-article-title {
|
||||
font-size: 36px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-date {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-intro {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-body {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 574px) {
|
||||
@ -137,10 +158,18 @@
|
||||
}
|
||||
|
||||
.block-first-article .first-article-title {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-intro {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.block-first-article .first-article-body {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
|
||||
.block-list-items .article-title {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.block-list-items .article-date {
|
||||
@ -58,6 +59,7 @@
|
||||
align-self: stretch;
|
||||
margin-left: auto;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.block-list-more-article a {
|
||||
@ -119,6 +121,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block-list-items .article-date {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.block-list-more-article {
|
||||
max-width: 100%;
|
||||
}
|
||||
@ -143,7 +149,7 @@
|
||||
}
|
||||
|
||||
.block-list-items .article-date {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.block-list-more-article span {
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
.list-title {
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.block-title-divider {
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-b .horizontal-list-thumb::after {
|
||||
/* .section-b .horizontal-list-thumb::after {
|
||||
background: url("../img/picfrm_b194.png") no-repeat left bottom / cover;
|
||||
background-size: 194px 25px;
|
||||
}
|
||||
@ -98,7 +98,7 @@
|
||||
.section-o .horizontal-list-thumb::after {
|
||||
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
||||
background-size: 194px 25px;
|
||||
}
|
||||
} */
|
||||
|
||||
.horizontal-list li img {
|
||||
display: block;
|
||||
@ -124,6 +124,7 @@
|
||||
}
|
||||
|
||||
.horizontal-list .article-intro {
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.horizontal-list .article-date {
|
||||
|
||||
@ -3,14 +3,16 @@
|
||||
grid-template-columns: 480px 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"head head"
|
||||
"image content";
|
||||
padding: 40px 0;
|
||||
gap: 16px 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.news-hero .news-hero-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
/* display: flex; */
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@ -24,13 +26,13 @@
|
||||
|
||||
.news-hero .fist-news-title {
|
||||
grid-area: head;
|
||||
max-width: 580px;
|
||||
font-size: 40px;
|
||||
/* max-width: 580px; */
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #eb9f13;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -43,7 +45,7 @@
|
||||
width: 480px;
|
||||
}
|
||||
|
||||
.news-hero .first-news-image::after {
|
||||
/* .news-hero .first-news-image::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
@ -53,7 +55,7 @@
|
||||
background: url("../img/picfrm_b480.png") no-repeat left bottom / cover;
|
||||
background-size: 480px 25px;
|
||||
pointer-events: none;
|
||||
}
|
||||
} */
|
||||
|
||||
.news-hero .first-news-image img {
|
||||
display: block;
|
||||
@ -64,14 +66,17 @@
|
||||
}
|
||||
|
||||
.news-hero .first-news-content {
|
||||
grid-area: content;
|
||||
height: 293px;
|
||||
max-height: 293px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-hero .first-news-content a {
|
||||
grid-area: content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-hero .fist-news-date,
|
||||
@ -94,6 +99,7 @@
|
||||
.news-hero .first-news-body {
|
||||
font-size: 13px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
@ -136,7 +142,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-list-thumb::after {
|
||||
/* .news-list-thumb::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
@ -146,7 +152,7 @@
|
||||
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
||||
background-size: 194px 25px;
|
||||
pointer-events: none;
|
||||
}
|
||||
} */
|
||||
|
||||
.news-list-thumb img {
|
||||
display: block;
|
||||
@ -166,6 +172,10 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.news-list-items .article-intro {
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.news-list-items .article-date {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
@ -191,17 +201,17 @@
|
||||
grid-template-columns: 318px 1fr;
|
||||
}
|
||||
|
||||
.news-hero .fist-news-title {
|
||||
/* .news-hero .fist-news-title {
|
||||
max-width: 320px;
|
||||
}
|
||||
} */
|
||||
|
||||
.news-hero .first-news-image {
|
||||
width: 318px;
|
||||
}
|
||||
|
||||
.news-hero .first-news-image::after {
|
||||
/* .news-hero .first-news-image::after {
|
||||
background-size: 318px 25px;
|
||||
}
|
||||
} */
|
||||
|
||||
.news-hero .first-news-image img {
|
||||
width: 318px;
|
||||
@ -219,7 +229,7 @@
|
||||
|
||||
@media (min-width: 575px) and (max-width: 1023px) {
|
||||
.news-hero .fist-news-title {
|
||||
font-size: 36px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.more-news {
|
||||
@ -302,6 +312,11 @@
|
||||
height:260px;
|
||||
}
|
||||
|
||||
.news-hero .first-news-content {
|
||||
height: 260px;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.news-list-items a > div:nth-of-type(1) {
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
@ -364,6 +379,21 @@
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.news-hero .first-news-content {
|
||||
height: 220px;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.news-hero .first-news-body::after {
|
||||
height: 90px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(14, 27, 66, 0) 0%,
|
||||
rgba(14, 27, 66, 0.42) 62%,
|
||||
#0e1b42 92%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
.news-hero .fist-news-title {
|
||||
font-size: 24px;
|
||||
@ -386,10 +416,10 @@
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.news-list-thumb::after {
|
||||
/* .news-list-thumb::after {
|
||||
background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
|
||||
background-size: 139px 25px;
|
||||
}
|
||||
} */
|
||||
|
||||
.news-list-items a > div:nth-of-type(3) {
|
||||
display: none;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
BIN
innovedus_cms/home/static/img/default_cover_1.png
Normal file
BIN
innovedus_cms/home/static/img/default_cover_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
BIN
innovedus_cms/home/static/img/default_cover_2.png
Normal file
BIN
innovedus_cms/home/static/img/default_cover_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
BIN
innovedus_cms/home/static/img/default_cover_3.png
Normal file
BIN
innovedus_cms/home/static/img/default_cover_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load wagtailcore_tags wagtailimages_tags static %}
|
||||
{% load wagtailcore_tags wagtailimages_tags static home_tags %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/article_page.css' %}">
|
||||
@ -89,7 +89,8 @@
|
||||
{% image related.cover_image max-194x133 as related_cover %}
|
||||
<img src="{{ related_cover.url }}" alt="{{ related.title }}">
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ related.title }}">
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ related.title }}">
|
||||
{% endif %}
|
||||
</a>
|
||||
<p class="related-article-date">{{ related.date|date:"Y/m/d" }}</p>
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% load wagtailcore_tags %}
|
||||
{% block content %}
|
||||
<nav class="breadcrumbs" aria-label="breadcrumb">
|
||||
<ol>
|
||||
<li>
|
||||
{% if site_root %}
|
||||
<a href="{{ site_root.url }}">首頁</a>
|
||||
{% else %}
|
||||
<a href="/">首頁</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><span>標籤</span></li>
|
||||
<li><span>#{{ tag.name }}</span></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{% include "home/includes/page-article-list.html" with category=category_sections.0 %}
|
||||
{% load wagtailcore_tags static %}
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="site-hero-band full-bleed">
|
||||
<div class="site-container">
|
||||
<div class="block-title category-title"><span>#{{ tag.name }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "home/includes/page-article-list.html" with category=category_sections.0 show_hero=False empty_message="沒有符合條件的文章" %}
|
||||
{% endblock %}
|
||||
|
||||
@ -9,9 +9,10 @@
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="site-hero-band full-bleed">
|
||||
{% include "base/includes/home_banner.html" %}
|
||||
|
||||
<div class="site-container">
|
||||
<div class="home-hero">
|
||||
{% include "base/includes/home_banner.html" %}
|
||||
{% with top_section=sections.top_section %}
|
||||
{% for section in top_section %}
|
||||
{% include "home/includes/news_list.html" with section=section %}
|
||||
|
||||
@ -1,48 +1,77 @@
|
||||
{% load wagtailimages_tags static %}
|
||||
{% load wagtailimages_tags static home_tags %}
|
||||
|
||||
<div class="site-hero-band full-bleed">
|
||||
<div class="site-container">
|
||||
{% with first_article=items|first %}
|
||||
{% if first_article %}
|
||||
<div class="first-article">
|
||||
<div class="first-article-image">
|
||||
<a href="{{ first_article.url }}">
|
||||
{% if first_article.cover_image %}
|
||||
{% image first_article.cover_image max-410x293 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}"/>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="first-article-content">
|
||||
<a href="{{ first_article.url }}">
|
||||
<div class="first-article-title">
|
||||
<span>{{ first_article.title }}</span>
|
||||
</div>
|
||||
<div class="first-article-date">
|
||||
<span>{{ first_article.date|date:"Y.m.d" }}</span>
|
||||
</div>
|
||||
{% if first_article.intro %}
|
||||
<div class="first-article-intro">
|
||||
<span>{{ first_article.intro }}</span>
|
||||
{% if show_hero != False %}
|
||||
<div class="site-hero-band full-bleed">
|
||||
<div class="site-container">
|
||||
{% with first_article=items|first %}
|
||||
{% if first_article %}
|
||||
<div class="first-article">
|
||||
<div class="first-article-image">
|
||||
<a href="{{ first_article.url }}">
|
||||
{% if first_article.cover_image %}
|
||||
{% image first_article.cover_image max-410x293 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
||||
{% else %}
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ first_article.title }}"/>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="first-article-content">
|
||||
<a href="{{ first_article.url }}">
|
||||
<div class="first-article-title">
|
||||
<span>{{ first_article.title }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="first-article-body">
|
||||
<span>{{ first_article.body_search_text|truncatechars:200 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="first-article-date">
|
||||
<span>{{ first_article.date|date:"Y.m.d" }}</span>
|
||||
</div>
|
||||
{% if first_article.intro %}
|
||||
<div class="first-article-intro">
|
||||
<span>{{ first_article.intro }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="first-article-body">
|
||||
<span>{{ first_article.body_search_text|truncatechars:200 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="empty">目前沒有文章</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<span class="empty">{{ empty_message|default:"目前沒有文章" }}</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-list-lower">
|
||||
{% if items|length >= 2 %}
|
||||
{% for article in items|slice:"1:" %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_hero != False %}
|
||||
{% if items|length > 2 %}
|
||||
<div class="article-list-lower">
|
||||
{% for article in items|slice:"1:" %}
|
||||
<a href="{{ article.url }}">
|
||||
<div class="article-list-item">
|
||||
<div class="article-thumb">
|
||||
{% if article.cover_image %}
|
||||
{% image article.cover_image max-410x218 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
||||
{% else %}
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-content">
|
||||
<div><span class="article-title">{{ article.title }}</span></div>
|
||||
<div><span class="article-date">{{ article.date|date:"Y.m.d" }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if items|length %}
|
||||
<div class="article-list-lower">
|
||||
{% for article in items %}
|
||||
<a href="{{ article.url }}">
|
||||
<div class="article-list-item">
|
||||
<div class="article-thumb">
|
||||
@ -50,7 +79,8 @@
|
||||
{% image article.cover_image max-410x218 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-content">
|
||||
@ -60,5 +90,8 @@
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="empty">{{ empty_message|default:"沒有符合條件的文章" }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load wagtailimages_tags static %}
|
||||
{% load wagtailimages_tags static home_tags %}
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list.css' %}">
|
||||
{% endblock %}
|
||||
@ -13,7 +13,8 @@
|
||||
{% image first_article.cover_image max-480x293 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ first_article.title }}"/>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load wagtailimages_tags static %}
|
||||
{% load wagtailimages_tags static home_tags %}
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list_lower.css' %}">
|
||||
{% endblock %}
|
||||
@ -12,7 +12,8 @@
|
||||
{% image article.cover_image max-194x133 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div><span class="article-title">{{ article.title }}</span></div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load wagtailimages_tags static %}
|
||||
{% load wagtailimages_tags static home_tags %}
|
||||
|
||||
<div class="horizontal-list-wrap" data-horizontal-list>
|
||||
<button class="horizontal-list-arrow is-hidden" type="button" data-dir="left" aria-label="上一頁">
|
||||
@ -15,7 +15,8 @@
|
||||
{% image article.cover_image max-194x133 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div><span class="article-title">{{ article.title }}</span></div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load wagtailimages_tags static %}
|
||||
{% load wagtailimages_tags static home_tags %}
|
||||
|
||||
<div class="news-list-wrap" data-news-list>
|
||||
<div class="news-hero">
|
||||
@ -12,25 +12,22 @@
|
||||
</span>
|
||||
<a href="{{ section.url }}"><span class="more-link">查看全部</span></a>
|
||||
</div>
|
||||
{% with first_article=section.items|first %}
|
||||
{% if first_article %}
|
||||
<div class="fist-news-title">
|
||||
<a href="{{ first_article.url }}">
|
||||
<span>{{ first_article.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% with first_article=section.items|first %}
|
||||
{% if first_article %}
|
||||
<div class="fist-news-title">
|
||||
<a href="{{ first_article.url }}">
|
||||
<span>{{ first_article.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="first-news-image">
|
||||
<a href="{{ first_article.url }}">
|
||||
{% if first_article.cover_image %}
|
||||
{% image first_article.cover_image max-480x293 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}" height="293" width="480"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}" height="293" width="480"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ first_article.title }}" height="293" width="480"/>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
@ -65,7 +62,8 @@
|
||||
{% image article.cover_image max-194x133 as cover %}
|
||||
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% else %}
|
||||
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% random_default_cover as default_cover %}
|
||||
<img src="{{ default_cover }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div><span class="article-title">{{ article.title }}</span></div>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
{% endblock %}
|
||||
|
||||
<div class="page-article-list">
|
||||
{% include "home/includes/article_list.html" with items=category.items %}
|
||||
{% include "home/includes/article_list.html" with items=category.items show_hero=show_hero empty_message=empty_message %}
|
||||
|
||||
{% if category.items.paginator.num_pages > 1 %}
|
||||
<div class="pagination">
|
||||
@ -12,7 +12,16 @@
|
||||
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
|
||||
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
||||
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="var(--stroke-0, #0E1B42)" stroke-width="2"/>
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span>BACK</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="prev-page disabled" aria-disabled="true">
|
||||
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
||||
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span>BACK</span>
|
||||
@ -34,10 +43,19 @@
|
||||
<span>NEXT</span>
|
||||
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
||||
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="var(--stroke-0, #0E1B42)" stroke-width="2"/>
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="next-page disabled" aria-disabled="true">
|
||||
<span>NEXT</span>
|
||||
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
||||
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
|
||||
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
16
innovedus_cms/home/templatetags/home_tags.py
Normal file
16
innovedus_cms/home/templatetags/home_tags.py
Normal file
@ -0,0 +1,16 @@
|
||||
import random
|
||||
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def random_default_cover():
|
||||
choices = (
|
||||
"img/default_cover_1.png",
|
||||
"img/default_cover_2.png",
|
||||
"img/default_cover_3.png",
|
||||
)
|
||||
return static(random.choice(choices))
|
||||
@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404, render
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Site
|
||||
|
||||
from .models import ArticlePage, PAGE_SIZE
|
||||
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
|
||||
|
||||
|
||||
def hashtag_search(request, slug):
|
||||
@ -12,7 +12,7 @@ def hashtag_search(request, slug):
|
||||
articles = (
|
||||
ArticlePage.objects.live()
|
||||
.filter(tags__slug=slug)
|
||||
.order_by("-date")
|
||||
.order_by("-date", "-id")
|
||||
)
|
||||
|
||||
paginator = Paginator(articles, PAGE_SIZE)
|
||||
@ -37,7 +37,12 @@ def hashtag_search(request, slug):
|
||||
"url": request.path,
|
||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
||||
}
|
||||
],
|
||||
],
|
||||
"category_trending": (
|
||||
ArticlePage.objects.live()
|
||||
.filter(tags__slug=slug, trending=True)
|
||||
.order_by("-date", "-id")[:CATEGORY_HOT_SIZE]
|
||||
),
|
||||
"site_root": site_root,
|
||||
"page": site_root.specific if site_root else None,
|
||||
}
|
||||
|
||||
BIN
innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo
Normal file
BIN
innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
432
innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
432
innovedus_cms/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
@ -0,0 +1,432 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: innovedus_cms\n"
|
||||
"POT-Creation-Date: 2026-03-12 00:00+0000\n"
|
||||
"PO-Revision-Date: 2026-03-12 00:00+0000\n"
|
||||
"Last-Translator: Codex\n"
|
||||
"Language-Team: zh_Hant\n"
|
||||
"Language: zh_Hant\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
msgid "Header Settings"
|
||||
msgstr "頁首設定"
|
||||
|
||||
msgid "Use on dark background (light logo)."
|
||||
msgstr "深色底用(亮色 logo)"
|
||||
|
||||
msgid "Light Logo"
|
||||
msgstr "亮色 Logo"
|
||||
|
||||
msgid "Use on light background (dark logo)."
|
||||
msgstr "淺色底用(深色 logo)"
|
||||
|
||||
msgid "Dark Logo"
|
||||
msgstr "深色 Logo"
|
||||
|
||||
msgid "Site Name"
|
||||
msgstr "網站名稱"
|
||||
|
||||
msgid "Extra Links"
|
||||
msgstr "額外連結"
|
||||
|
||||
msgid "Label"
|
||||
msgstr "標籤"
|
||||
|
||||
msgid "URL"
|
||||
msgstr "連結"
|
||||
|
||||
msgid "Footer Navigation"
|
||||
msgstr "頁尾導覽"
|
||||
|
||||
msgid "Footer Links"
|
||||
msgstr "頁尾連結"
|
||||
|
||||
msgid "Section Title"
|
||||
msgstr "區塊標題"
|
||||
|
||||
msgid "Social Link"
|
||||
msgstr "社群連結"
|
||||
|
||||
msgid "Social Links"
|
||||
msgstr "社群連結"
|
||||
|
||||
msgid "Platform"
|
||||
msgstr "平台"
|
||||
|
||||
msgid "SMTP Settings"
|
||||
msgstr "SMTP 設定"
|
||||
|
||||
msgid "SMTP Relay Host"
|
||||
msgstr "SMTP Relay Host"
|
||||
|
||||
msgid "SMTP Relay Port"
|
||||
msgstr "SMTP Relay Port"
|
||||
|
||||
msgid "Use TLS"
|
||||
msgstr "使用 TLS"
|
||||
|
||||
msgid "Use SSL"
|
||||
msgstr "使用 SSL"
|
||||
|
||||
msgid "Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."
|
||||
msgstr "465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。"
|
||||
|
||||
msgid "SMTP Timeout Seconds"
|
||||
msgstr "SMTP 逾時秒數"
|
||||
|
||||
msgid "SMTP Username"
|
||||
msgstr "SMTP 帳號"
|
||||
|
||||
msgid "SMTP Password"
|
||||
msgstr "SMTP 密碼"
|
||||
|
||||
msgid "Newsletter System Settings"
|
||||
msgstr "電子報系統設定"
|
||||
|
||||
msgid "Member Center"
|
||||
msgstr "會員中心"
|
||||
|
||||
msgid "Member Center Base URL"
|
||||
msgstr "會員中心 Base URL"
|
||||
|
||||
msgid "Subscribe Path"
|
||||
msgstr "訂閱路徑"
|
||||
|
||||
msgid "Confirm Path"
|
||||
msgstr "確認路徑"
|
||||
|
||||
msgid "Unsubscribe Token Path"
|
||||
msgstr "退訂 Token 路徑"
|
||||
|
||||
msgid "Unsubscribe Path"
|
||||
msgstr "退訂路徑"
|
||||
|
||||
msgid "Subscriptions Path"
|
||||
msgstr "訂閱列表路徑"
|
||||
|
||||
msgid "OAuth Token Path"
|
||||
msgstr "OAuth Token 路徑"
|
||||
|
||||
msgid "OAuth Client ID"
|
||||
msgstr "OAuth Client ID"
|
||||
|
||||
msgid "OAuth Client Secret"
|
||||
msgstr "OAuth Client Secret"
|
||||
|
||||
msgid "OAuth Scope"
|
||||
msgstr "OAuth Scope"
|
||||
|
||||
msgid "OAuth Audience"
|
||||
msgstr "OAuth Audience"
|
||||
|
||||
msgid "One-Click Unsubscribe Path"
|
||||
msgstr "一鍵退訂路徑"
|
||||
|
||||
msgid "Tenant ID"
|
||||
msgstr "Tenant ID"
|
||||
|
||||
msgid "List ID"
|
||||
msgstr "List ID"
|
||||
|
||||
msgid "Member Center Timeout Seconds"
|
||||
msgstr "會員中心逾時秒數"
|
||||
|
||||
msgid "Send Engine"
|
||||
msgstr "發送引擎"
|
||||
|
||||
msgid "Send Engine Base URL"
|
||||
msgstr "發送引擎 Base URL"
|
||||
|
||||
msgid "Send Jobs Path"
|
||||
msgstr "發送任務路徑"
|
||||
|
||||
msgid "Send Engine OAuth Scope"
|
||||
msgstr "發送引擎 OAuth Scope"
|
||||
|
||||
msgid "Send Engine Timeout Seconds"
|
||||
msgstr "發送引擎逾時秒數"
|
||||
|
||||
msgid "Retry Interval Seconds"
|
||||
msgstr "重試間隔秒數"
|
||||
|
||||
msgid "Retry Max Attempts"
|
||||
msgstr "最大重試次數"
|
||||
|
||||
msgid "Newsletter Mail"
|
||||
msgstr "電子報寄件設定"
|
||||
|
||||
msgid "Sender Name"
|
||||
msgstr "寄件者名稱"
|
||||
|
||||
msgid "Sender Email"
|
||||
msgstr "寄件者 Email"
|
||||
|
||||
msgid "Reply-To Email"
|
||||
msgstr "回覆 Email"
|
||||
|
||||
msgid "Default Charset"
|
||||
msgstr "預設字元編碼"
|
||||
|
||||
msgid "List-Unsubscribe One-Click"
|
||||
msgstr "一鍵退訂設定"
|
||||
|
||||
msgid "One-Click Endpoint Path"
|
||||
msgstr "一鍵退訂端點路徑"
|
||||
|
||||
msgid "One-Click Token Secret"
|
||||
msgstr "一鍵退訂 Token 密鑰"
|
||||
|
||||
msgid "One-click token signing secret. Leave blank to use Django SECRET_KEY."
|
||||
msgstr "One-click token 簽章密鑰;留空則使用 Django SECRET_KEY。"
|
||||
|
||||
msgid "One-Click Token TTL Seconds"
|
||||
msgstr "一鍵退訂 Token 有效秒數"
|
||||
|
||||
msgid "Site Base URL"
|
||||
msgstr "站台 Base URL"
|
||||
|
||||
msgid "Site base URL for scheduler sends, e.g. https://news.example.com"
|
||||
msgstr "排程發送使用的站台網址(例如 https://news.example.com)。"
|
||||
|
||||
msgid "System Notification Mail Settings"
|
||||
msgstr "系統通知信設定"
|
||||
|
||||
msgid "Contact Us Notification Mail"
|
||||
msgstr "聯絡我們通知信"
|
||||
|
||||
msgid "Contact Form Sender Name"
|
||||
msgstr "聯絡表單寄件者名稱"
|
||||
|
||||
msgid "Contact Form Sender Email"
|
||||
msgstr "聯絡表單寄件者 Email"
|
||||
|
||||
msgid "Contact Form Reply-To Email"
|
||||
msgstr "聯絡表單回覆 Email"
|
||||
|
||||
msgid "Contact Form Notification Recipients"
|
||||
msgstr "聯絡表單通知收件人"
|
||||
|
||||
msgid "Multiple recipients separated by comma or newline."
|
||||
msgstr "可填多個收件人,以逗號或換行分隔。"
|
||||
|
||||
msgid "Contact Form Subject Prefix"
|
||||
msgstr "聯絡表單主旨前綴"
|
||||
|
||||
msgid "User Copy Subject Template"
|
||||
msgstr "使用者存檔信主旨模板"
|
||||
|
||||
msgid "User Copy Text Template"
|
||||
msgstr "使用者存檔信純文字模板"
|
||||
|
||||
msgid "User Copy HTML Template"
|
||||
msgstr "使用者存檔信 HTML 模板"
|
||||
|
||||
msgid "Subscribe Confirmation Email"
|
||||
msgstr "訂閱確認信"
|
||||
|
||||
msgid "Page Templates"
|
||||
msgstr "頁面模板"
|
||||
|
||||
msgid "Newsletter Template Settings"
|
||||
msgstr "電子報模板設定"
|
||||
|
||||
msgid "Banner"
|
||||
msgstr "橫幅"
|
||||
|
||||
msgid "Banners"
|
||||
msgstr "橫幅"
|
||||
|
||||
msgid "Key"
|
||||
msgstr "識別鍵"
|
||||
|
||||
msgid "Identifier key, e.g. home / category"
|
||||
msgstr "識別用 key(例如 home / category)"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "標題"
|
||||
|
||||
msgid "Link Text"
|
||||
msgstr "連結文字"
|
||||
|
||||
msgid "Active"
|
||||
msgstr "啟用"
|
||||
|
||||
msgid "Sort Order"
|
||||
msgstr "排序"
|
||||
|
||||
msgid "Footer Text"
|
||||
msgstr "頁尾文字"
|
||||
|
||||
msgid "Draft"
|
||||
msgstr "草稿"
|
||||
|
||||
msgid "Scheduled"
|
||||
msgstr "已排程"
|
||||
|
||||
msgid "Sending"
|
||||
msgstr "寄送中"
|
||||
|
||||
msgid "Sent"
|
||||
msgstr "已送出"
|
||||
|
||||
msgid "Failed"
|
||||
msgstr "失敗"
|
||||
|
||||
msgid "Newsletter Campaign"
|
||||
msgstr "電子報"
|
||||
|
||||
msgid "Newsletter Campaigns"
|
||||
msgstr "電子報"
|
||||
|
||||
msgid "Newsletter Dispatch Record"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Newsletter Dispatch Records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Collaboration"
|
||||
msgstr "合作邀約"
|
||||
|
||||
msgid "Website Issue"
|
||||
msgstr "網站問題回報"
|
||||
|
||||
msgid "Career"
|
||||
msgstr "求職專區"
|
||||
|
||||
msgid "Other"
|
||||
msgstr "其他"
|
||||
|
||||
msgid "Name"
|
||||
msgstr "姓名"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "Email"
|
||||
|
||||
msgid "Contact"
|
||||
msgstr "聯絡方式"
|
||||
|
||||
msgid "Category"
|
||||
msgstr "問題類別"
|
||||
|
||||
msgid "Message"
|
||||
msgstr "留言內容"
|
||||
|
||||
msgid "Source Page"
|
||||
msgstr "來源頁面"
|
||||
|
||||
msgid "IP Address"
|
||||
msgstr "IP 位址"
|
||||
|
||||
msgid "User Agent"
|
||||
msgstr "User Agent"
|
||||
|
||||
msgid "Created At"
|
||||
msgstr "建立時間"
|
||||
|
||||
msgid "Contact Form Submission"
|
||||
msgstr "聯絡表單提交"
|
||||
|
||||
msgid "Contact Form Submissions"
|
||||
msgstr "聯絡表單提交"
|
||||
|
||||
msgid "Newsletter campaigns"
|
||||
msgstr "電子報"
|
||||
|
||||
msgid "Newsletter dispatch records"
|
||||
msgstr "電子報發送紀錄"
|
||||
|
||||
msgid "Social Media Settings"
|
||||
msgstr "社群媒體設定"
|
||||
|
||||
msgid "Subject Template"
|
||||
msgstr "主旨模板"
|
||||
|
||||
msgid "HTML Template"
|
||||
msgstr "HTML 模板"
|
||||
|
||||
msgid "Text Template"
|
||||
msgstr "純文字模板"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "狀態"
|
||||
|
||||
msgid "Scheduled At"
|
||||
msgstr "排程時間"
|
||||
|
||||
msgid "Sent At"
|
||||
msgstr "發送時間"
|
||||
|
||||
msgid "Last Error"
|
||||
msgstr "最後錯誤"
|
||||
|
||||
msgid "Updated At"
|
||||
msgstr "更新時間"
|
||||
|
||||
msgid "Campaign"
|
||||
msgstr "活動"
|
||||
|
||||
msgid "Subscriber ID"
|
||||
msgstr "訂閱者 ID"
|
||||
|
||||
msgid "Retry Count"
|
||||
msgstr "重試次數"
|
||||
|
||||
msgid "Next Retry At"
|
||||
msgstr "下次重試時間"
|
||||
|
||||
msgid "Response Status"
|
||||
msgstr "回應狀態碼"
|
||||
|
||||
msgid "Response Payload"
|
||||
msgstr "回應內容"
|
||||
|
||||
msgid "Error Message"
|
||||
msgstr "錯誤訊息"
|
||||
|
||||
msgid "Subscribe Subject Template"
|
||||
msgstr "訂閱主旨模板"
|
||||
|
||||
msgid "Subscribe HTML Template"
|
||||
msgstr "訂閱 HTML 模板"
|
||||
|
||||
msgid "Subscribe Text Template"
|
||||
msgstr "訂閱純文字模板"
|
||||
|
||||
msgid "Confirm Success Template"
|
||||
msgstr "確認成功模板"
|
||||
|
||||
msgid "Confirm Failure Template"
|
||||
msgstr "確認失敗模板"
|
||||
|
||||
msgid "Unsubscribe Intro Template"
|
||||
msgstr "退訂前置說明模板"
|
||||
|
||||
msgid "Unsubscribe Success Template"
|
||||
msgstr "退訂成功模板"
|
||||
|
||||
msgid "Unsubscribe Failure Template"
|
||||
msgstr "退訂失敗模板"
|
||||
|
||||
msgid "Leave blank to use member_center_list_id from Newsletter System Settings."
|
||||
msgstr "留空時會使用 Newsletter System Settings 的 member_center_list_id。"
|
||||
|
||||
msgid "Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."
|
||||
msgstr "可用圖片按鈕從 Wagtail 圖庫選圖或上傳。建議使用圖片 URL,不要在 CMS 端轉 Base64。"
|
||||
|
||||
msgid "Send now"
|
||||
msgstr "馬上發送"
|
||||
|
||||
msgid "Send test email (save settings before sending)."
|
||||
msgstr "發送測試郵件(請先儲存設定後再發送測試郵件)"
|
||||
|
||||
msgid "Send email to"
|
||||
msgstr "發送郵件到"
|
||||
|
||||
msgid "This field is only for this test and will not be saved."
|
||||
msgstr "此欄位僅作本次測試,不會儲存。"
|
||||
|
||||
msgid "Send test email"
|
||||
msgstr "發送測試郵件"
|
||||
@ -13,9 +13,18 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
import os
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception:
|
||||
certifi = None
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
||||
|
||||
# Ensure Python SSL always has a CA bundle unless caller explicitly sets one.
|
||||
if not os.environ.get("SSL_CERT_FILE") and certifi is not None:
|
||||
os.environ["SSL_CERT_FILE"] = certifi.where()
|
||||
|
||||
def env_list(name, default):
|
||||
"""
|
||||
Return a list from a comma-separated env var; fall back to provided default list.
|
||||
@ -138,6 +147,10 @@ USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
|
||||
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
|
||||
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
|
||||
# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.
|
||||
|
||||
198
innovedus_cms/mysite/static/css/contact_form.css
Normal file
198
innovedus_cms/mysite/static/css/contact_form.css
Normal file
@ -0,0 +1,198 @@
|
||||
.contact-form-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contact-form-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.contact-form-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.contact-form-modal__dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(680px, calc(100vw - 48px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
margin: 0 auto;
|
||||
padding: 20px 28px 24px;
|
||||
background: #0e1b42e6;
|
||||
color: #ffffff;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.contact-form-modal__close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-form-modal__title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contact-form-modal__subtitle {
|
||||
margin: 12px 0 18px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
max-width: 415px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contact-form,
|
||||
.contact-form * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contact-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contact-form__field {
|
||||
margin-bottom: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-form__field label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contact-form__field input,
|
||||
.contact-form__field select,
|
||||
.contact-form__field textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
background: #ffffff4d;
|
||||
color: #0e1b42;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.contact-form__field input:focus,
|
||||
.contact-form__field select:focus,
|
||||
.contact-form__field textarea:focus {
|
||||
background: #ffffffcc;
|
||||
color: #0e1b42;
|
||||
}
|
||||
|
||||
.contact-form__field input::placeholder {
|
||||
color: #ffffffcc;
|
||||
}
|
||||
|
||||
.contact-form__field select {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.contact-form__field textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.contact-form__actions {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact-form__actions button {
|
||||
min-width: 196px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
color: #0e1b42;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-form__result {
|
||||
margin: 10px 0 0;
|
||||
min-height: 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.contact-form-modal__dialog {
|
||||
width: calc(90vw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
margin: 0 auto;
|
||||
padding: 16px 12px 18px;
|
||||
}
|
||||
|
||||
.contact-form-modal__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.contact-form-modal__subtitle {
|
||||
font-size: 16px;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.contact-form__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contact-form__field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contact-form__field label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.contact-form__field input,
|
||||
.contact-form__field select,
|
||||
.contact-form__field textarea {
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.contact-form__field select {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.contact-form__field textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.contact-form__actions button {
|
||||
min-width: 160px;
|
||||
height: 34px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-form__result {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
261
innovedus_cms/mysite/static/css/footer.css
Normal file
261
innovedus_cms/mysite/static/css/footer.css
Normal file
@ -0,0 +1,261 @@
|
||||
.site-footer {
|
||||
background: #0e1b42;
|
||||
color: #ffffff;
|
||||
padding: 28px 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-ad-slot {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
width: 100%;
|
||||
max-width: 790px;
|
||||
height: 1px;
|
||||
background: #ffffff4d;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
width: 100%;
|
||||
max-width: 790px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 220px 180px;
|
||||
grid-template-areas:
|
||||
"b1 b2 b3"
|
||||
"b1 b2 b4";
|
||||
column-gap: 40px;
|
||||
row-gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.footer-col--brand {
|
||||
grid-area: b1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footer-brand-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.footer-brand-content p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.footer-col--menu {
|
||||
grid-area: b2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.footer-menu-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
column-count: 2;
|
||||
column-gap: 40px;
|
||||
}
|
||||
|
||||
.footer-menu-list li {
|
||||
break-inside: avoid;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.footer-menu-list a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.footer-menu-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-fixed-links{
|
||||
grid-area: b3;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-fixed-links ul{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer-fixed-links a {
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.footer-fixed-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-socials {
|
||||
grid-area: b4;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer-socials .icon {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
--fill-0: #ffffff;
|
||||
}
|
||||
|
||||
.footer-socials .icon .icon-cutout {
|
||||
fill: #0e1b42;
|
||||
}
|
||||
|
||||
.footer-powered {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 100;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.footer-fixed-links,
|
||||
.footer-socials {
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.footer-separator {
|
||||
max-width: 616px;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
max-width: 616px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 150px 1fr;
|
||||
grid-template-areas:
|
||||
"b1 b2 b2"
|
||||
"b1 b4 b3";
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.footer-col--right:nth-child(3) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-menu-list {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.site-footer {
|
||||
padding: 24px 0 16px;
|
||||
}
|
||||
|
||||
.site-footer .site-container {
|
||||
max-width: 494px;
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
max-width: 494px;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
max-width: 494px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 150px 1fr;
|
||||
grid-template-areas:
|
||||
"b1 b2 b3"
|
||||
"b1 b4 .";
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-menu-list {
|
||||
column-gap: 0px;
|
||||
}
|
||||
|
||||
.footer-menu-list li {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.footer-col--right {
|
||||
grid-column: auto;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-fixed-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.footer-fixed-links ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-fixed-links ul li {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 574px) {
|
||||
.footer-separator {
|
||||
max-width: 289px;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
max-width: 220px;
|
||||
margin-inline: auto;
|
||||
justify-content: center;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"b2"
|
||||
"b1"
|
||||
"b4"
|
||||
"b3";
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-col--menu,
|
||||
.footer-col--right,
|
||||
.footer-fixed-links,
|
||||
.footer-socials {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-menu-list {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-fixed-links ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.footer-menu-list a {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.footer-fixed-links a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer-socials .icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
@ -66,7 +66,7 @@ a {
|
||||
.main-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@ -276,7 +276,7 @@ a {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 181px;
|
||||
width: 153px;
|
||||
}
|
||||
|
||||
.template-darkbackground .header-search input[type="search"] {
|
||||
@ -287,88 +287,6 @@ a {
|
||||
color: #ffffff88;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #0e1b42;
|
||||
color: #ffffff;
|
||||
padding: 24px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 48px;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
footer .company-info {
|
||||
max-width: 300px;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
footer .copyright img {
|
||||
margin: 10px 0;
|
||||
width: 265px;
|
||||
height: 37px;
|
||||
}
|
||||
|
||||
footer .copyright p {
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-socials {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-socials .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
--fill-0: #ffffff;
|
||||
}
|
||||
|
||||
.footer-socials .icon circle {
|
||||
fill: #ffffff;
|
||||
fill-opacity: 0.85;
|
||||
}
|
||||
|
||||
.footer-socials .icon .icon-cutout {
|
||||
fill: #0e1b42;
|
||||
}
|
||||
|
||||
footer .footer-links {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
footer .footer-links li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
footer .footer-links a {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
footer .footer-divider {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
width: 1px;
|
||||
border-left: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
footer .footer-sections {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
display: inline-block;
|
||||
width: 197px;
|
||||
@ -404,35 +322,6 @@ footer .footer-sections {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-inner {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer .company-info,
|
||||
footer .footer-links {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.footer-socials {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer .footer-sections {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer .footer-divider {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
border-left: 0;
|
||||
border-top: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
footer .copyright p {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 575px) and (max-width: 767px) {
|
||||
|
||||
117
innovedus_cms/mysite/static/css/subscribe_fab.css
Normal file
117
innovedus_cms/mysite/static/css/subscribe_fab.css
Normal file
@ -0,0 +1,117 @@
|
||||
.subscribe-fab {
|
||||
--fab-toggle-width: 59px;
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 12%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
min-height: 59px;
|
||||
}
|
||||
|
||||
.subscribe-fab__toggle {
|
||||
flex: 0 0 var(--fab-toggle-width);
|
||||
width: var(--fab-toggle-width);
|
||||
height: 59px;
|
||||
margin: 0 0 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ffffff66;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscribe-fab__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: scale(1.35);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.subscribe-fab__text {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.subscribe-fab__panel {
|
||||
position: absolute;
|
||||
width: 378px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 368px;
|
||||
height: 59px;
|
||||
padding: 0 10px 0 0;
|
||||
border-radius: 36px;
|
||||
background: #ffffff1a;
|
||||
border: 1px solid #ffffff80;
|
||||
transform: translateX(calc(100% - var(--fab-toggle-width)));
|
||||
transition: transform 0.25s ease, background-color 0.2s ease;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.subscribe-fab:hover .subscribe-fab__panel {
|
||||
background: #ffffff66;
|
||||
}
|
||||
|
||||
.subscribe-fab.is-open .subscribe-fab__panel {
|
||||
background: #ffffff66;
|
||||
transform: translateX(44px);
|
||||
}
|
||||
|
||||
.subscribe-fab:hover .subscribe-fab__toggle,
|
||||
.subscribe-fab.is-open .subscribe-fab__toggle {
|
||||
color: #0e1b42;
|
||||
}
|
||||
|
||||
.subscribe-fab__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.subscribe-fab.is-open .subscribe-fab__form {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.subscribe-fab__input {
|
||||
width: 190px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
padding: 0 10px;
|
||||
background: #ffffff80;
|
||||
color: #0e1b42;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subscribe-fab__input::placeholder {
|
||||
color: #0e1b4288;
|
||||
}
|
||||
|
||||
.subscribe-fab__submit {
|
||||
height: 30px;
|
||||
border: 0;
|
||||
padding: 0 10px;
|
||||
background: #ffffffcc;
|
||||
color: #0e1b42;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 375px) {
|
||||
.subscribe-fab__input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.subscribe-fab.is-open .subscribe-fab__panel {
|
||||
transform: translateX(84px);
|
||||
}
|
||||
}
|
||||
BIN
innovedus_cms/mysite/static/favicon.ico
Normal file
BIN
innovedus_cms/mysite/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 B |
70
innovedus_cms/mysite/static/js/contact_form.js
Normal file
70
innovedus_cms/mysite/static/js/contact_form.js
Normal file
@ -0,0 +1,70 @@
|
||||
(function () {
|
||||
const modal = document.querySelector("[data-contact-form]");
|
||||
if (!modal) return;
|
||||
|
||||
const form = modal.querySelector("[data-contact-form-body]");
|
||||
const result = modal.querySelector("[data-contact-result]");
|
||||
const categoryInput = modal.querySelector("#contact-form-category");
|
||||
const closeTargets = modal.querySelectorAll("[data-contact-close]");
|
||||
const triggers = document.querySelectorAll("[data-contact-trigger]");
|
||||
let lastActive = null;
|
||||
|
||||
const setOpen = (open) => {
|
||||
modal.hidden = !open;
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
if (!open && lastActive) {
|
||||
lastActive.focus();
|
||||
}
|
||||
};
|
||||
|
||||
triggers.forEach((el) => {
|
||||
el.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
lastActive = el;
|
||||
const preset = el.getAttribute("data-contact-category");
|
||||
if (preset && categoryInput) {
|
||||
categoryInput.value = preset;
|
||||
}
|
||||
if (result) result.textContent = "";
|
||||
setOpen(true);
|
||||
});
|
||||
});
|
||||
|
||||
closeTargets.forEach((el) => {
|
||||
el.addEventListener("click", () => setOpen(false));
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !modal.hidden) {
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) {
|
||||
if (result) result.textContent = "送出失敗,請確認欄位後再試一次。";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) result.textContent = "已送出,我們會盡快與您聯繫。";
|
||||
form.reset();
|
||||
} catch (_) {
|
||||
if (result) result.textContent = "送出失敗,請稍後再試。";
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
51
innovedus_cms/mysite/static/js/subscribe_fab.js
Normal file
51
innovedus_cms/mysite/static/js/subscribe_fab.js
Normal file
@ -0,0 +1,51 @@
|
||||
(function () {
|
||||
const root = document.querySelector("[data-subscribe-fab]");
|
||||
if (!root) return;
|
||||
|
||||
const toggle = root.querySelector(".subscribe-fab__toggle");
|
||||
const input = root.querySelector(".subscribe-fab__input");
|
||||
const triggers = document.querySelectorAll("[data-subscribe-trigger]");
|
||||
if (!toggle) return;
|
||||
|
||||
const focusInput = () => {
|
||||
if (!input) return;
|
||||
requestAnimationFrame(function () {
|
||||
input.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const setOpen = (open, shouldFocusInput) => {
|
||||
root.classList.toggle("is-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open && shouldFocusInput) {
|
||||
focusInput();
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", function () {
|
||||
const isOpen = root.classList.contains("is-open");
|
||||
setOpen(!isOpen, !isOpen);
|
||||
});
|
||||
|
||||
triggers.forEach(function (trigger) {
|
||||
trigger.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
setOpen(true, true);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
if (event.target.closest("[data-subscribe-trigger]")) {
|
||||
return;
|
||||
}
|
||||
if (!root.contains(event.target)) {
|
||||
setOpen(false, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false, false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -17,6 +17,8 @@
|
||||
<meta name="description" content="{{ page.search_description }}" />
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
|
||||
{% if ga4_measurement_id %}
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_measurement_id }}"></script>
|
||||
<script>
|
||||
@ -34,6 +36,9 @@
|
||||
|
||||
{# Global stylesheets #}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/mysite.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/footer.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/subscribe_fab.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
|
||||
|
||||
{% block extra_css %}
|
||||
{# Override this in templates to add extra stylesheets #}
|
||||
@ -52,10 +57,14 @@
|
||||
</main>
|
||||
|
||||
{% include "includes/footer.html" %}
|
||||
{% include "includes/contact_form.html" %}
|
||||
{% include "includes/subscribe_fab.html" %}
|
||||
|
||||
{# Global javascript #}
|
||||
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/header.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/contact_form.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
|
||||
{# Instagram embed script to render IG oEmbeds #}
|
||||
<script async src="https://www.instagram.com/embed.js"></script>
|
||||
|
||||
|
||||
52
innovedus_cms/mysite/templates/includes/contact_form.html
Normal file
52
innovedus_cms/mysite/templates/includes/contact_form.html
Normal file
@ -0,0 +1,52 @@
|
||||
<div class="contact-form-modal" data-contact-form hidden>
|
||||
<div class="contact-form-modal__backdrop" data-contact-close></div>
|
||||
<div class="contact-form-modal__dialog" role="dialog" aria-modal="true" aria-label="聯絡表單">
|
||||
<button class="contact-form-modal__close" type="button" aria-label="關閉" data-contact-close>×</button>
|
||||
|
||||
<h2 class="contact-form-modal__title">聯絡我們</h2>
|
||||
<p class="contact-form-modal__subtitle">有任何問題或想要與我們交流的地方,都歡迎留言聯絡</p>
|
||||
|
||||
<form class="contact-form" method="post" action="{% url 'contact_form_submit' %}" data-contact-form-body>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source_page" value="{{ request.get_full_path|default:'/' }}" />
|
||||
|
||||
<div class="contact-form__row">
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-form-name">該如何稱呼您</label>
|
||||
<input id="contact-form-name" type="text" name="name" required placeholder="請留下您的姓名" />
|
||||
</div>
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-form-contact">聯絡方式</label>
|
||||
<input id="contact-form-contact" type="text" name="contact" required placeholder="+886..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-form-email">Email</label>
|
||||
<input id="contact-form-email" type="email" name="email" placeholder="name@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-form-category">問題類別</label>
|
||||
<select id="contact-form-category" name="category" required>
|
||||
<option value="" selected disabled>請選擇問題類別</option>
|
||||
<option value="collaboration">合作邀約</option>
|
||||
<option value="website_issue">網站問題回報</option>
|
||||
<option value="career">求職專區</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-form-message">留言內容</label>
|
||||
<textarea id="contact-form-message" name="message" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit">送出</button>
|
||||
</div>
|
||||
|
||||
<p class="contact-form__result" data-contact-result aria-live="polite"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,75 +1,97 @@
|
||||
{% load navigation_tags %}
|
||||
{% load navigation_tags wagtailcore_tags %}
|
||||
|
||||
<footer>
|
||||
<div class="site-container footer-inner">
|
||||
<div class="company-info">
|
||||
<div class="copyright">
|
||||
{% get_footer_text %}
|
||||
</div>
|
||||
{% with social_links=settings.base.SocialMediaSettings.links %}
|
||||
{% if social_links %}
|
||||
<div class="footer-socials" aria-label="social icons">
|
||||
{% for item in social_links %}
|
||||
<a href="{{ item.value.url }}" target="_blank" aria-label="{{ item.value.platform }}">
|
||||
{% if item.value.platform|lower == "facebook" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="facebook" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM23.12 8.37H21.18C19.27 8.37 18.67 9.56 18.67 10.78V13.67H22.94L22.26 18.12H18.67V28.89H13.85V18.12H9.94V13.67H13.85V10.28C13.85 6.42 16.15 4.29 19.67 4.29C21.36 4.29 23.12 4.59 23.12 4.59V8.38V8.37Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "instagram" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="instagram" role="img">
|
||||
<path d="M16.67 13.42C14.92 13.42 13.5 14.84 13.5 16.59C13.5 18.34 14.92 19.76 16.67 19.76C18.42 19.76 19.84 18.34 19.84 16.59C19.84 14.84 18.42 13.42 16.67 13.42Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
|
||||
<path d="M20.6 8.42H12.57C10.28 8.42 8.42 10.28 8.42 12.57V20.6C8.42 22.89 10.28 24.75 12.57 24.75H20.6C22.89 24.75 24.75 22.89 24.75 20.6V12.57C24.75 10.28 22.89 8.42 20.6 8.42ZM16.67 21.54C13.94 21.54 11.72 19.32 11.72 16.59C11.72 13.86 13.94 11.64 16.67 11.64C19.4 11.64 21.62 13.86 21.62 16.59C21.62 19.32 19.4 21.54 16.67 21.54ZM21.91 12.48C21.26 12.48 20.73 11.95 20.73 11.3C20.73 10.65 21.26 10.12 21.91 10.12C22.56 10.12 23.09 10.65 23.09 11.3C23.09 11.95 22.56 12.48 21.91 12.48Z" fill="var(--fill-0, #0E1B42)" id="Vector_2"/>
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM26.64 20.6C26.64 23.93 23.93 26.64 20.6 26.64H12.57C9.24 26.64 6.53 23.93 6.53 20.6V12.57C6.53 9.24 9.24 6.53 12.57 6.53H20.6C23.93 6.53 26.64 9.24 26.64 12.57V20.6Z" fill="var(--fill-0, #0E1B42)" id="Vector_3"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "youtube" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="youtube" role="img">
|
||||
<path d="M20.36 16.45L15.14 13.61C14.93 13.49 14.19 13.64 14.19 13.89V19.43C14.19 19.67 14.92 19.83 15.13 19.71L20.59 17.01C20.81 16.89 20.59 16.57 20.36 16.45Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM27.74 19.42C27.74 22.05 25.61 24.18 22.98 24.18H10.81C8.18 24.18 6.05 22.05 6.05 19.42V13.76C6.05 11.13 8.18 9 10.81 9H22.98C25.61 9 27.74 11.13 27.74 13.76V19.42Z" fill="var(--fill-0, #0E1B42)" id="Vector_2"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "threads" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="threads" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "linkedin" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="linkedin" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "x" or item.value.platform|lower == "twitter" or item.value.platform|lower == "twitter-x" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="twitter-x" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
{{ item.value.platform }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="site-container footer-shell">
|
||||
<div class="footer-ad-slot" aria-hidden="true"></div>
|
||||
<div class="footer-separator" aria-hidden="true"></div>
|
||||
|
||||
<div class="footer-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="footer-links">
|
||||
{% if settings.base.NavigationSettings.footer_links %}
|
||||
<div class="footer-sections">
|
||||
{% for section in settings.base.NavigationSettings.footer_links %}
|
||||
<div class="footer-section">
|
||||
{% if section.value.title %}
|
||||
<h3>{{ section.value.title }}</h3>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for link in section.value.links %}
|
||||
<li><a href="{{ link.url }}" target="_blank">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="footer-main">
|
||||
<div class="footer-col footer-col--brand">
|
||||
<div class="footer-brand-content">
|
||||
{% get_footer_text %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<nav class="footer-col footer-col--menu" aria-label="Footer main menu">
|
||||
<ul class="footer-menu-list">
|
||||
{% if nav_latest_page %}
|
||||
<li><a href="{{ nav_latest_page.url }}">{{ nav_latest_page.title }}</a></li>
|
||||
{% else %}
|
||||
<li><a href="#">最新文章</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% wagtail_site as current_site %}
|
||||
{% if page %}
|
||||
{% with site_root=page.get_site.root_page %}
|
||||
{% for menu_page in site_root.get_children.live.in_menu %}
|
||||
<li><a href="{{ menu_page.url }}">{{ menu_page.title }}</a></li>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% elif current_site %}
|
||||
{% with site_root=current_site.root_page %}
|
||||
{% for menu_page in site_root.get_children.live.in_menu %}
|
||||
<li><a href="{{ menu_page.url }}">{{ menu_page.title }}</a></li>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- <div class="footer-col footer-col--right"> -->
|
||||
<div class="footer-fixed-links">
|
||||
<ul>
|
||||
<li><a href="#" data-subscribe-trigger>訂閱電子報</a></li>
|
||||
<li><a href="#" data-contact-trigger data-contact-category="collaboration">合作提案</a></li>
|
||||
<li><a href="#" data-contact-trigger data-contact-category="other">聯絡我們</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% with social_links=settings.base.SocialMediaSettings.links %}
|
||||
{% if social_links %}
|
||||
<div class="footer-socials" aria-label="social icons">
|
||||
{% for item in social_links %}
|
||||
<a href="{{ item.value.url }}" target="_blank" rel="noopener noreferrer" aria-label="{{ item.value.platform }}">
|
||||
{% if item.value.platform|lower == "facebook" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="facebook" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM23.12 8.37H21.18C19.27 8.37 18.67 9.56 18.67 10.78V13.67H22.94L22.26 18.12H18.67V28.89H13.85V18.12H9.94V13.67H13.85V10.28C13.85 6.42 16.15 4.29 19.67 4.29C21.36 4.29 23.12 4.59 23.12 4.59V8.38V8.37Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "instagram" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="instagram" role="img">
|
||||
<path d="M16.67 13.42C14.92 13.42 13.5 14.84 13.5 16.59C13.5 18.34 14.92 19.76 16.67 19.76C18.42 19.76 19.84 18.34 19.84 16.59C19.84 14.84 18.42 13.42 16.67 13.42Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path d="M20.6 8.42H12.57C10.28 8.42 8.42 10.28 8.42 12.57V20.6C8.42 22.89 10.28 24.75 12.57 24.75H20.6C22.89 24.75 24.75 22.89 24.75 20.6V12.57C24.75 10.28 22.89 8.42 20.6 8.42ZM16.67 21.54C13.94 21.54 11.72 19.32 11.72 16.59C11.72 13.86 13.94 11.64 16.67 11.64C19.4 11.64 21.62 13.86 21.62 16.59C21.62 19.32 19.4 21.54 16.67 21.54ZM21.91 12.48C21.26 12.48 20.73 11.95 20.73 11.3C20.73 10.65 21.26 10.12 21.91 10.12C22.56 10.12 23.09 10.65 23.09 11.3C23.09 11.95 22.56 12.48 21.91 12.48Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM26.64 20.6C26.64 23.93 23.93 26.64 20.6 26.64H12.57C9.24 26.64 6.53 23.93 6.53 20.6V12.57C6.53 9.24 9.24 6.53 12.57 6.53H20.6C23.93 6.53 26.64 9.24 26.64 12.57V20.6Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "youtube" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="youtube" role="img">
|
||||
<path d="M20.36 16.45L15.14 13.61C14.93 13.49 14.19 13.64 14.19 13.89V19.43C14.19 19.67 14.92 19.83 15.13 19.71L20.59 17.01C20.81 16.89 20.59 16.57 20.36 16.45Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM27.74 19.42C27.74 22.05 25.61 24.18 22.98 24.18H10.81C8.18 24.18 6.05 22.05 6.05 19.42V13.76C6.05 11.13 8.18 9 10.81 9H22.98C25.61 9 27.74 11.13 27.74 13.76V19.42Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "threads" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="threads" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "linkedin" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="linkedin" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% elif item.value.platform|lower == "x" or item.value.platform|lower == "twitter" or item.value.platform|lower == "twitter-x" %}
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="twitter-x" role="img">
|
||||
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
|
||||
<path class="icon-cutout" d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" transform="translate(7.59 7.59) scale(1.125)"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
{{ item.value.platform }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
|
||||
<div class="footer-powered">power by INNOVEDUS</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{% load wagtailsettings_tags wagtailimages_tags %}
|
||||
{% load wagtailsettings_tags wagtailimages_tags wagtailcore_tags %}
|
||||
{% get_settings use_default_site=True as settings %}
|
||||
|
||||
<header class="site-header{% if settings.base.HeaderSettings.logo_light and settings.base.HeaderSettings.logo_dark %} has-logo-variants{% endif %}">
|
||||
@ -39,67 +39,132 @@
|
||||
|
||||
<nav class="main-nav" id="site-nav">
|
||||
<ul class="main-menu" id="main-menu">
|
||||
{% with site_root=page.get_site.root_page %}
|
||||
{# Top-level menu: direct children of site root #}
|
||||
<li class="menu-item">
|
||||
<div class="menu-item-header">
|
||||
<a href="#">
|
||||
<span class="main-menu-link">最新文章</span>
|
||||
</a>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% if nav_latest_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_latest_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if nav_trending_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_trending_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% for menu_page in site_root.get_children.live.in_menu %}
|
||||
{% wagtail_site as current_site %}
|
||||
{% if page %}
|
||||
{% with site_root=page.get_site.root_page %}
|
||||
{# Top-level menu: direct children of site root #}
|
||||
<li class="menu-item">
|
||||
<div class="menu-item-header">
|
||||
<a href="{{ menu_page.url }}">
|
||||
<span class="main-menu-link">{{ menu_page.title }}</span>
|
||||
<a href="#">
|
||||
<span class="main-menu-link">最新文章</span>
|
||||
</a>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% if nav_latest_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_latest_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if nav_trending_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_trending_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% for menu_page in site_root.get_children.live.in_menu %}
|
||||
<li class="menu-item">
|
||||
<div class="menu-item-header">
|
||||
<a href="{{ menu_page.url }}">
|
||||
<span class="main-menu-link">{{ menu_page.title }}</span>
|
||||
</a>
|
||||
{% with submenu=menu_page.get_children.live.in_menu %}
|
||||
{% if submenu %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{# Second-level: direct children of each top-level page #}
|
||||
{% with submenu=menu_page.get_children.live.in_menu %}
|
||||
{% if submenu %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% for subpage in submenu %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ subpage.url }}">
|
||||
<span class="submenu-item-link">{{ subpage.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{# Second-level: direct children of each top-level page #}
|
||||
{% with submenu=menu_page.get_children.live.in_menu %}
|
||||
{% if submenu %}
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% for subpage in submenu %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ subpage.url }}">
|
||||
<span class="submenu-item-link">{{ subpage.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% elif current_site %}
|
||||
{% with site_root=current_site.root_page %}
|
||||
{# Top-level menu: direct children of site root #}
|
||||
<li class="menu-item">
|
||||
<div class="menu-item-header">
|
||||
<a href="#">
|
||||
<span class="main-menu-link">最新文章</span>
|
||||
</a>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if nav_latest_page or nav_trending_page %}
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% if nav_latest_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_latest_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if nav_trending_page %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ nav_trending_page.url }}">
|
||||
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% for menu_page in site_root.get_children.live.in_menu %}
|
||||
<li class="menu-item">
|
||||
<div class="menu-item-header">
|
||||
<a href="{{ menu_page.url }}">
|
||||
<span class="main-menu-link">{{ menu_page.title }}</span>
|
||||
</a>
|
||||
{% with submenu=menu_page.get_children.live.in_menu %}
|
||||
{% if submenu %}
|
||||
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{# Second-level: direct children of each top-level page #}
|
||||
{% with submenu=menu_page.get_children.live.in_menu %}
|
||||
{% if submenu %}
|
||||
<span class="menu-divider" aria-hidden="true"></span>
|
||||
<ul class="submenu">
|
||||
{% for subpage in submenu %}
|
||||
<li class="submenu-item">
|
||||
<a href="{{ subpage.url }}">
|
||||
<span class="submenu-item-link">{{ subpage.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{# Optional extra links from settings #}
|
||||
{% if settings.base.HeaderSettings.main_links %}
|
||||
|
||||
22
innovedus_cms/mysite/templates/includes/subscribe_fab.html
Normal file
22
innovedus_cms/mysite/templates/includes/subscribe_fab.html
Normal file
@ -0,0 +1,22 @@
|
||||
<div class="subscribe-fab" data-subscribe-fab>
|
||||
<div id="subscribe-fab-panel" class="subscribe-fab__panel">
|
||||
<button class="subscribe-fab__toggle" type="button" aria-expanded="false" aria-controls="subscribe-fab-panel">
|
||||
<svg class="subscribe-fab__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 6h18v12H3z" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||||
<path d="M3 7l9 7 9-7" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||||
</svg>
|
||||
<span class="subscribe-fab__text">點我訂閱</span>
|
||||
</button>
|
||||
|
||||
<form class="subscribe-fab__form" method="post" action="{% url 'newsletter_subscribe' %}">
|
||||
{% csrf_token %}
|
||||
<input
|
||||
class="subscribe-fab__input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="輸入 Email">
|
||||
<button class="subscribe-fab__submit" type="submit">確認訂閱</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
60
innovedus_cms/mysite/templates/wagtailsettings/edit.html
Normal file
60
innovedus_cms/mysite/templates/wagtailsettings/edit.html
Normal file
@ -0,0 +1,60 @@
|
||||
{% extends "wagtailadmin/generic/edit.html" %}
|
||||
{% load i18n wagtailadmin_tags %}
|
||||
|
||||
{% block before_form %}
|
||||
{% if site_switcher %}
|
||||
<form class="w-mb-8" method="get" id="settings-site-switch" novalidate>
|
||||
<label for="{{ site_switcher.site.id_for_label }}">
|
||||
{% trans "Site" %}:
|
||||
</label>
|
||||
{{ site_switcher.site }}
|
||||
</form>
|
||||
{% elif site_for_header %}
|
||||
<div class="w-mb-8">
|
||||
{% trans "Site" %}:
|
||||
{{ site_for_header.hostname }}
|
||||
{% if site_for_header.is_default_site %}[{% trans "default" %}]{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_content %}
|
||||
{% if panel %}
|
||||
{{ panel.render_form_content }}
|
||||
{% else %}
|
||||
{{ block.super }}
|
||||
{% endif %}
|
||||
|
||||
{% if form.smtp_relay_host and form.smtp_password %}
|
||||
<section class="w-panel w-mt-8">
|
||||
<div class="w-panel__header">
|
||||
<h2 class="w-panel__heading">{% trans "Send test email (save settings before sending)." %}</h2>
|
||||
</div>
|
||||
<div class="w-panel__content">
|
||||
<ul class="fields">
|
||||
<li>
|
||||
<label class="w-block w-font-semibold w-mb-2" for="smtp_test_email">{% trans "Send email to" %}</label>
|
||||
<input
|
||||
class="w-input"
|
||||
type="email"
|
||||
id="smtp_test_email"
|
||||
name="smtp_test_email"
|
||||
value="{{ form.data.smtp_test_email|default:'' }}"
|
||||
placeholder="name@example.com"
|
||||
>
|
||||
<p class="help">{% trans "This field is only for this test and will not be saved." %}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button-secondary"
|
||||
formaction="{% url 'newsletter_smtp_test' %}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
>
|
||||
{% trans "Send test email" %}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -8,6 +8,7 @@ from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
||||
from search import views as search_views
|
||||
from home import views as home_views
|
||||
from base import views as base_views
|
||||
|
||||
urlpatterns = [
|
||||
path("django-admin/", admin.site.urls),
|
||||
@ -16,6 +17,13 @@ urlpatterns = [
|
||||
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
||||
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
||||
path("search/", search_views.search, name="search"),
|
||||
path("newsletter/subscribe/", base_views.newsletter_subscribe, name="newsletter_subscribe"),
|
||||
path("contact-form/submit/", base_views.contact_form_submit, name="contact_form_submit"),
|
||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
|
||||
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -4,4 +4,5 @@ gunicorn
|
||||
dj-database-url
|
||||
psycopg[binary]
|
||||
python-dotenv
|
||||
django-storages[boto3]
|
||||
django-storages[boto3]
|
||||
certifi
|
||||
|
||||
@ -1,33 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load wagtailcore_tags %}
|
||||
{% load wagtailcore_tags static %}
|
||||
|
||||
{% block body_class %}template-searchresults{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumbs" aria-label="breadcrumb">
|
||||
<ol>
|
||||
<li>
|
||||
{% if site_root %}
|
||||
<a href="{{ site_root.url }}">首頁</a>
|
||||
{% else %}
|
||||
<a href="/">首頁</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><span>搜尋</span></li>
|
||||
{% if search_query %}
|
||||
<li><span>{{ search_query }}</span></li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% if search_query %}
|
||||
<div class="site-hero-band full-bleed">
|
||||
<div class="site-container">
|
||||
<div class="block-title category-title"><span>{{ search_query }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="search-results">
|
||||
{% if search_query %}
|
||||
{% if results_count %}
|
||||
{% include "home/includes/page-article-list.html" %}
|
||||
{% include "home/includes/page-article-list.html" with category=category_sections.0 show_hero=False empty_message="沒有符合條件的文章" %}
|
||||
{% else %}
|
||||
<p>找不到與「{{ search_query }}」相關的文章。</p>
|
||||
{% endif %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user