Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b441106d2d | ||
|
|
be99e5e635 | ||
|
|
ccb675f4bc | ||
|
|
5048f865f2 | ||
|
|
7a632c5ebd | ||
|
|
4679cc70ef | ||
|
|
8a0621d1ce | ||
|
|
9b3673831a | ||
|
|
6ea501dc62 | ||
|
|
4c78500ec9 | ||
|
|
69ef3ccf72 |
@ -1,6 +1,6 @@
|
|||||||
# 電子報介接備忘錄(租戶端 / Wagtail)
|
# 電子報介接備忘錄(租戶端 / Wagtail)
|
||||||
|
|
||||||
最後更新:2026-02-11
|
最後更新:2026-02-18
|
||||||
|
|
||||||
## 1. 目標與前提
|
## 1. 目標與前提
|
||||||
|
|
||||||
@ -154,3 +154,270 @@
|
|||||||
3. 再完成退訂頁與退訂 API 串接。
|
3. 再完成退訂頁與退訂 API 串接。
|
||||||
4. 建立電子報 app 與 HTML 編輯器資料模型。
|
4. 建立電子報 app 與 HTML 編輯器資料模型。
|
||||||
5. 最後接排程任務與 Send Engine 發信。
|
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
|
WORKDIR /code
|
||||||
|
|
||||||
# Create an unprivileged user to run the app
|
# 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
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
RUN set -ex && \
|
RUN set -ex && \
|
||||||
|
|||||||
@ -1,3 +1,20 @@
|
|||||||
from django.contrib import admin
|
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.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from wagtail.admin.panels import (
|
from wagtail.admin.panels import (
|
||||||
FieldPanel,
|
FieldPanel,
|
||||||
MultiFieldPanel,
|
MultiFieldPanel,
|
||||||
@ -27,6 +29,9 @@ from wagtail.snippets.models import register_snippet
|
|||||||
from wagtail.fields import StreamField
|
from wagtail.fields import StreamField
|
||||||
from wagtail import blocks
|
from wagtail import blocks
|
||||||
|
|
||||||
|
from .security import encrypt_text
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class HeaderSettings(BaseGenericSetting):
|
class HeaderSettings(BaseGenericSetting):
|
||||||
logo_light = models.ForeignKey(
|
logo_light = models.ForeignKey(
|
||||||
@ -35,7 +40,8 @@ class HeaderSettings(BaseGenericSetting):
|
|||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="+",
|
related_name="+",
|
||||||
help_text="深色底用(亮色 logo)",
|
help_text=_("Use on dark background (light logo)."),
|
||||||
|
verbose_name=_("Light Logo"),
|
||||||
)
|
)
|
||||||
logo_dark = models.ForeignKey(
|
logo_dark = models.ForeignKey(
|
||||||
"wagtailimages.Image",
|
"wagtailimages.Image",
|
||||||
@ -43,15 +49,16 @@ class HeaderSettings(BaseGenericSetting):
|
|||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="+",
|
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([
|
extra_links = StreamField([
|
||||||
("link", blocks.StructBlock([
|
("link", blocks.StructBlock([
|
||||||
("label", blocks.CharBlock()),
|
("label", blocks.CharBlock(label=_("Label"))),
|
||||||
("url", blocks.URLBlock())
|
("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 = [
|
panels = [
|
||||||
MultiFieldPanel(
|
MultiFieldPanel(
|
||||||
@ -61,61 +68,520 @@ class HeaderSettings(BaseGenericSetting):
|
|||||||
FieldPanel("site_name"),
|
FieldPanel("site_name"),
|
||||||
FieldPanel("extra_links"),
|
FieldPanel("extra_links"),
|
||||||
],
|
],
|
||||||
heading="Header Settings",
|
heading=_("Header Settings"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Header Settings"
|
verbose_name = _("Header Settings")
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class NavigationSettings(BaseGenericSetting):
|
class NavigationSettings(BaseGenericSetting):
|
||||||
footer_links = StreamField([
|
footer_links = StreamField([
|
||||||
("section", blocks.StructBlock([
|
("section", blocks.StructBlock([
|
||||||
("title", blocks.CharBlock(required=False)),
|
("title", blocks.CharBlock(required=False, label=_("Section Title"))),
|
||||||
("links", blocks.ListBlock(blocks.StructBlock([
|
("links", blocks.ListBlock(blocks.StructBlock([
|
||||||
("label", blocks.CharBlock()),
|
("label", blocks.CharBlock(label=_("Label"))),
|
||||||
("url", blocks.URLBlock())
|
("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 = [
|
panels = [
|
||||||
FieldPanel("footer_links"),
|
FieldPanel("footer_links"),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Footer Navigation"
|
verbose_name = _("Footer Navigation")
|
||||||
|
|
||||||
class SocialLinkBlock(blocks.StructBlock):
|
class SocialLinkBlock(blocks.StructBlock):
|
||||||
SOCIAL_MEDIA_CHOICES = [
|
SOCIAL_MEDIA_CHOICES = [
|
||||||
("facebook", "Facebook"),
|
("facebook", _("Facebook")),
|
||||||
("twitter", "Twitter"),
|
("twitter", _("Twitter")),
|
||||||
("instagram", "Instagram"),
|
("instagram", _("Instagram")),
|
||||||
("threads", "Threads"),
|
("threads", _("Threads")),
|
||||||
("linkedin", "LinkedIn"),
|
("linkedin", _("LinkedIn")),
|
||||||
("youtube", "YouTube"),
|
("youtube", _("YouTube")),
|
||||||
]
|
]
|
||||||
|
|
||||||
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES)
|
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES, label=_("Platform"))
|
||||||
url = blocks.URLBlock()
|
url = blocks.URLBlock(label=_("URL"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
icon = "link"
|
icon = "link"
|
||||||
label = "Social Link"
|
label = _("Social Link")
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class SocialMediaSettings(BaseGenericSetting):
|
class SocialMediaSettings(BaseGenericSetting):
|
||||||
links = StreamField([
|
links = StreamField([
|
||||||
("link", SocialLinkBlock()),
|
("link", SocialLinkBlock()),
|
||||||
], use_json_field=True)
|
], use_json_field=True, verbose_name=_("Social Links"))
|
||||||
|
|
||||||
panels = [FieldPanel("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
|
@register_snippet
|
||||||
class BannerSnippet(models.Model):
|
class BannerSnippet(models.Model):
|
||||||
key = models.CharField(max_length=50, blank=True, help_text="識別用 key(例如 home / category)")
|
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)
|
title = models.CharField(max_length=255, blank=True, verbose_name=_("Title"))
|
||||||
image = models.ForeignKey(
|
image = models.ForeignKey(
|
||||||
"wagtailimages.Image",
|
"wagtailimages.Image",
|
||||||
null=True,
|
null=True,
|
||||||
@ -124,9 +590,9 @@ class BannerSnippet(models.Model):
|
|||||||
related_name="+",
|
related_name="+",
|
||||||
)
|
)
|
||||||
link_url = models.URLField(blank=True)
|
link_url = models.URLField(blank=True)
|
||||||
link_text = models.CharField(max_length=100, blank=True)
|
link_text = models.CharField(max_length=100, blank=True, verbose_name=_("Link Text"))
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
||||||
sort_order = models.PositiveIntegerField(default=0)
|
sort_order = models.PositiveIntegerField(default=0, verbose_name=_("Sort Order"))
|
||||||
|
|
||||||
panels = [
|
panels = [
|
||||||
FieldPanel("key"),
|
FieldPanel("key"),
|
||||||
@ -140,8 +606,8 @@ class BannerSnippet(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["sort_order", "id"]
|
ordering = ["sort_order", "id"]
|
||||||
verbose_name = "Banner"
|
verbose_name = _("Banner")
|
||||||
verbose_name_plural = "Banners"
|
verbose_name_plural = _("Banners")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title or f"Banner {self.pk}"
|
return self.title or f"Banner {self.pk}"
|
||||||
@ -172,4 +638,35 @@ class FooterText(
|
|||||||
return {"footer_text": self.body}
|
return {"footer_text": self.body}
|
||||||
|
|
||||||
class Meta(TranslatableMixin.Meta):
|
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.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
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Run pending migrations and collect static assets before starting the app
|
APP_ROLE="${APP_ROLE:-web}"
|
||||||
python manage.py migrate --noinput
|
|
||||||
python manage.py collectstatic --noinput
|
|
||||||
|
|
||||||
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):
|
def get_context(self, request):
|
||||||
context = super().get_context(request)
|
context = super().get_context(request)
|
||||||
context["category_sections"] = [self.get_latest_articles(request)]
|
context["category_sections"] = [self.get_latest_articles(request)]
|
||||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
# breadcrumbs, site_root = self.build_breadcrumbs()
|
||||||
context["breadcrumbs"] = breadcrumbs
|
# context["breadcrumbs"] = breadcrumbs
|
||||||
context["breadcrumb_root"] = site_root
|
# context["breadcrumb_root"] = site_root
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -216,9 +216,9 @@ class TrendingPage(Page, CategoryMixin, BreadcrumbMixin):
|
|||||||
def get_context(self, request):
|
def get_context(self, request):
|
||||||
context = super().get_context(request)
|
context = super().get_context(request)
|
||||||
context["category_sections"] = [self.get_trending_articles(request)]
|
context["category_sections"] = [self.get_trending_articles(request)]
|
||||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
# breadcrumbs, site_root = self.build_breadcrumbs()
|
||||||
context["breadcrumbs"] = breadcrumbs
|
# context["breadcrumbs"] = breadcrumbs
|
||||||
context["breadcrumb_root"] = site_root
|
# context["breadcrumb_root"] = site_root
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@ -311,8 +311,8 @@ class ArticlePage(Page, BreadcrumbMixin):
|
|||||||
context = super().get_context(request)
|
context = super().get_context(request)
|
||||||
|
|
||||||
breadcrumbs, site_root = self.build_breadcrumbs()
|
breadcrumbs, site_root = self.build_breadcrumbs()
|
||||||
context["breadcrumbs"] = breadcrumbs
|
# context["breadcrumbs"] = breadcrumbs
|
||||||
context["breadcrumb_root"] = site_root
|
# context["breadcrumb_root"] = site_root
|
||||||
category_crumbs = [
|
category_crumbs = [
|
||||||
crumb
|
crumb
|
||||||
for crumb in breadcrumbs
|
for crumb in breadcrumbs
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
.page-article-list{
|
/* .page-article-list{
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.first-article {
|
.first-article {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.first-article .first-article-title {
|
.first-article .first-article-title {
|
||||||
font-size: 40px;
|
font-size: 36px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #f4a41c;
|
color: #f4a41c;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -123,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 40px;
|
font-size: 30px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@ -152,6 +152,15 @@
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination a.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
color: #0e1b4266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a.disabled button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination .prev-page span,
|
.pagination .prev-page span,
|
||||||
.pagination .next-page span {
|
.pagination .next-page span {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -199,6 +208,10 @@
|
|||||||
grid-template-columns: 318px 1fr;
|
grid-template-columns: 318px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.first-article .first-article-title {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.first-article .first-article-image {
|
.first-article .first-article-image {
|
||||||
width: 318px;
|
width: 318px;
|
||||||
}
|
}
|
||||||
@ -208,6 +221,14 @@
|
|||||||
height: 290px;
|
height: 290px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.first-article .first-article-intro {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-article .first-article-body {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.article-list-item {
|
.article-list-item {
|
||||||
grid-template-columns: 318px 1fr;
|
grid-template-columns: 318px 1fr;
|
||||||
}
|
}
|
||||||
@ -219,6 +240,10 @@
|
|||||||
.article-thumb img{
|
.article-thumb img{
|
||||||
width: 318px;
|
width: 318px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@ -235,9 +260,17 @@
|
|||||||
"image"
|
"image"
|
||||||
"content";
|
"content";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 575px) and (max-width: 767px) {
|
@media (min-width: 575px) and (max-width: 767px) {
|
||||||
|
.first-article .first-article-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.first-article .first-article-image {
|
.first-article .first-article-image {
|
||||||
width: 426px;
|
width: 426px;
|
||||||
}
|
}
|
||||||
@ -247,6 +280,14 @@
|
|||||||
height: 260px;
|
height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.first-article .first-article-intro {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-article .first-article-body {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.article-thumb {
|
.article-thumb {
|
||||||
width: 426px;
|
width: 426px;
|
||||||
}
|
}
|
||||||
@ -258,6 +299,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 574px) {
|
@media (max-width: 574px) {
|
||||||
|
.first-article .first-article-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.first-article .first-article-image {
|
.first-article .first-article-image {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
@ -267,6 +312,18 @@
|
|||||||
height: 218px;
|
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 {
|
.article-thumb {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
@ -275,4 +332,8 @@
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
height: 218px;
|
height: 218px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-date {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
.article-content p {
|
.article-content p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-content .intro {
|
.article-content .intro {
|
||||||
@ -319,6 +320,10 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.back-to-top {
|
.back-to-top {
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
}
|
}
|
||||||
@ -337,6 +342,10 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.article-content .intro {
|
.article-content .intro {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"image content";
|
"image content";
|
||||||
gap: 16px 40px;
|
gap: 16px 40px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-first-article .first-article-image {
|
.block-first-article .first-article-image {
|
||||||
@ -41,9 +42,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-first-article .first-article-title {
|
.block-first-article .first-article-title {
|
||||||
font-size: 40px;
|
font-size: 36px;
|
||||||
|
line-height: 1.25;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #000000;
|
color: #0e1b42;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
@ -97,7 +99,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-first-article .first-article-title {
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,9 +131,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-first-article .first-article-title {
|
.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) {
|
@media (max-width: 574px) {
|
||||||
@ -137,10 +158,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-first-article .first-article-title {
|
.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 {
|
.block-first-article .first-article-intro {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-first-article .first-article-body {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
|
|
||||||
.block-list-items .article-title {
|
.block-list-items .article-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-list-items .article-date {
|
.block-list-items .article-date {
|
||||||
@ -58,6 +59,7 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-list-more-article a {
|
.block-list-more-article a {
|
||||||
@ -119,6 +121,10 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-list-items .article-date {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.block-list-more-article {
|
.block-list-more-article {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@ -143,7 +149,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-list-items .article-date {
|
.block-list-items .article-date {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-list-more-article span {
|
.block-list-more-article span {
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
.list-title {
|
.list-title {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-title-divider {
|
.block-title-divider {
|
||||||
|
|||||||
@ -90,7 +90,7 @@
|
|||||||
pointer-events: none;
|
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: url("../img/picfrm_b194.png") no-repeat left bottom / cover;
|
||||||
background-size: 194px 25px;
|
background-size: 194px 25px;
|
||||||
}
|
}
|
||||||
@ -98,7 +98,7 @@
|
|||||||
.section-o .horizontal-list-thumb::after {
|
.section-o .horizontal-list-thumb::after {
|
||||||
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
||||||
background-size: 194px 25px;
|
background-size: 194px 25px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.horizontal-list li img {
|
.horizontal-list li img {
|
||||||
display: block;
|
display: block;
|
||||||
@ -124,6 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.horizontal-list .article-intro {
|
.horizontal-list .article-intro {
|
||||||
|
min-height: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal-list .article-date {
|
.horizontal-list .article-date {
|
||||||
|
|||||||
@ -3,14 +3,16 @@
|
|||||||
grid-template-columns: 480px 1fr;
|
grid-template-columns: 480px 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"header header"
|
"header header"
|
||||||
|
"head head"
|
||||||
"image content";
|
"image content";
|
||||||
|
padding: 40px 0;
|
||||||
gap: 16px 40px;
|
gap: 16px 40px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .news-hero-header {
|
.news-hero .news-hero-header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
display: flex;
|
/* display: flex; */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@ -24,13 +26,13 @@
|
|||||||
|
|
||||||
.news-hero .fist-news-title {
|
.news-hero .fist-news-title {
|
||||||
grid-area: head;
|
grid-area: head;
|
||||||
max-width: 580px;
|
/* max-width: 580px; */
|
||||||
font-size: 40px;
|
font-size: 36px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #eb9f13;
|
color: #eb9f13;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 1;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -43,7 +45,7 @@
|
|||||||
width: 480px;
|
width: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .first-news-image::after {
|
/* .news-hero .first-news-image::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
@ -53,7 +55,7 @@
|
|||||||
background: url("../img/picfrm_b480.png") no-repeat left bottom / cover;
|
background: url("../img/picfrm_b480.png") no-repeat left bottom / cover;
|
||||||
background-size: 480px 25px;
|
background-size: 480px 25px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.news-hero .first-news-image img {
|
.news-hero .first-news-image img {
|
||||||
display: block;
|
display: block;
|
||||||
@ -64,14 +66,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .first-news-content {
|
.news-hero .first-news-content {
|
||||||
|
grid-area: content;
|
||||||
|
height: 293px;
|
||||||
max-height: 293px;
|
max-height: 293px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .first-news-content a {
|
.news-hero .first-news-content a {
|
||||||
grid-area: content;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .fist-news-date,
|
.news-hero .fist-news-date,
|
||||||
@ -94,6 +99,7 @@
|
|||||||
.news-hero .first-news-body {
|
.news-hero .first-news-body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -136,7 +142,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-list-thumb::after {
|
/* .news-list-thumb::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
@ -146,7 +152,7 @@
|
|||||||
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
|
||||||
background-size: 194px 25px;
|
background-size: 194px 25px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.news-list-thumb img {
|
.news-list-thumb img {
|
||||||
display: block;
|
display: block;
|
||||||
@ -166,6 +172,10 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.news-list-items .article-intro {
|
||||||
|
min-height: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
.news-list-items .article-date {
|
.news-list-items .article-date {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
@ -191,17 +201,17 @@
|
|||||||
grid-template-columns: 318px 1fr;
|
grid-template-columns: 318px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .fist-news-title {
|
/* .news-hero .fist-news-title {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.news-hero .first-news-image {
|
.news-hero .first-news-image {
|
||||||
width: 318px;
|
width: 318px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-hero .first-news-image::after {
|
/* .news-hero .first-news-image::after {
|
||||||
background-size: 318px 25px;
|
background-size: 318px 25px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.news-hero .first-news-image img {
|
.news-hero .first-news-image img {
|
||||||
width: 318px;
|
width: 318px;
|
||||||
@ -219,7 +229,7 @@
|
|||||||
|
|
||||||
@media (min-width: 575px) and (max-width: 1023px) {
|
@media (min-width: 575px) and (max-width: 1023px) {
|
||||||
.news-hero .fist-news-title {
|
.news-hero .fist-news-title {
|
||||||
font-size: 36px;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-news {
|
.more-news {
|
||||||
@ -302,6 +312,11 @@
|
|||||||
height:260px;
|
height:260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.news-hero .first-news-content {
|
||||||
|
height: 260px;
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
.news-list-items a > div:nth-of-type(1) {
|
.news-list-items a > div:nth-of-type(1) {
|
||||||
grid-row: 1 / span 3;
|
grid-row: 1 / span 3;
|
||||||
}
|
}
|
||||||
@ -364,6 +379,21 @@
|
|||||||
height: 220px;
|
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 {
|
.news-hero .fist-news-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@ -386,10 +416,10 @@
|
|||||||
height: 110px;
|
height: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-list-thumb::after {
|
/* .news-list-thumb::after {
|
||||||
background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
|
background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
|
||||||
background-size: 139px 25px;
|
background-size: 139px 25px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.news-list-items a > div:nth-of-type(3) {
|
.news-list-items a > div:nth-of-type(3) {
|
||||||
display: none;
|
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" %}
|
{% extends "base.html" %}
|
||||||
{% load wagtailcore_tags wagtailimages_tags static %}
|
{% load wagtailcore_tags wagtailimages_tags static home_tags %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/article_page.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 %}
|
{% image related.cover_image max-194x133 as related_cover %}
|
||||||
<img src="{{ related_cover.url }}" alt="{{ related.title }}">
|
<img src="{{ related_cover.url }}" alt="{{ related.title }}">
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<p class="related-article-date">{{ related.date|date:"Y/m/d" }}</p>
|
<p class="related-article-date">{{ related.date|date:"Y/m/d" }}</p>
|
||||||
|
|||||||
@ -1,19 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load wagtailcore_tags %}
|
{% load wagtailcore_tags static %}
|
||||||
{% block content %}
|
{% block extra_css %}
|
||||||
<nav class="breadcrumbs" aria-label="breadcrumb">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
|
||||||
<ol>
|
{% endblock %}
|
||||||
<li>
|
{% block content %}
|
||||||
{% if site_root %}
|
<div class="site-hero-band full-bleed">
|
||||||
<a href="{{ site_root.url }}">首頁</a>
|
<div class="site-container">
|
||||||
{% else %}
|
<div class="block-title category-title"><span>#{{ tag.name }}</span></div>
|
||||||
<a href="/">首頁</a>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</li>
|
|
||||||
<li><span>標籤</span></li>
|
{% include "home/includes/page-article-list.html" with category=category_sections.0 show_hero=False empty_message="沒有符合條件的文章" %}
|
||||||
<li><span>#{{ tag.name }}</span></li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{% include "home/includes/page-article-list.html" with category=category_sections.0 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -9,9 +9,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="site-hero-band full-bleed">
|
<div class="site-hero-band full-bleed">
|
||||||
|
{% include "base/includes/home_banner.html" %}
|
||||||
|
|
||||||
<div class="site-container">
|
<div class="site-container">
|
||||||
<div class="home-hero">
|
<div class="home-hero">
|
||||||
{% include "base/includes/home_banner.html" %}
|
|
||||||
{% with top_section=sections.top_section %}
|
{% with top_section=sections.top_section %}
|
||||||
{% for section in top_section %}
|
{% for section in top_section %}
|
||||||
{% include "home/includes/news_list.html" with section=section %}
|
{% include "home/includes/news_list.html" with section=section %}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{% load wagtailimages_tags static %}
|
{% load wagtailimages_tags static home_tags %}
|
||||||
|
|
||||||
<div class="site-hero-band full-bleed">
|
{% if show_hero != False %}
|
||||||
|
<div class="site-hero-band full-bleed">
|
||||||
<div class="site-container">
|
<div class="site-container">
|
||||||
{% with first_article=items|first %}
|
{% with first_article=items|first %}
|
||||||
{% if first_article %}
|
{% if first_article %}
|
||||||
@ -11,7 +12,8 @@
|
|||||||
{% image first_article.cover_image max-410x293 as cover %}
|
{% image first_article.cover_image max-410x293 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -35,13 +37,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="empty">目前沒有文章</span>
|
<span class="empty">{{ empty_message|default:"目前沒有文章" }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="article-list-lower">
|
{% endif %}
|
||||||
{% if items|length >= 2 %}
|
|
||||||
|
{% if show_hero != False %}
|
||||||
|
{% if items|length > 2 %}
|
||||||
|
<div class="article-list-lower">
|
||||||
{% for article in items|slice:"1:" %}
|
{% for article in items|slice:"1:" %}
|
||||||
<a href="{{ article.url }}">
|
<a href="{{ article.url }}">
|
||||||
<div class="article-list-item">
|
<div class="article-list-item">
|
||||||
@ -50,7 +55,8 @@
|
|||||||
{% image article.cover_image max-410x218 as cover %}
|
{% image article.cover_image max-410x218 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="article-content">
|
<div class="article-content">
|
||||||
@ -60,5 +66,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% 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">
|
||||||
|
{% 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>
|
||||||
|
{% else %}
|
||||||
|
<span class="empty">{{ empty_message|default:"沒有符合條件的文章" }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{% load wagtailimages_tags static %}
|
{% load wagtailimages_tags static home_tags %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list.css' %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -13,7 +13,8 @@
|
|||||||
{% image first_article.cover_image max-480x293 as cover %}
|
{% image first_article.cover_image max-480x293 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{% load wagtailimages_tags static %}
|
{% load wagtailimages_tags static home_tags %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list_lower.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list_lower.css' %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -12,7 +12,8 @@
|
|||||||
{% image article.cover_image max-194x133 as cover %}
|
{% image article.cover_image max-194x133 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div><span class="article-title">{{ article.title }}</span></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>
|
<div class="horizontal-list-wrap" data-horizontal-list>
|
||||||
<button class="horizontal-list-arrow is-hidden" type="button" data-dir="left" aria-label="上一頁">
|
<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 %}
|
{% image article.cover_image max-194x133 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div><span class="article-title">{{ article.title }}</span></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-list-wrap" data-news-list>
|
||||||
<div class="news-hero">
|
<div class="news-hero">
|
||||||
@ -12,6 +12,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<a href="{{ section.url }}"><span class="more-link">查看全部</span></a>
|
<a href="{{ section.url }}"><span class="more-link">查看全部</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% with first_article=section.items|first %}
|
{% with first_article=section.items|first %}
|
||||||
{% if first_article %}
|
{% if first_article %}
|
||||||
<div class="fist-news-title">
|
<div class="fist-news-title">
|
||||||
@ -19,18 +20,14 @@
|
|||||||
<span>{{ first_article.title }}</span>
|
<span>{{ first_article.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% with first_article=section.items|first %}
|
|
||||||
{% if first_article %}
|
|
||||||
<div class="first-news-image">
|
<div class="first-news-image">
|
||||||
<a href="{{ first_article.url }}">
|
<a href="{{ first_article.url }}">
|
||||||
{% if first_article.cover_image %}
|
{% if first_article.cover_image %}
|
||||||
{% image first_article.cover_image max-480x293 as cover %}
|
{% image first_article.cover_image max-480x293 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ first_article.title }}" height="293" width="480"/>
|
<img src="{{ cover.url }}" alt="{{ first_article.title }}" height="293" width="480"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -65,7 +62,8 @@
|
|||||||
{% image article.cover_image max-194x133 as cover %}
|
{% image article.cover_image max-194x133 as cover %}
|
||||||
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div><span class="article-title">{{ article.title }}</span></div>
|
<div><span class="article-title">{{ article.title }}</span></div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<div class="page-article-list">
|
<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 %}
|
{% if category.items.paginator.num_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
@ -12,7 +12,16 @@
|
|||||||
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
|
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
|
||||||
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
|
<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">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span>BACK</span>
|
<span>BACK</span>
|
||||||
@ -34,7 +43,16 @@
|
|||||||
<span>NEXT</span>
|
<span>NEXT</span>
|
||||||
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
|
<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">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
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 taggit.models import Tag
|
||||||
from wagtail.models import Site
|
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):
|
def hashtag_search(request, slug):
|
||||||
@ -12,7 +12,7 @@ def hashtag_search(request, slug):
|
|||||||
articles = (
|
articles = (
|
||||||
ArticlePage.objects.live()
|
ArticlePage.objects.live()
|
||||||
.filter(tags__slug=slug)
|
.filter(tags__slug=slug)
|
||||||
.order_by("-date")
|
.order_by("-date", "-id")
|
||||||
)
|
)
|
||||||
|
|
||||||
paginator = Paginator(articles, PAGE_SIZE)
|
paginator = Paginator(articles, PAGE_SIZE)
|
||||||
@ -38,6 +38,11 @@ def hashtag_search(request, slug):
|
|||||||
"page_range": paginator.get_elided_page_range(page_obj.number),
|
"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,
|
"site_root": site_root,
|
||||||
"page": site_root.specific if site_root else None,
|
"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, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
except Exception:
|
||||||
|
certifi = None
|
||||||
|
|
||||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
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):
|
def env_list(name, default):
|
||||||
"""
|
"""
|
||||||
Return a list from a comma-separated env var; fall back to provided default list.
|
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
|
USE_TZ = True
|
||||||
|
|
||||||
|
LOCALE_PATHS = [
|
||||||
|
os.path.join(BASE_DIR, "locale"),
|
||||||
|
]
|
||||||
|
|
||||||
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
|
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
|
||||||
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
|
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
|
||||||
# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.
|
# 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 {
|
.main-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 18px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -276,7 +276,7 @@ a {
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 181px;
|
width: 153px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-darkbackground .header-search input[type="search"] {
|
.template-darkbackground .header-search input[type="search"] {
|
||||||
@ -287,88 +287,6 @@ a {
|
|||||||
color: #ffffff88;
|
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 {
|
.block-title {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 197px;
|
width: 197px;
|
||||||
@ -404,35 +322,6 @@ footer .footer-sections {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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) {
|
@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 }}" />
|
<meta name="description" content="{{ page.search_description }}" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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 %}
|
{% if ga4_measurement_id %}
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_measurement_id }}"></script>
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_measurement_id }}"></script>
|
||||||
<script>
|
<script>
|
||||||
@ -34,6 +36,9 @@
|
|||||||
|
|
||||||
{# Global stylesheets #}
|
{# Global stylesheets #}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/mysite.css' %}">
|
<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 %}
|
{% block extra_css %}
|
||||||
{# Override this in templates to add extra stylesheets #}
|
{# Override this in templates to add extra stylesheets #}
|
||||||
@ -52,10 +57,14 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include "includes/footer.html" %}
|
{% include "includes/footer.html" %}
|
||||||
|
{% include "includes/contact_form.html" %}
|
||||||
|
{% include "includes/subscribe_fab.html" %}
|
||||||
|
|
||||||
{# Global javascript #}
|
{# Global javascript #}
|
||||||
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
|
<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/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 #}
|
{# Instagram embed script to render IG oEmbeds #}
|
||||||
<script async src="https://www.instagram.com/embed.js"></script>
|
<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,30 +1,70 @@
|
|||||||
{% load navigation_tags %}
|
{% load navigation_tags wagtailcore_tags %}
|
||||||
|
|
||||||
<footer>
|
<footer class="site-footer">
|
||||||
<div class="site-container footer-inner">
|
<div class="site-container footer-shell">
|
||||||
<div class="company-info">
|
<div class="footer-ad-slot" aria-hidden="true"></div>
|
||||||
<div class="copyright">
|
<div class="footer-separator" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="footer-main">
|
||||||
|
<div class="footer-col footer-col--brand">
|
||||||
|
<div class="footer-brand-content">
|
||||||
{% get_footer_text %}
|
{% get_footer_text %}
|
||||||
</div>
|
</div>
|
||||||
|
</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 %}
|
{% with social_links=settings.base.SocialMediaSettings.links %}
|
||||||
{% if social_links %}
|
{% if social_links %}
|
||||||
<div class="footer-socials" aria-label="social icons">
|
<div class="footer-socials" aria-label="social icons">
|
||||||
{% for item in social_links %}
|
{% for item in social_links %}
|
||||||
<a href="{{ item.value.url }}" target="_blank" aria-label="{{ item.value.platform }}">
|
<a href="{{ item.value.url }}" target="_blank" rel="noopener noreferrer" aria-label="{{ item.value.platform }}">
|
||||||
{% if item.value.platform|lower == "facebook" %}
|
{% 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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{% elif item.value.platform|lower == "instagram" %}
|
{% 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">
|
<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="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)" id="Vector_2"/>
|
<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)" id="Vector_3"/>
|
<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>
|
</svg>
|
||||||
{% elif item.value.platform|lower == "youtube" %}
|
{% 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">
|
<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="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)" 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 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>
|
</svg>
|
||||||
{% elif item.value.platform|lower == "threads" %}
|
{% 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">
|
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="threads" role="img">
|
||||||
@ -49,27 +89,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
<!-- </div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-divider" aria-hidden="true"></div>
|
<div class="footer-powered">power by INNOVEDUS</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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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 %}
|
{% 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 %}">
|
<header class="site-header{% if settings.base.HeaderSettings.logo_light and settings.base.HeaderSettings.logo_dark %} has-logo-variants{% endif %}">
|
||||||
@ -39,6 +39,8 @@
|
|||||||
|
|
||||||
<nav class="main-nav" id="site-nav">
|
<nav class="main-nav" id="site-nav">
|
||||||
<ul class="main-menu" id="main-menu">
|
<ul class="main-menu" id="main-menu">
|
||||||
|
{% wagtail_site as current_site %}
|
||||||
|
{% if page %}
|
||||||
{% with site_root=page.get_site.root_page %}
|
{% with site_root=page.get_site.root_page %}
|
||||||
{# Top-level menu: direct children of site root #}
|
{# Top-level menu: direct children of site root #}
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
@ -100,6 +102,69 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% 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 %}
|
||||||
|
</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 %}
|
||||||
|
<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 #}
|
{# Optional extra links from settings #}
|
||||||
{% if settings.base.HeaderSettings.main_links %}
|
{% 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 search import views as search_views
|
||||||
from home import views as home_views
|
from home import views as home_views
|
||||||
|
from base import views as base_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("django-admin/", admin.site.urls),
|
path("django-admin/", admin.site.urls),
|
||||||
@ -16,6 +17,13 @@ urlpatterns = [
|
|||||||
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
||||||
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
||||||
path("search/", search_views.search, name="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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,3 +5,4 @@ dj-database-url
|
|||||||
psycopg[binary]
|
psycopg[binary]
|
||||||
python-dotenv
|
python-dotenv
|
||||||
django-storages[boto3]
|
django-storages[boto3]
|
||||||
|
certifi
|
||||||
|
|||||||
@ -1,33 +1,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load wagtailcore_tags %}
|
{% load wagtailcore_tags static %}
|
||||||
|
|
||||||
{% block body_class %}template-searchresults{% endblock %}
|
{% block body_class %}template-searchresults{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
|
{% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 %}
|
{% if search_query %}
|
||||||
<li><span>{{ search_query }}</span></li>
|
<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 %}
|
{% endif %}
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section class="search-results">
|
<section class="search-results">
|
||||||
{% if search_query %}
|
{% if search_query %}
|
||||||
{% if results_count %}
|
{% 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 %}
|
{% else %}
|
||||||
<p>找不到與「{{ search_query }}」相關的文章。</p>
|
<p>找不到與「{{ search_query }}」相關的文章。</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user