Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7113e5b1f7 |
@ -1,40 +0,0 @@
|
|||||||
# Contact form processing
|
|
||||||
|
|
||||||
Contact form submissions are stored first and processed asynchronously by:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py run_contact_submission_worker
|
|
||||||
```
|
|
||||||
|
|
||||||
The `scheduler` application role runs this worker and the newsletter scheduler in independent loops. A database lease allows only one spam-detection request at a time across scheduler replicas, while newsletter and SMTP work continue independently.
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
Required in production:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CONTACT_SPAM_DETECTION_ENABLED=true
|
|
||||||
CONTACT_SPAM_DETECTION_URL=https://spamd.innovedus.com/classify
|
|
||||||
CONTACT_SPAM_DETECTION_API_KEY=replace-me
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional tuning:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS=180
|
|
||||||
CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS=300
|
|
||||||
CONTACT_SPAM_DETECTION_MAX_ATTEMPTS=3
|
|
||||||
CONTACT_FORM_WORKER_BATCH_SIZE=20
|
|
||||||
CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS=600
|
|
||||||
CONTACT_FORM_WORKER_LEASE_SECONDS=600
|
|
||||||
CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS=300
|
|
||||||
CONTACT_FORM_MAIL_MAX_ATTEMPTS=3
|
|
||||||
CONTACT_SCHEDULER_INTERVAL_SECONDS=60
|
|
||||||
NEWSLETTER_SCHEDULER_INTERVAL_SECONDS=60
|
|
||||||
```
|
|
||||||
|
|
||||||
When spam detection is disabled or its URL/key is missing, queued submissions stay in the database and no contact email is sent. A service error opens a database-backed cooldown until the configured retry time, so later scheduler runs do not repeatedly call an AI box that is restarting.
|
|
||||||
|
|
||||||
Rate limit count/window and the submitter-copy switch are managed in Wagtail under Contact Form Settings. The default is three submissions per ten minutes per IP, and submitter copies are disabled by default.
|
|
||||||
|
|
||||||
The application must only be publicly reachable through an Application Load Balancer whose `routing.http.xff_header_processing.mode` is `append` (the AWS default). The client IP is read from the rightmost valid `X-Forwarded-For` entry, with `REMOTE_ADDR` used only when that header is absent.
|
|
||||||
@ -4,10 +4,9 @@ from .models import ContactFormSubmission
|
|||||||
|
|
||||||
@admin.register(ContactFormSubmission)
|
@admin.register(ContactFormSubmission)
|
||||||
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ("created_at", "processing_status", "spam_category", "category", "name", "email", "ip_address")
|
list_display = ("created_at", "category", "name", "email", "contact")
|
||||||
list_filter = ("processing_status", "spam_category", "category", "created_at")
|
list_filter = ("category", "created_at")
|
||||||
search_fields = ("name", "email", "contact", "message", "source_page", "ip_address")
|
search_fields = ("name", "email", "contact", "message", "source_page")
|
||||||
actions = ("requeue_submissions",)
|
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"name",
|
"name",
|
||||||
"email",
|
"email",
|
||||||
@ -17,39 +16,5 @@ class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
|||||||
"source_page",
|
"source_page",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"processing_status",
|
|
||||||
"spam_category",
|
|
||||||
"spam_source",
|
|
||||||
"spam_checked_at",
|
|
||||||
"scan_attempt_count",
|
|
||||||
"send_attempt_count",
|
|
||||||
"next_attempt_at",
|
|
||||||
"processing_started_at",
|
|
||||||
"notification_sent_at",
|
|
||||||
"user_copy_sent_at",
|
|
||||||
"last_error",
|
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.action(description="Requeue selected contact submissions")
|
|
||||||
def requeue_submissions(self, request, queryset):
|
|
||||||
eligible = queryset.filter(
|
|
||||||
processing_status__in=[
|
|
||||||
ContactFormSubmission.STATUS_BLOCKED,
|
|
||||||
ContactFormSubmission.STATUS_FAILED,
|
|
||||||
ContactFormSubmission.STATUS_RATE_LIMITED,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
updated = eligible.update(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_QUEUED,
|
|
||||||
spam_category=None,
|
|
||||||
spam_source="",
|
|
||||||
spam_checked_at=None,
|
|
||||||
scan_attempt_count=0,
|
|
||||||
send_attempt_count=0,
|
|
||||||
next_attempt_at=None,
|
|
||||||
processing_started_at=None,
|
|
||||||
last_error="",
|
|
||||||
)
|
|
||||||
self.message_user(request, f"Requeued {updated} contact submission(s).")
|
|
||||||
|
|||||||
@ -1,437 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import transaction
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.html import escape
|
|
||||||
|
|
||||||
from .models import (
|
|
||||||
ContactFormSettings,
|
|
||||||
ContactFormSubmission,
|
|
||||||
ContactSpamDetectionState,
|
|
||||||
MailSmtpSettings,
|
|
||||||
SystemNotificationMailSettings,
|
|
||||||
)
|
|
||||||
from .newsletter import send_contact_notification_email, send_contact_user_email
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SpamDetectionError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SpamDetectionResult:
|
|
||||||
category: int
|
|
||||||
source: str
|
|
||||||
|
|
||||||
|
|
||||||
class SpamDetectionClient:
|
|
||||||
def __init__(self):
|
|
||||||
self.url = settings.CONTACT_SPAM_DETECTION_URL
|
|
||||||
self.api_key = settings.CONTACT_SPAM_DETECTION_API_KEY
|
|
||||||
self.timeout_seconds = max(1, int(settings.CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS))
|
|
||||||
|
|
||||||
def validate_config(self):
|
|
||||||
if not self.url:
|
|
||||||
raise SpamDetectionError("CONTACT_SPAM_DETECTION_URL is not configured")
|
|
||||||
if not self.api_key:
|
|
||||||
raise SpamDetectionError("CONTACT_SPAM_DETECTION_API_KEY is not configured")
|
|
||||||
|
|
||||||
def classify(self, message: str) -> SpamDetectionResult:
|
|
||||||
self.validate_config()
|
|
||||||
request = Request(
|
|
||||||
self.url,
|
|
||||||
data=json.dumps({"message": message}).encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urlopen(request, timeout=self.timeout_seconds) as response:
|
|
||||||
response_data = json.loads(response.read().decode("utf-8"))
|
|
||||||
except HTTPError as exc:
|
|
||||||
try:
|
|
||||||
detail = exc.read().decode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
detail = ""
|
|
||||||
raise SpamDetectionError(f"spam detection HTTP {exc.code}: {detail[:500]}") from exc
|
|
||||||
except (URLError, TimeoutError, OSError) as exc:
|
|
||||||
raise SpamDetectionError(f"spam detection request failed: {exc}") from exc
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise SpamDetectionError("spam detection returned invalid JSON") from exc
|
|
||||||
|
|
||||||
if not isinstance(response_data, dict):
|
|
||||||
raise SpamDetectionError("spam detection response must be an object")
|
|
||||||
if response_data.get("error"):
|
|
||||||
raise SpamDetectionError(f"spam detection service error: {response_data['error']}")
|
|
||||||
|
|
||||||
category = response_data.get("category")
|
|
||||||
if type(category) is not int or category not in {0, 1, 2, 3}:
|
|
||||||
raise SpamDetectionError(f"spam detection returned invalid category: {category!r}")
|
|
||||||
return SpamDetectionResult(
|
|
||||||
category=category,
|
|
||||||
source=str(response_data.get("source") or "")[:32],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _claim_mail_submission(now):
|
|
||||||
stale_before = now - timedelta(seconds=max(1, int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS)))
|
|
||||||
eligible = Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SEND_RETRY,
|
|
||||||
next_attempt_at__lte=now,
|
|
||||||
) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SEND_RETRY,
|
|
||||||
next_attempt_at__isnull=True,
|
|
||||||
) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SENDING,
|
|
||||||
processing_started_at__lte=stale_before,
|
|
||||||
) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SENDING,
|
|
||||||
processing_started_at__isnull=True,
|
|
||||||
)
|
|
||||||
with transaction.atomic():
|
|
||||||
submission = (
|
|
||||||
ContactFormSubmission.objects.select_for_update(skip_locked=True)
|
|
||||||
.filter(eligible)
|
|
||||||
.order_by("created_at", "id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if submission is None:
|
|
||||||
return None
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SENDING
|
|
||||||
submission.processing_started_at = now
|
|
||||||
submission.next_attempt_at = None
|
|
||||||
submission.save(update_fields=["processing_status", "processing_started_at", "next_attempt_at", "updated_at"])
|
|
||||||
return submission
|
|
||||||
|
|
||||||
|
|
||||||
def _claim_scan_submission(now):
|
|
||||||
stale_before = now - timedelta(seconds=max(1, int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS)))
|
|
||||||
eligible = Q(processing_status=ContactFormSubmission.STATUS_QUEUED) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SCAN_RETRY,
|
|
||||||
next_attempt_at__lte=now,
|
|
||||||
) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SCAN_RETRY,
|
|
||||||
next_attempt_at__isnull=True,
|
|
||||||
) | Q(
|
|
||||||
processing_status=ContactFormSubmission.STATUS_SCANNING,
|
|
||||||
processing_started_at__lte=stale_before,
|
|
||||||
)
|
|
||||||
with transaction.atomic():
|
|
||||||
submission = (
|
|
||||||
ContactFormSubmission.objects.select_for_update(skip_locked=True)
|
|
||||||
.filter(eligible)
|
|
||||||
.order_by("created_at", "id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if submission is None:
|
|
||||||
return None
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SCANNING
|
|
||||||
submission.processing_started_at = now
|
|
||||||
submission.next_attempt_at = None
|
|
||||||
submission.scan_attempt_count += 1
|
|
||||||
submission.save(
|
|
||||||
update_fields=[
|
|
||||||
"processing_status",
|
|
||||||
"processing_started_at",
|
|
||||||
"next_attempt_at",
|
|
||||||
"scan_attempt_count",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return submission
|
|
||||||
|
|
||||||
|
|
||||||
def _acquire_detection_lease(now):
|
|
||||||
with transaction.atomic():
|
|
||||||
state, _ = ContactSpamDetectionState.objects.get_or_create(pk=1)
|
|
||||||
state = ContactSpamDetectionState.objects.select_for_update().get(pk=state.pk)
|
|
||||||
if state.cooldown_until and state.cooldown_until > now:
|
|
||||||
return "", state.cooldown_until, False
|
|
||||||
if state.locked_until and state.locked_until > now:
|
|
||||||
return "", None, True
|
|
||||||
|
|
||||||
lease_seconds = max(
|
|
||||||
int(settings.CONTACT_FORM_WORKER_LEASE_SECONDS),
|
|
||||||
int(settings.CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS) + 60,
|
|
||||||
)
|
|
||||||
token = uuid.uuid4().hex
|
|
||||||
state.lock_token = token
|
|
||||||
state.locked_until = now + timedelta(seconds=max(1, lease_seconds))
|
|
||||||
state.cooldown_until = None
|
|
||||||
state.save(update_fields=["lock_token", "locked_until", "cooldown_until", "updated_at"])
|
|
||||||
return token, None, False
|
|
||||||
|
|
||||||
|
|
||||||
def _release_detection_lease(token: str, *, cooldown_until=None, error=""):
|
|
||||||
ContactSpamDetectionState.objects.filter(pk=1, lock_token=token).update(
|
|
||||||
lock_token="",
|
|
||||||
locked_until=None,
|
|
||||||
cooldown_until=cooldown_until,
|
|
||||||
last_error=error[:4000],
|
|
||||||
updated_at=timezone.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_scan_failure(submission: ContactFormSubmission, error: str):
|
|
||||||
now = timezone.now()
|
|
||||||
retry_at = now + timedelta(seconds=max(1, int(settings.CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS)))
|
|
||||||
if submission.scan_attempt_count >= max(1, int(settings.CONTACT_SPAM_DETECTION_MAX_ATTEMPTS)):
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_FAILED
|
|
||||||
else:
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SCAN_RETRY
|
|
||||||
submission.processing_started_at = None
|
|
||||||
submission.next_attempt_at = retry_at
|
|
||||||
submission.last_error = error[:4000]
|
|
||||||
submission.save(
|
|
||||||
update_fields=[
|
|
||||||
"processing_status",
|
|
||||||
"processing_started_at",
|
|
||||||
"next_attempt_at",
|
|
||||||
"last_error",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return retry_at
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_submission(submission: ContactFormSubmission, client: SpamDetectionClient):
|
|
||||||
result = client.classify(submission.message)
|
|
||||||
now = timezone.now()
|
|
||||||
submission.spam_category = result.category
|
|
||||||
submission.spam_source = result.source
|
|
||||||
submission.spam_checked_at = now
|
|
||||||
submission.next_attempt_at = None
|
|
||||||
submission.last_error = ""
|
|
||||||
if result.category == 0:
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SENDING
|
|
||||||
submission.processing_started_at = now
|
|
||||||
else:
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_BLOCKED
|
|
||||||
submission.processing_started_at = None
|
|
||||||
submission.save(
|
|
||||||
update_fields=[
|
|
||||||
"spam_category",
|
|
||||||
"spam_source",
|
|
||||||
"spam_checked_at",
|
|
||||||
"processing_status",
|
|
||||||
"processing_started_at",
|
|
||||||
"next_attempt_at",
|
|
||||||
"last_error",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _notification_content(submission: ContactFormSubmission, notification_settings):
|
|
||||||
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"
|
|
||||||
f"IP Address: {submission.ip_address or ''}\n\n"
|
|
||||||
f"Message:\n{submission.message}\n"
|
|
||||||
)
|
|
||||||
html_body = (
|
|
||||||
f"<p><strong>Name:</strong> {escape(submission.name)}</p>"
|
|
||||||
f"<p><strong>Email:</strong> {escape(submission.email)}</p>"
|
|
||||||
f"<p><strong>Contact:</strong> {escape(submission.contact)}</p>"
|
|
||||||
f"<p><strong>Category:</strong> {escape(submission.get_category_display())}</p>"
|
|
||||||
f"<p><strong>Source Page:</strong> {escape(submission.source_page or '')}</p>"
|
|
||||||
f"<p><strong>IP Address:</strong> {escape(str(submission.ip_address or ''))}</p>"
|
|
||||||
f"<p><strong>Message:</strong></p><p>{escaped_message_html}</p>"
|
|
||||||
)
|
|
||||||
return subject, text_body, html_body
|
|
||||||
|
|
||||||
|
|
||||||
def _user_copy_content(submission: ContactFormSubmission, notification_settings):
|
|
||||||
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 ""),
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
_render_contact_template(notification_settings.contact_form_user_subject_template, values_text),
|
|
||||||
_render_contact_template(notification_settings.contact_form_user_text_template, values_text),
|
|
||||||
_render_contact_template(notification_settings.contact_form_user_html_template, values_html),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _send_submission(submission: ContactFormSubmission):
|
|
||||||
now = timezone.now()
|
|
||||||
submission.send_attempt_count += 1
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SENDING
|
|
||||||
submission.processing_started_at = now
|
|
||||||
submission.save(
|
|
||||||
update_fields=["send_attempt_count", "processing_status", "processing_started_at", "updated_at"]
|
|
||||||
)
|
|
||||||
|
|
||||||
notification_settings = SystemNotificationMailSettings.load()
|
|
||||||
smtp_settings = MailSmtpSettings.load()
|
|
||||||
contact_settings = ContactFormSettings.load()
|
|
||||||
try:
|
|
||||||
if submission.notification_sent_at is None:
|
|
||||||
subject, text_body, html_body = _notification_content(submission, notification_settings)
|
|
||||||
send_contact_notification_email(
|
|
||||||
subject=subject,
|
|
||||||
text_body=text_body,
|
|
||||||
html_body=html_body,
|
|
||||||
notification_config=notification_settings,
|
|
||||||
smtp_config=smtp_settings,
|
|
||||||
)
|
|
||||||
submission.notification_sent_at = timezone.now()
|
|
||||||
submission.save(update_fields=["notification_sent_at", "updated_at"])
|
|
||||||
|
|
||||||
if contact_settings.send_user_copy and submission.email and submission.user_copy_sent_at is None:
|
|
||||||
user_subject, user_text, user_html = _user_copy_content(submission, notification_settings)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
submission.user_copy_sent_at = timezone.now()
|
|
||||||
submission.save(update_fields=["user_copy_sent_at", "updated_at"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("contact submission email failed submission_id=%s: %s", submission.pk, exc)
|
|
||||||
submission.last_error = str(exc)[:4000]
|
|
||||||
submission.processing_started_at = None
|
|
||||||
delay = max(1, int(settings.CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS))
|
|
||||||
submission.next_attempt_at = timezone.now() + timedelta(seconds=delay)
|
|
||||||
if submission.send_attempt_count >= max(1, int(settings.CONTACT_FORM_MAIL_MAX_ATTEMPTS)):
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_FAILED
|
|
||||||
else:
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SEND_RETRY
|
|
||||||
submission.save(
|
|
||||||
update_fields=[
|
|
||||||
"processing_status",
|
|
||||||
"processing_started_at",
|
|
||||||
"next_attempt_at",
|
|
||||||
"last_error",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_SENT
|
|
||||||
submission.processing_started_at = None
|
|
||||||
submission.next_attempt_at = None
|
|
||||||
submission.last_error = ""
|
|
||||||
submission.save(
|
|
||||||
update_fields=[
|
|
||||||
"processing_status",
|
|
||||||
"processing_started_at",
|
|
||||||
"next_attempt_at",
|
|
||||||
"last_error",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def process_contact_submissions(*, limit=None, max_runtime_seconds=None) -> dict:
|
|
||||||
limit = max(1, int(limit or settings.CONTACT_FORM_WORKER_BATCH_SIZE))
|
|
||||||
max_runtime_seconds = max(
|
|
||||||
1,
|
|
||||||
int(max_runtime_seconds or settings.CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS),
|
|
||||||
)
|
|
||||||
result = {
|
|
||||||
"processed": 0,
|
|
||||||
"allowed": 0,
|
|
||||||
"blocked": 0,
|
|
||||||
"sent": 0,
|
|
||||||
"failed": 0,
|
|
||||||
"cooldown_until": None,
|
|
||||||
"detector_busy": False,
|
|
||||||
"disabled": not settings.CONTACT_SPAM_DETECTION_ENABLED,
|
|
||||||
}
|
|
||||||
started_at = time.monotonic()
|
|
||||||
client = SpamDetectionClient()
|
|
||||||
|
|
||||||
while result["processed"] < limit and time.monotonic() - started_at < max_runtime_seconds:
|
|
||||||
now = timezone.now()
|
|
||||||
submission = _claim_mail_submission(now)
|
|
||||||
if submission is not None:
|
|
||||||
result["processed"] += 1
|
|
||||||
if _send_submission(submission):
|
|
||||||
result["sent"] += 1
|
|
||||||
else:
|
|
||||||
result["failed"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not settings.CONTACT_SPAM_DETECTION_ENABLED:
|
|
||||||
break
|
|
||||||
|
|
||||||
lease_token, cooldown_until, detector_busy = _acquire_detection_lease(now)
|
|
||||||
if not lease_token:
|
|
||||||
result["cooldown_until"] = cooldown_until
|
|
||||||
result["detector_busy"] = detector_busy
|
|
||||||
break
|
|
||||||
|
|
||||||
submission = _claim_scan_submission(now)
|
|
||||||
if submission is None:
|
|
||||||
_release_detection_lease(lease_token)
|
|
||||||
break
|
|
||||||
|
|
||||||
result["processed"] += 1
|
|
||||||
try:
|
|
||||||
scan_result = _scan_submission(submission, client)
|
|
||||||
except SpamDetectionError as exc:
|
|
||||||
logger.warning("contact spam detection failed submission_id=%s: %s", submission.pk, exc)
|
|
||||||
retry_at = _record_scan_failure(submission, str(exc))
|
|
||||||
_release_detection_lease(lease_token, cooldown_until=retry_at, error=str(exc))
|
|
||||||
result["failed"] += 1
|
|
||||||
result["cooldown_until"] = retry_at
|
|
||||||
break
|
|
||||||
|
|
||||||
_release_detection_lease(lease_token)
|
|
||||||
|
|
||||||
if scan_result.category != 0:
|
|
||||||
result["blocked"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result["allowed"] += 1
|
|
||||||
if _send_submission(submission):
|
|
||||||
result["sent"] += 1
|
|
||||||
else:
|
|
||||||
result["failed"] += 1
|
|
||||||
|
|
||||||
return result
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from base.contact_submission_processor import process_contact_submissions
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Scan and send queued contact form submissions"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("--limit", type=int, default=settings.CONTACT_FORM_WORKER_BATCH_SIZE)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-runtime-seconds",
|
|
||||||
type=int,
|
|
||||||
default=settings.CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
result = process_contact_submissions(
|
|
||||||
limit=options["limit"],
|
|
||||||
max_runtime_seconds=options["max_runtime_seconds"],
|
|
||||||
)
|
|
||||||
cooldown_until = result["cooldown_until"].isoformat() if result["cooldown_until"] else ""
|
|
||||||
self.stdout.write(
|
|
||||||
"processed={processed} allowed={allowed} blocked={blocked} sent={sent} "
|
|
||||||
"failed={failed} disabled={disabled} detector_busy={detector_busy} "
|
|
||||||
"cooldown_until={cooldown}".format(
|
|
||||||
**result,
|
|
||||||
cooldown=cooldown_until,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
# Generated by Django 5.2.15 on 2026-08-05 05:22
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
def mark_existing_submissions_as_legacy(apps, schema_editor):
|
|
||||||
ContactFormSubmission = apps.get_model("base", "ContactFormSubmission")
|
|
||||||
ContactFormSubmission.objects.update(processing_status="legacy")
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('base', '0013_send_engine_oauth_client_credentials'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ContactFormRateLimitBucket',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('ip_address', models.GenericIPAddressField(unique=True, verbose_name='IP Address')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Contact Form Rate Limit Bucket',
|
|
||||||
'verbose_name_plural': 'Contact Form Rate Limit Buckets',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ContactFormSettings',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('rate_limit_enabled', models.BooleanField(default=True, verbose_name='Enable IP Rate Limit')),
|
|
||||||
('rate_limit_max_submissions', models.PositiveIntegerField(default=3, verbose_name='Maximum Submissions Per Window')),
|
|
||||||
('rate_limit_window_minutes', models.PositiveIntegerField(default=10, verbose_name='Rate Limit Window Minutes')),
|
|
||||||
('send_user_copy', models.BooleanField(default=False, help_text='Send a copy to the submitter only after spam detection allows the message.', verbose_name='Send User Copy')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Contact Form Settings',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ContactSpamDetectionState',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('lock_token', models.CharField(blank=True, max_length=64, verbose_name='Lock Token')),
|
|
||||||
('locked_until', models.DateTimeField(blank=True, null=True, verbose_name='Locked Until')),
|
|
||||||
('cooldown_until', models.DateTimeField(blank=True, null=True, verbose_name='Cooldown Until')),
|
|
||||||
('last_error', models.TextField(blank=True, verbose_name='Last Error')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Contact Spam Detection State',
|
|
||||||
'verbose_name_plural': 'Contact Spam Detection State',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='last_error',
|
|
||||||
field=models.TextField(blank=True, verbose_name='Last Error'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='next_attempt_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Next Attempt At'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='notification_sent_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Notification Sent At'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='processing_started_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Processing Started At'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='processing_status',
|
|
||||||
field=models.CharField(choices=[('queued', 'Queued'), ('scanning', 'Scanning'), ('scan_retry', 'Scan Retry'), ('blocked', 'Blocked as Spam'), ('sending', 'Sending'), ('send_retry', 'Send Retry'), ('sent', 'Sent'), ('failed', 'Failed'), ('rate_limited', 'Rate Limited'), ('legacy', 'Legacy Submission')], db_index=True, default='queued', max_length=20, verbose_name='Processing Status'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='scan_attempt_count',
|
|
||||||
field=models.PositiveIntegerField(default=0, verbose_name='Scan Attempt Count'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='send_attempt_count',
|
|
||||||
field=models.PositiveIntegerField(default=0, verbose_name='Send Attempt Count'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='spam_category',
|
|
||||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'Allowed'), (1, 'Unsolicited Advertising'), (2, 'Malicious'), (3, 'Meaningless')], null=True, verbose_name='Spam Category'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='spam_checked_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Spam Checked At'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='spam_source',
|
|
||||||
field=models.CharField(blank=True, max_length=32, verbose_name='Spam Detection Source'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated At'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
name='user_copy_sent_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True, verbose_name='User Copy Sent At'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
index=models.Index(fields=['ip_address', 'created_at'], name='contact_ip_created_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='contactformsubmission',
|
|
||||||
index=models.Index(fields=['processing_status', 'next_attempt_at', 'created_at'], name='contact_process_due_idx'),
|
|
||||||
),
|
|
||||||
migrations.RunPython(mark_existing_submissions_as_legacy, migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
@ -171,39 +171,6 @@ class MailSmtpSettings(BaseGenericSetting):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
|
||||||
class ContactFormSettings(BaseGenericSetting):
|
|
||||||
rate_limit_enabled = models.BooleanField(default=True, verbose_name=_("Enable IP Rate Limit"))
|
|
||||||
rate_limit_max_submissions = models.PositiveIntegerField(
|
|
||||||
default=3,
|
|
||||||
verbose_name=_("Maximum Submissions Per Window"),
|
|
||||||
)
|
|
||||||
rate_limit_window_minutes = models.PositiveIntegerField(
|
|
||||||
default=10,
|
|
||||||
verbose_name=_("Rate Limit Window Minutes"),
|
|
||||||
)
|
|
||||||
send_user_copy = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_("Send a copy to the submitter only after spam detection allows the message."),
|
|
||||||
verbose_name=_("Send User Copy"),
|
|
||||||
)
|
|
||||||
|
|
||||||
panels = [
|
|
||||||
MultiFieldPanel(
|
|
||||||
[
|
|
||||||
FieldPanel("rate_limit_enabled"),
|
|
||||||
FieldPanel("rate_limit_max_submissions"),
|
|
||||||
FieldPanel("rate_limit_window_minutes"),
|
|
||||||
],
|
|
||||||
heading=_("Contact Form Rate Limit"),
|
|
||||||
),
|
|
||||||
FieldPanel("send_user_copy"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Contact Form Settings")
|
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class NewsletterSystemSettings(BaseGenericSetting):
|
class NewsletterSystemSettings(BaseGenericSetting):
|
||||||
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
|
member_center_base_url = models.URLField(blank=True, verbose_name=_("Member Center Base URL"))
|
||||||
@ -837,36 +804,6 @@ class ContactFormSubmission(models.Model):
|
|||||||
(CATEGORY_OTHER, _("Other")),
|
(CATEGORY_OTHER, _("Other")),
|
||||||
]
|
]
|
||||||
|
|
||||||
STATUS_QUEUED = "queued"
|
|
||||||
STATUS_SCANNING = "scanning"
|
|
||||||
STATUS_SCAN_RETRY = "scan_retry"
|
|
||||||
STATUS_BLOCKED = "blocked"
|
|
||||||
STATUS_SENDING = "sending"
|
|
||||||
STATUS_SEND_RETRY = "send_retry"
|
|
||||||
STATUS_SENT = "sent"
|
|
||||||
STATUS_FAILED = "failed"
|
|
||||||
STATUS_RATE_LIMITED = "rate_limited"
|
|
||||||
STATUS_LEGACY = "legacy"
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
(STATUS_QUEUED, _("Queued")),
|
|
||||||
(STATUS_SCANNING, _("Scanning")),
|
|
||||||
(STATUS_SCAN_RETRY, _("Scan Retry")),
|
|
||||||
(STATUS_BLOCKED, _("Blocked as Spam")),
|
|
||||||
(STATUS_SENDING, _("Sending")),
|
|
||||||
(STATUS_SEND_RETRY, _("Send Retry")),
|
|
||||||
(STATUS_SENT, _("Sent")),
|
|
||||||
(STATUS_FAILED, _("Failed")),
|
|
||||||
(STATUS_RATE_LIMITED, _("Rate Limited")),
|
|
||||||
(STATUS_LEGACY, _("Legacy Submission")),
|
|
||||||
]
|
|
||||||
|
|
||||||
SPAM_CATEGORY_CHOICES = [
|
|
||||||
(0, _("Allowed")),
|
|
||||||
(1, _("Unsolicited Advertising")),
|
|
||||||
(2, _("Malicious")),
|
|
||||||
(3, _("Meaningless")),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
email = models.EmailField(blank=True, verbose_name=_("Email"))
|
||||||
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
|
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
|
||||||
@ -875,63 +812,12 @@ class ContactFormSubmission(models.Model):
|
|||||||
source_page = models.CharField(max_length=512, blank=True, verbose_name=_("Source Page"))
|
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"))
|
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
|
||||||
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
|
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
|
||||||
processing_status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=STATUS_CHOICES,
|
|
||||||
default=STATUS_QUEUED,
|
|
||||||
db_index=True,
|
|
||||||
verbose_name=_("Processing Status"),
|
|
||||||
)
|
|
||||||
spam_category = models.PositiveSmallIntegerField(
|
|
||||||
choices=SPAM_CATEGORY_CHOICES,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("Spam Category"),
|
|
||||||
)
|
|
||||||
spam_source = models.CharField(max_length=32, blank=True, verbose_name=_("Spam Detection Source"))
|
|
||||||
spam_checked_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Spam Checked At"))
|
|
||||||
scan_attempt_count = models.PositiveIntegerField(default=0, verbose_name=_("Scan Attempt Count"))
|
|
||||||
send_attempt_count = models.PositiveIntegerField(default=0, verbose_name=_("Send Attempt Count"))
|
|
||||||
next_attempt_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Next Attempt At"))
|
|
||||||
processing_started_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Processing Started At"))
|
|
||||||
notification_sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Notification Sent At"))
|
|
||||||
user_copy_sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("User Copy Sent At"))
|
|
||||||
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["ip_address", "created_at"], name="contact_ip_created_idx"),
|
|
||||||
models.Index(
|
|
||||||
fields=["processing_status", "next_attempt_at", "created_at"],
|
|
||||||
name="contact_process_due_idx",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
verbose_name = _("Contact Form Submission")
|
verbose_name = _("Contact Form Submission")
|
||||||
verbose_name_plural = _("Contact Form Submissions")
|
verbose_name_plural = _("Contact Form Submissions")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_category_display()} - {self.name}"
|
return f"{self.get_category_display()} - {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class ContactFormRateLimitBucket(models.Model):
|
|
||||||
ip_address = models.GenericIPAddressField(unique=True, verbose_name=_("IP Address"))
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Contact Form Rate Limit Bucket")
|
|
||||||
verbose_name_plural = _("Contact Form Rate Limit Buckets")
|
|
||||||
|
|
||||||
|
|
||||||
class ContactSpamDetectionState(models.Model):
|
|
||||||
lock_token = models.CharField(max_length=64, blank=True, verbose_name=_("Lock Token"))
|
|
||||||
locked_until = models.DateTimeField(null=True, blank=True, verbose_name=_("Locked Until"))
|
|
||||||
cooldown_until = models.DateTimeField(null=True, blank=True, verbose_name=_("Cooldown Until"))
|
|
||||||
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Contact Spam Detection State")
|
|
||||||
verbose_name_plural = _("Contact Spam Detection State")
|
|
||||||
|
|||||||
@ -1,19 +1,11 @@
|
|||||||
import json
|
from datetime import date
|
||||||
from datetime import date, timedelta
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from .contact_submission_processor import (
|
|
||||||
SpamDetectionClient,
|
|
||||||
SpamDetectionError,
|
|
||||||
SpamDetectionResult,
|
|
||||||
process_contact_submissions,
|
|
||||||
)
|
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
APIResult,
|
APIResult,
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
@ -25,12 +17,7 @@ from .newsletter import (
|
|||||||
render_newsletter_html,
|
render_newsletter_html,
|
||||||
verify_one_click_token,
|
verify_one_click_token,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import ContactFormSubmission, SystemNotificationMailSettings
|
||||||
ContactFormSettings,
|
|
||||||
ContactFormSubmission,
|
|
||||||
ContactSpamDetectionState,
|
|
||||||
SystemNotificationMailSettings,
|
|
||||||
)
|
|
||||||
from .security import decrypt_text, encrypt_text
|
from .security import decrypt_text, encrypt_text
|
||||||
|
|
||||||
|
|
||||||
@ -278,7 +265,6 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
self.assertEqual(submission.name, "Tester")
|
self.assertEqual(submission.name, "Tester")
|
||||||
self.assertEqual(submission.email, "tester@example.com")
|
self.assertEqual(submission.email, "tester@example.com")
|
||||||
self.assertEqual(submission.category, "other")
|
self.assertEqual(submission.category, "other")
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_QUEUED)
|
|
||||||
|
|
||||||
def test_contact_form_submit_rejects_invalid_email(self):
|
def test_contact_form_submit_rejects_invalid_email(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -295,182 +281,3 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(ContactFormSubmission.objects.count(), 0)
|
self.assertEqual(ContactFormSubmission.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class ContactFormSubmissionFlowTests(TestCase):
|
|
||||||
def _post(self, *, remote_addr="10.0.1.10", forwarded_for="198.51.100.20"):
|
|
||||||
return self.client.post(
|
|
||||||
reverse("contact_form_submit"),
|
|
||||||
data={
|
|
||||||
"name": "Tester",
|
|
||||||
"contact": "tester@example.com",
|
|
||||||
"email": "tester@example.com",
|
|
||||||
"category": "other",
|
|
||||||
"message": "hello",
|
|
||||||
"source_page": "/contact/",
|
|
||||||
},
|
|
||||||
REMOTE_ADDR=remote_addr,
|
|
||||||
HTTP_X_FORWARDED_FOR=forwarded_for,
|
|
||||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_records_rightmost_ip_appended_by_alb(self):
|
|
||||||
response = self._post(forwarded_for="203.0.113.99, 198.51.100.20")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(ContactFormSubmission.objects.get().ip_address, "198.51.100.20")
|
|
||||||
|
|
||||||
def test_falls_back_to_remote_address_without_forwarded_header(self):
|
|
||||||
response = self._post(remote_addr="10.0.1.10", forwarded_for="")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(ContactFormSubmission.objects.get().ip_address, "10.0.1.10")
|
|
||||||
|
|
||||||
def test_fourth_submission_in_default_window_is_rate_limited(self):
|
|
||||||
for _ in range(4):
|
|
||||||
response = self._post()
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
statuses = list(ContactFormSubmission.objects.order_by("created_at", "id").values_list("processing_status", flat=True))
|
|
||||||
self.assertEqual(
|
|
||||||
statuses,
|
|
||||||
[
|
|
||||||
ContactFormSubmission.STATUS_QUEUED,
|
|
||||||
ContactFormSubmission.STATUS_QUEUED,
|
|
||||||
ContactFormSubmission.STATUS_QUEUED,
|
|
||||||
ContactFormSubmission.STATUS_RATE_LIMITED,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
CONTACT_SPAM_DETECTION_ENABLED=True,
|
|
||||||
CONTACT_SPAM_DETECTION_URL="https://spam.example.com/classify",
|
|
||||||
CONTACT_SPAM_DETECTION_API_KEY="test-key",
|
|
||||||
CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS=300,
|
|
||||||
CONTACT_SPAM_DETECTION_MAX_ATTEMPTS=3,
|
|
||||||
CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS=300,
|
|
||||||
CONTACT_FORM_MAIL_MAX_ATTEMPTS=3,
|
|
||||||
)
|
|
||||||
class ContactSubmissionProcessorTests(TestCase):
|
|
||||||
def _submission(self, **overrides):
|
|
||||||
values = {
|
|
||||||
"name": "Tester",
|
|
||||||
"contact": "tester@example.com",
|
|
||||||
"email": "tester@example.com",
|
|
||||||
"category": ContactFormSubmission.CATEGORY_OTHER,
|
|
||||||
"message": "hello",
|
|
||||||
"source_page": "/contact/",
|
|
||||||
"ip_address": "198.51.100.20",
|
|
||||||
}
|
|
||||||
values.update(overrides)
|
|
||||||
return ContactFormSubmission.objects.create(**values)
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.send_contact_user_email")
|
|
||||||
@patch("base.contact_submission_processor.send_contact_notification_email")
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_allowed_submission_is_sent_after_scan(self, classify, send_notification, send_user):
|
|
||||||
submission = self._submission()
|
|
||||||
classify.return_value = SpamDetectionResult(category=0, source="kneo300")
|
|
||||||
|
|
||||||
result = process_contact_submissions(limit=1)
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
|
||||||
self.assertEqual(result["sent"], 1)
|
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
|
|
||||||
self.assertEqual(submission.spam_category, 0)
|
|
||||||
self.assertIsNotNone(submission.notification_sent_at)
|
|
||||||
send_notification.assert_called_once()
|
|
||||||
send_user.assert_not_called()
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.send_contact_notification_email")
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_spam_submission_is_recorded_and_not_sent(self, classify, send_notification):
|
|
||||||
submission = self._submission()
|
|
||||||
classify.return_value = SpamDetectionResult(category=2, source="kneo300")
|
|
||||||
|
|
||||||
result = process_contact_submissions(limit=1)
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
|
||||||
self.assertEqual(result["blocked"], 1)
|
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_BLOCKED)
|
|
||||||
self.assertEqual(submission.spam_category, 2)
|
|
||||||
send_notification.assert_not_called()
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.send_contact_notification_email")
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_detection_error_schedules_cooldown_and_does_not_send(self, classify, send_notification):
|
|
||||||
submission = self._submission()
|
|
||||||
classify.side_effect = SpamDetectionError("AI box unavailable")
|
|
||||||
|
|
||||||
result = process_contact_submissions(limit=1)
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
|
||||||
self.assertEqual(result["failed"], 1)
|
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SCAN_RETRY)
|
|
||||||
self.assertGreater(submission.next_attempt_at, timezone.now())
|
|
||||||
send_notification.assert_not_called()
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_active_detection_cooldown_prevents_calls_for_other_rows(self, classify):
|
|
||||||
ContactSpamDetectionState.objects.create(
|
|
||||||
pk=1,
|
|
||||||
cooldown_until=timezone.now() + timedelta(minutes=5),
|
|
||||||
last_error="AI box unavailable",
|
|
||||||
)
|
|
||||||
self._submission(email="second@example.com")
|
|
||||||
|
|
||||||
result = process_contact_submissions(limit=2)
|
|
||||||
|
|
||||||
self.assertEqual(result["processed"], 0)
|
|
||||||
self.assertIsNotNone(result["cooldown_until"])
|
|
||||||
classify.assert_not_called()
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_active_detection_lease_prevents_parallel_ai_calls(self, classify):
|
|
||||||
ContactSpamDetectionState.objects.create(
|
|
||||||
pk=1,
|
|
||||||
lock_token="another-worker",
|
|
||||||
locked_until=timezone.now() + timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
self._submission()
|
|
||||||
|
|
||||||
result = process_contact_submissions(limit=1)
|
|
||||||
|
|
||||||
self.assertEqual(result["processed"], 0)
|
|
||||||
self.assertTrue(result["detector_busy"])
|
|
||||||
classify.assert_not_called()
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.send_contact_user_email")
|
|
||||||
@patch("base.contact_submission_processor.send_contact_notification_email")
|
|
||||||
@patch("base.contact_submission_processor.SpamDetectionClient.classify")
|
|
||||||
def test_mail_retry_does_not_repeat_successful_notification(self, classify, send_notification, send_user):
|
|
||||||
ContactFormSettings.objects.create(send_user_copy=True)
|
|
||||||
submission = self._submission()
|
|
||||||
classify.return_value = SpamDetectionResult(category=0, source="kneo300")
|
|
||||||
send_user.side_effect = [OSError("SMTP unavailable"), 1]
|
|
||||||
|
|
||||||
process_contact_submissions(limit=1)
|
|
||||||
submission.refresh_from_db()
|
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SEND_RETRY)
|
|
||||||
self.assertIsNotNone(submission.notification_sent_at)
|
|
||||||
|
|
||||||
submission.next_attempt_at = timezone.now() - timedelta(seconds=1)
|
|
||||||
submission.save(update_fields=["next_attempt_at", "updated_at"])
|
|
||||||
process_contact_submissions(limit=1)
|
|
||||||
|
|
||||||
submission.refresh_from_db()
|
|
||||||
self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
|
|
||||||
send_notification.assert_called_once()
|
|
||||||
self.assertEqual(send_user.call_count, 2)
|
|
||||||
|
|
||||||
@patch("base.contact_submission_processor.urlopen")
|
|
||||||
def test_category_zero_with_service_error_is_not_accepted(self, urlopen_mock):
|
|
||||||
response = Mock()
|
|
||||||
response.read.return_value = json.dumps(
|
|
||||||
{"category": 0, "source": "kneo300", "error": "timeout"}
|
|
||||||
).encode("utf-8")
|
|
||||||
urlopen_mock.return_value.__enter__.return_value = response
|
|
||||||
|
|
||||||
with self.assertRaises(SpamDetectionError):
|
|
||||||
SpamDetectionClient().classify("hello")
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import logging
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
|
||||||
from ipaddress import ip_address as parse_ip_address
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
@ -11,18 +9,14 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.db import transaction
|
|
||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils.html import escape
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
||||||
from .models import (
|
from .models import (
|
||||||
ContactFormRateLimitBucket,
|
|
||||||
ContactFormSettings,
|
|
||||||
ContactFormSubmission,
|
|
||||||
MailSmtpSettings,
|
MailSmtpSettings,
|
||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
@ -38,6 +32,8 @@ from .newsletter import (
|
|||||||
extract_token,
|
extract_token,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
render_newsletter_html_for_send_job,
|
render_newsletter_html_for_send_job,
|
||||||
|
send_contact_notification_email,
|
||||||
|
send_contact_user_email,
|
||||||
send_subscribe_email,
|
send_subscribe_email,
|
||||||
)
|
)
|
||||||
from .newsletter_scheduler import dispatch_campaign
|
from .newsletter_scheduler import dispatch_campaign
|
||||||
@ -88,65 +84,11 @@ def _build_context(*, title: str, message: str, success: bool):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_ip(value: str):
|
def _render_contact_template(template: str, values: dict[str, str]) -> str:
|
||||||
candidate = (value or "").strip()
|
rendered = template or ""
|
||||||
if not candidate:
|
for key, value in values.items():
|
||||||
return None
|
rendered = rendered.replace(f"{{{{{key}}}}}", value)
|
||||||
try:
|
return rendered
|
||||||
return parse_ip_address(candidate)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if candidate.startswith("[") and "]:" in candidate:
|
|
||||||
candidate = candidate[1 : candidate.rfind("]")]
|
|
||||||
elif candidate.count(":") == 1:
|
|
||||||
candidate = candidate.rsplit(":", 1)[0]
|
|
||||||
|
|
||||||
try:
|
|
||||||
return parse_ip_address(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_client_ip(request) -> str:
|
|
||||||
forwarded_ips = [
|
|
||||||
parsed
|
|
||||||
for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
|
|
||||||
if (parsed := _normalize_ip(item)) is not None
|
|
||||||
]
|
|
||||||
if forwarded_ips:
|
|
||||||
return str(forwarded_ips[-1])
|
|
||||||
|
|
||||||
remote_ip = _normalize_ip(request.META.get("REMOTE_ADDR", ""))
|
|
||||||
return str(remote_ip) if remote_ip is not None else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _save_contact_submission(*, form: ContactForm, request) -> ContactFormSubmission:
|
|
||||||
contact_settings = ContactFormSettings.load(request_or_site=request)
|
|
||||||
client_ip = _get_client_ip(request)
|
|
||||||
now = timezone.now()
|
|
||||||
rate_limited = False
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
if contact_settings.rate_limit_enabled and client_ip:
|
|
||||||
bucket, _ = ContactFormRateLimitBucket.objects.get_or_create(ip_address=client_ip)
|
|
||||||
ContactFormRateLimitBucket.objects.select_for_update().get(pk=bucket.pk)
|
|
||||||
window_minutes = max(1, int(contact_settings.rate_limit_window_minutes or 10))
|
|
||||||
max_submissions = max(0, int(contact_settings.rate_limit_max_submissions or 0))
|
|
||||||
recent_count = ContactFormSubmission.objects.filter(
|
|
||||||
ip_address=client_ip,
|
|
||||||
created_at__gte=now - timedelta(minutes=window_minutes),
|
|
||||||
).count()
|
|
||||||
rate_limited = recent_count >= max_submissions
|
|
||||||
|
|
||||||
submission = form.save(commit=False)
|
|
||||||
submission.ip_address = client_ip or None
|
|
||||||
submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
||||||
if rate_limited:
|
|
||||||
submission.processing_status = ContactFormSubmission.STATUS_RATE_LIMITED
|
|
||||||
submission.save()
|
|
||||||
|
|
||||||
return submission
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@ -158,9 +100,88 @@ def contact_form_submit(request):
|
|||||||
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
||||||
return redirect(request.META.get("HTTP_REFERER") or "/")
|
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||||
|
|
||||||
submission = _save_contact_submission(form=form, request=request)
|
submission = form.save(commit=False)
|
||||||
if submission.processing_status == ContactFormSubmission.STATUS_RATE_LIMITED:
|
submission.ip_address = request.META.get("REMOTE_ADDR") or ""
|
||||||
logger.info("contact form submission rate limited submission_id=%s ip=%s", submission.pk, submission.ip_address)
|
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":
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
return JsonResponse({"success": True})
|
return JsonResponse({"success": True})
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
from wagtail.admin.auth import permission_required, require_admin_access
|
|
||||||
from wagtail.admin.menu import MenuItem
|
|
||||||
from wagtail.admin.panels import FieldPanel
|
from wagtail.admin.panels import FieldPanel
|
||||||
from wagtail.admin.ui.tables import Column
|
|
||||||
from wagtail.admin.widgets import Button
|
from wagtail.admin.widgets import Button
|
||||||
from wagtail.permission_policies import ModelPermissionPolicy
|
from wagtail.permission_policies import ModelPermissionPolicy
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
@ -20,30 +18,9 @@ from .forms import (
|
|||||||
NewsletterHtmlEditorWidget,
|
NewsletterHtmlEditorWidget,
|
||||||
NewsletterTemplateAdminForm,
|
NewsletterTemplateAdminForm,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
|
||||||
ContactFormSubmission,
|
|
||||||
NewsletterCampaign,
|
|
||||||
NewsletterDispatchRecord,
|
|
||||||
NewsletterSystemSettings,
|
|
||||||
NewsletterTemplate,
|
|
||||||
)
|
|
||||||
|
|
||||||
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
SEND_NEWSLETTER_PERMISSION = "base.send_newslettercampaign"
|
||||||
ADD_NEWSLETTER_PERMISSION = "base.add_newslettercampaign"
|
|
||||||
|
|
||||||
|
|
||||||
def _contact_status_value(submission):
|
|
||||||
return submission.get_processing_status_display()
|
|
||||||
|
|
||||||
|
|
||||||
def _contact_spam_category_value(submission):
|
|
||||||
if submission.spam_category is None:
|
|
||||||
return "尚未檢查"
|
|
||||||
return submission.get_spam_category_display()
|
|
||||||
|
|
||||||
|
|
||||||
def _contact_category_value(submission):
|
|
||||||
return submission.get_category_display()
|
|
||||||
|
|
||||||
|
|
||||||
def _newsletter_campaign_add_url() -> str:
|
def _newsletter_campaign_add_url() -> str:
|
||||||
@ -59,8 +36,7 @@ def _newsletter_campaign_add_url() -> str:
|
|||||||
return "/admin/snippets/base/newslettercampaign/add/"
|
return "/admin/snippets/base/newslettercampaign/add/"
|
||||||
|
|
||||||
|
|
||||||
@require_admin_access
|
@staff_member_required
|
||||||
@permission_required(ADD_NEWSLETTER_PERMISSION)
|
|
||||||
def newsletter_campaign_type_select_view(request):
|
def newsletter_campaign_type_select_view(request):
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@ -74,8 +50,7 @@ def newsletter_campaign_type_select_view(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@require_admin_access
|
@staff_member_required
|
||||||
@permission_required(ADD_NEWSLETTER_PERMISSION)
|
|
||||||
def newsletter_campaign_add_by_type_view(request, campaign_type: str):
|
def newsletter_campaign_add_by_type_view(request, campaign_type: str):
|
||||||
campaign_type = (campaign_type or "").strip()
|
campaign_type = (campaign_type or "").strip()
|
||||||
allowed = {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}
|
allowed = {NewsletterCampaign.TYPE_GENERAL, NewsletterCampaign.TYPE_WEEKLY_NEWS}
|
||||||
@ -213,51 +188,6 @@ class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
|
|||||||
return super().user_has_permission(user, action)
|
return super().user_has_permission(user, action)
|
||||||
|
|
||||||
|
|
||||||
class ContactFormSubmissionViewSet(SnippetViewSet):
|
|
||||||
model = ContactFormSubmission
|
|
||||||
icon = "form"
|
|
||||||
menu_label = "聯絡表單留言"
|
|
||||||
menu_order = 240
|
|
||||||
add_to_admin_menu = False
|
|
||||||
inspect_view_enabled = True
|
|
||||||
copy_view_enabled = False
|
|
||||||
permission_policy = ReadOnlySnippetPermissionPolicy(ContactFormSubmission)
|
|
||||||
list_display = [
|
|
||||||
"created_at",
|
|
||||||
Column("processing_status", label="處理狀態", accessor=_contact_status_value, sort_key="processing_status"),
|
|
||||||
Column("spam_category", label="垃圾訊息判定", accessor=_contact_spam_category_value, sort_key="spam_category"),
|
|
||||||
Column("category", label="留言類型", accessor=_contact_category_value, sort_key="category"),
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"ip_address",
|
|
||||||
]
|
|
||||||
list_filter = ["processing_status", "spam_category", "category", "created_at"]
|
|
||||||
search_fields = ["name", "email", "contact", "message", "source_page", "ip_address"]
|
|
||||||
inspect_view_fields = [
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"contact",
|
|
||||||
"category",
|
|
||||||
"message",
|
|
||||||
"source_page",
|
|
||||||
"ip_address",
|
|
||||||
"user_agent",
|
|
||||||
"processing_status",
|
|
||||||
"spam_category",
|
|
||||||
"spam_source",
|
|
||||||
"spam_checked_at",
|
|
||||||
"scan_attempt_count",
|
|
||||||
"send_attempt_count",
|
|
||||||
"next_attempt_at",
|
|
||||||
"processing_started_at",
|
|
||||||
"notification_sent_at",
|
|
||||||
"user_copy_sent_at",
|
|
||||||
"last_error",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
class NewsletterDispatchRecordViewSet(SnippetViewSet):
|
||||||
model = NewsletterDispatchRecord
|
model = NewsletterDispatchRecord
|
||||||
icon = "tasks"
|
icon = "tasks"
|
||||||
@ -323,23 +253,6 @@ class NewsletterAdminGroup(SnippetViewSetGroup):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(NewsletterAdminGroup)
|
register_snippet(NewsletterAdminGroup)
|
||||||
register_snippet(ContactFormSubmissionViewSet)
|
|
||||||
|
|
||||||
|
|
||||||
class ContactFormSubmissionReportMenuItem(MenuItem):
|
|
||||||
def is_shown(self, request):
|
|
||||||
return request.user.has_perm("base.view_contactformsubmission")
|
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_reports_menu_item")
|
|
||||||
def register_contact_form_submission_report_menu_item():
|
|
||||||
return ContactFormSubmissionReportMenuItem(
|
|
||||||
"聯絡表單留言",
|
|
||||||
reverse("wagtailsnippets_base_contactformsubmission:list"),
|
|
||||||
name="contact-form-submissions",
|
|
||||||
icon_name="form",
|
|
||||||
order=600,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_snippet_listing_buttons")
|
@hooks.register("register_snippet_listing_buttons")
|
||||||
|
|||||||
37
innovedus_cms/entrypoint.sh
Executable file → Normal file
37
innovedus_cms/entrypoint.sh
Executable file → Normal file
@ -7,38 +7,11 @@ APP_ROLE="${APP_ROLE:-web}"
|
|||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
if [ "$APP_ROLE" = "scheduler" ]; then
|
if [ "$APP_ROLE" = "scheduler" ]; then
|
||||||
NEWSLETTER_SCHEDULER_INTERVAL_SECONDS="${NEWSLETTER_SCHEDULER_INTERVAL_SECONDS:-${SCHEDULER_INTERVAL_SECONDS:-60}}"
|
SCHEDULER_INTERVAL_SECONDS="${SCHEDULER_INTERVAL_SECONDS:-60}"
|
||||||
CONTACT_SCHEDULER_INTERVAL_SECONDS="${CONTACT_SCHEDULER_INTERVAL_SECONDS:-60}"
|
while true; do
|
||||||
|
python manage.py run_newsletter_scheduler --limit 20
|
||||||
run_newsletter_loop() {
|
sleep "$SCHEDULER_INTERVAL_SECONDS"
|
||||||
while true; do
|
done
|
||||||
if ! python manage.py run_newsletter_scheduler --limit 20; then
|
|
||||||
echo "newsletter scheduler command failed" >&2
|
|
||||||
fi
|
|
||||||
sleep "$NEWSLETTER_SCHEDULER_INTERVAL_SECONDS"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
run_contact_loop() {
|
|
||||||
while true; do
|
|
||||||
if ! python manage.py run_contact_submission_worker; then
|
|
||||||
echo "contact submission worker command failed" >&2
|
|
||||||
fi
|
|
||||||
sleep "$CONTACT_SCHEDULER_INTERVAL_SECONDS"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
run_newsletter_loop &
|
|
||||||
newsletter_pid=$!
|
|
||||||
run_contact_loop &
|
|
||||||
contact_pid=$!
|
|
||||||
|
|
||||||
stop_scheduler_loops() {
|
|
||||||
kill "$newsletter_pid" "$contact_pid" 2>/dev/null || true
|
|
||||||
wait "$newsletter_pid" "$contact_pid" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap stop_scheduler_loops EXIT TERM INT
|
|
||||||
wait
|
|
||||||
else
|
else
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@ -14,3 +14,26 @@ def random_default_cover():
|
|||||||
"img/default_cover_3.jpg",
|
"img/default_cover_3.jpg",
|
||||||
)
|
)
|
||||||
return static(random.choice(choices))
|
return static(random.choice(choices))
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def idle_recommendations(context, limit=4):
|
||||||
|
from home.models import ArticlePage
|
||||||
|
|
||||||
|
current_page = context.get("page")
|
||||||
|
exclude_ids = []
|
||||||
|
current_id = getattr(current_page, "id", None)
|
||||||
|
if current_id:
|
||||||
|
exclude_ids.append(current_id)
|
||||||
|
|
||||||
|
base_qs = ArticlePage.objects.live().filter(not_news=False).exclude(id__in=exclude_ids)
|
||||||
|
articles = list(base_qs.filter(trending=True).order_by("-date", "-id")[:limit])
|
||||||
|
if len(articles) < limit:
|
||||||
|
used_ids = [article.id for article in articles]
|
||||||
|
articles.extend(
|
||||||
|
list(
|
||||||
|
base_qs.exclude(id__in=used_ids)
|
||||||
|
.order_by("-date", "-id")[: limit - len(articles)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return articles
|
||||||
|
|||||||
Binary file not shown.
@ -270,7 +270,7 @@ msgid "Sending"
|
|||||||
msgstr "寄送中"
|
msgstr "寄送中"
|
||||||
|
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "已寄出"
|
msgstr "已送出"
|
||||||
|
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
msgstr "失敗"
|
msgstr "失敗"
|
||||||
@ -333,97 +333,10 @@ msgid "Created At"
|
|||||||
msgstr "建立時間"
|
msgstr "建立時間"
|
||||||
|
|
||||||
msgid "Contact Form Submission"
|
msgid "Contact Form Submission"
|
||||||
msgstr "聯絡表單留言"
|
msgstr "聯絡表單提交"
|
||||||
|
|
||||||
msgid "Contact Form Submissions"
|
msgid "Contact Form Submissions"
|
||||||
msgstr "聯絡表單留言"
|
msgstr "聯絡表單提交"
|
||||||
|
|
||||||
msgid "Contact form submissions"
|
|
||||||
msgstr "聯絡表單留言"
|
|
||||||
|
|
||||||
msgid "Contact Form Settings"
|
|
||||||
msgstr "聯絡表單設定"
|
|
||||||
|
|
||||||
msgid "Contact Form Rate Limit"
|
|
||||||
msgstr "聯絡表單頻率限制"
|
|
||||||
|
|
||||||
msgid "Enable IP Rate Limit"
|
|
||||||
msgstr "啟用 IP 留言頻率限制"
|
|
||||||
|
|
||||||
msgid "Maximum Submissions Per Window"
|
|
||||||
msgstr "限制區間內最多留言數"
|
|
||||||
|
|
||||||
msgid "Rate Limit Window Minutes"
|
|
||||||
msgstr "限制區間(分鐘)"
|
|
||||||
|
|
||||||
msgid "Send User Copy"
|
|
||||||
msgstr "寄送留言副本給填表人"
|
|
||||||
|
|
||||||
msgid "Send a copy to the submitter only after spam detection allows the message."
|
|
||||||
msgstr "僅在垃圾訊息檢測通過後,寄送留言副本給填表人。"
|
|
||||||
|
|
||||||
msgid "Processing Status"
|
|
||||||
msgstr "處理狀態"
|
|
||||||
|
|
||||||
msgid "Queued"
|
|
||||||
msgstr "等待垃圾訊息檢查"
|
|
||||||
|
|
||||||
msgid "Scanning"
|
|
||||||
msgstr "檢查垃圾訊息中"
|
|
||||||
|
|
||||||
msgid "Scan Retry"
|
|
||||||
msgstr "等待重新檢查"
|
|
||||||
|
|
||||||
msgid "Blocked as Spam"
|
|
||||||
msgstr "已阻擋垃圾訊息"
|
|
||||||
|
|
||||||
msgid "Send Retry"
|
|
||||||
msgstr "等待重新寄送"
|
|
||||||
|
|
||||||
msgid "Rate Limited"
|
|
||||||
msgstr "超過留言頻率限制"
|
|
||||||
|
|
||||||
msgid "Legacy Submission"
|
|
||||||
msgstr "舊資料(未重新檢查)"
|
|
||||||
|
|
||||||
msgid "Spam Category"
|
|
||||||
msgstr "垃圾訊息分類"
|
|
||||||
|
|
||||||
msgid "Allowed"
|
|
||||||
msgstr "正常留言(允許寄送)"
|
|
||||||
|
|
||||||
msgid "Unsolicited Advertising"
|
|
||||||
msgstr "廣告/推銷訊息"
|
|
||||||
|
|
||||||
msgid "Malicious"
|
|
||||||
msgstr "惡意/釣魚訊息"
|
|
||||||
|
|
||||||
msgid "Meaningless"
|
|
||||||
msgstr "無意義/測試內容"
|
|
||||||
|
|
||||||
msgid "Spam Detection Source"
|
|
||||||
msgstr "垃圾訊息判定來源"
|
|
||||||
|
|
||||||
msgid "Spam Checked At"
|
|
||||||
msgstr "垃圾訊息檢測時間"
|
|
||||||
|
|
||||||
msgid "Scan Attempt Count"
|
|
||||||
msgstr "檢測嘗試次數"
|
|
||||||
|
|
||||||
msgid "Send Attempt Count"
|
|
||||||
msgstr "寄送嘗試次數"
|
|
||||||
|
|
||||||
msgid "Next Attempt At"
|
|
||||||
msgstr "下次嘗試時間"
|
|
||||||
|
|
||||||
msgid "Processing Started At"
|
|
||||||
msgstr "開始處理時間"
|
|
||||||
|
|
||||||
msgid "Notification Sent At"
|
|
||||||
msgstr "內部通知寄送時間"
|
|
||||||
|
|
||||||
msgid "User Copy Sent At"
|
|
||||||
msgstr "填表人副本寄送時間"
|
|
||||||
|
|
||||||
msgid "Newsletter campaigns"
|
msgid "Newsletter campaigns"
|
||||||
msgstr "電子報"
|
msgstr "電子報"
|
||||||
|
|||||||
@ -55,16 +55,6 @@ def env_optional(name, default=None):
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def env_int(name, default):
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value.strip())
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def build_media_storage_options():
|
def build_media_storage_options():
|
||||||
options = {
|
options = {
|
||||||
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
"access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||||
@ -121,18 +111,6 @@ def build_allowed_hosts():
|
|||||||
GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "").strip()
|
GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "").strip()
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "").strip()
|
SECRET_KEY = os.environ.get("SECRET_KEY", "").strip()
|
||||||
|
|
||||||
CONTACT_SPAM_DETECTION_ENABLED = env_bool("CONTACT_SPAM_DETECTION_ENABLED", default=False)
|
|
||||||
CONTACT_SPAM_DETECTION_URL = os.environ.get("CONTACT_SPAM_DETECTION_URL", "").strip()
|
|
||||||
CONTACT_SPAM_DETECTION_API_KEY = os.environ.get("CONTACT_SPAM_DETECTION_API_KEY", "").strip()
|
|
||||||
CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS = env_int("CONTACT_SPAM_DETECTION_TIMEOUT_SECONDS", 180)
|
|
||||||
CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS = env_int("CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS", 300)
|
|
||||||
CONTACT_SPAM_DETECTION_MAX_ATTEMPTS = env_int("CONTACT_SPAM_DETECTION_MAX_ATTEMPTS", 3)
|
|
||||||
CONTACT_FORM_WORKER_BATCH_SIZE = env_int("CONTACT_FORM_WORKER_BATCH_SIZE", 20)
|
|
||||||
CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS = env_int("CONTACT_FORM_WORKER_MAX_RUNTIME_SECONDS", 600)
|
|
||||||
CONTACT_FORM_WORKER_LEASE_SECONDS = env_int("CONTACT_FORM_WORKER_LEASE_SECONDS", 600)
|
|
||||||
CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS = env_int("CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS", 300)
|
|
||||||
CONTACT_FORM_MAIL_MAX_ATTEMPTS = env_int("CONTACT_FORM_MAIL_MAX_ATTEMPTS", 3)
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|||||||
315
innovedus_cms/mysite/static/css/idle_modal.css
Normal file
315
innovedus_cms/mysite/static/css/idle_modal.css
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
.idle-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal,
|
||||||
|
.idle-modal * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14, 27, 66, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__dialog {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 1024px;
|
||||||
|
min-height: 618px;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 82px 92px 48px 76px;
|
||||||
|
background: #0e1b42;
|
||||||
|
border: 1pt solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Inter", "Noto Sans JP", sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 29px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__cup {
|
||||||
|
display: block;
|
||||||
|
width: 88px;
|
||||||
|
height: 98px;
|
||||||
|
margin: 23px auto 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 0 0 197px;
|
||||||
|
width: 197px;
|
||||||
|
height: 60px;
|
||||||
|
padding-left: 21px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0e1b42;
|
||||||
|
font-family: "Inter", "Noto Sans JP", sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 60px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__rule {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 1px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 194px);
|
||||||
|
gap: 0 26px;
|
||||||
|
margin-top: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__card {
|
||||||
|
width: 194px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__card-link {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__thumb {
|
||||||
|
display: block;
|
||||||
|
width: 194px;
|
||||||
|
height: 133px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__thumb img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__article-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Inter", "Noto Sans JP", sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
.idle-modal {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__dialog {
|
||||||
|
width: 768px;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
padding: 56px 48px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__grid {
|
||||||
|
grid-template-columns: repeat(4, 145px);
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__card {
|
||||||
|
width: 145px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__thumb {
|
||||||
|
width: 139px;
|
||||||
|
height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__article-title {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 575px) and (max-width: 767px) {
|
||||||
|
.idle-modal {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__dialog {
|
||||||
|
width: 575px;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
padding: 16px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__title {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 29px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__cup {
|
||||||
|
width: 58px;
|
||||||
|
height: 65px;
|
||||||
|
margin: 6px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-head {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-label {
|
||||||
|
flex-basis: 197px;
|
||||||
|
width: 197px;
|
||||||
|
height: 60px;
|
||||||
|
padding-left: 21px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 194px);
|
||||||
|
gap: 8px 16px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__card {
|
||||||
|
width: 194px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__thumb {
|
||||||
|
width: 194px;
|
||||||
|
height: 133px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__article-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 574px) {
|
||||||
|
.idle-modal {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__dialog {
|
||||||
|
width: min(300px, calc(100vw - 24px));
|
||||||
|
min-height: 0;
|
||||||
|
padding: 28px 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__close {
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__title {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__cup {
|
||||||
|
width: 58px;
|
||||||
|
height: 65px;
|
||||||
|
margin: 12px auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-head {
|
||||||
|
height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__section-label {
|
||||||
|
flex-basis: 139px;
|
||||||
|
width: 139px;
|
||||||
|
height: 55px;
|
||||||
|
padding-left: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__grid {
|
||||||
|
grid-template-columns: repeat(2, 139px);
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__thumb {
|
||||||
|
width: 139px;
|
||||||
|
height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__card {
|
||||||
|
width: 139px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-modal__article-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
innovedus_cms/mysite/static/js/idle_modal.js
Normal file
70
innovedus_cms/mysite/static/js/idle_modal.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
(function () {
|
||||||
|
const modal = document.querySelector("[data-idle-modal]");
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const delay = Number.parseInt(modal.getAttribute("data-idle-delay") || "120000", 10);
|
||||||
|
const closeTargets = modal.querySelectorAll("[data-idle-close]");
|
||||||
|
const articleLinks = modal.querySelectorAll(".idle-modal__card-link");
|
||||||
|
let timer = null;
|
||||||
|
let lastActive = null;
|
||||||
|
|
||||||
|
const isOpen = () => !modal.hidden;
|
||||||
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleIdleTimer = () => {
|
||||||
|
clearIdleTimer();
|
||||||
|
if (document.hidden || isOpen()) return;
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
lastActive = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
modal.hidden = false;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const closeButton = modal.querySelector(".idle-modal__close");
|
||||||
|
if (closeButton) closeButton.focus();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (!isOpen()) return;
|
||||||
|
modal.hidden = true;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
if (lastActive && typeof lastActive.focus === "function") {
|
||||||
|
lastActive.focus();
|
||||||
|
}
|
||||||
|
scheduleIdleTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
["pointermove", "keydown", "scroll", "touchstart", "click"].forEach((eventName) => {
|
||||||
|
document.addEventListener(
|
||||||
|
eventName,
|
||||||
|
() => {
|
||||||
|
if (!isOpen()) scheduleIdleTimer();
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
closeTargets.forEach((target) => {
|
||||||
|
target.addEventListener("click", closeModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
articleLinks.forEach((link) => {
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", scheduleIdleTimer);
|
||||||
|
scheduleIdleTimer();
|
||||||
|
})();
|
||||||
@ -47,6 +47,7 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/footer.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/subscribe_fab.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'css/idle_modal.css' %}">
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{# Override this in templates to add extra stylesheets #}
|
{# Override this in templates to add extra stylesheets #}
|
||||||
@ -67,12 +68,14 @@
|
|||||||
{% include "includes/footer.html" %}
|
{% include "includes/footer.html" %}
|
||||||
{% include "includes/contact_form.html" %}
|
{% include "includes/contact_form.html" %}
|
||||||
{% include "includes/subscribe_fab.html" %}
|
{% include "includes/subscribe_fab.html" %}
|
||||||
|
{% include "includes/idle_modal.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/contact_form.js' %}"></script>
|
||||||
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
|
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'js/idle_modal.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>
|
||||||
|
|
||||||
|
|||||||
40
innovedus_cms/mysite/templates/includes/idle_modal.html
Normal file
40
innovedus_cms/mysite/templates/includes/idle_modal.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{% load home_tags wagtailimages_tags %}
|
||||||
|
{% idle_recommendations as idle_articles %}
|
||||||
|
{% if idle_articles %}
|
||||||
|
<div class="idle-modal" data-idle-modal data-idle-delay="120000" hidden>
|
||||||
|
<div class="idle-modal__backdrop" data-idle-close></div>
|
||||||
|
<section class="idle-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="idle-modal-title">
|
||||||
|
<button class="idle-modal__close" type="button" aria-label="關閉" data-idle-close>×</button>
|
||||||
|
<h2 class="idle-modal__title" id="idle-modal-title">你已閒置超過兩分鐘囉!</h2>
|
||||||
|
<svg class="idle-modal__cup" width="88" height="98" viewBox="489 134 89 98" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M558.438 232H492.217C491.526 231.236 491.212 230.55 491.961 229.689L557.967 229.5C559.506 229.517 559.601 231.279 558.433 232H558.438Z" fill="white"/>
|
||||||
|
<path d="M559.777 168.074C565.973 165.137 572.104 166.218 575.121 172.733C578.074 179.107 577.291 187.35 574.548 193.66C570.626 202.673 562.079 208.625 552.675 210.87L550.353 224.777L549.742 225.697L499.539 225.633L499.13 224.608L489 163.05C489.164 162.378 489.724 162.109 490.391 162.135L559.302 162.14C561.877 161.992 559.399 167.686 559.777 168.074ZM557.671 164.63H491.835L501.873 223.399L547.891 223.365L557.671 164.63ZM553.081 208.076C562.897 205.419 571.055 198.582 573.582 188.483C575.3 181.61 574.512 167.249 564.17 169.309C563.104 169.522 559.586 170.843 559.184 171.787L553.081 208.076Z" fill="white"/>
|
||||||
|
<path d="M522.507 159.223C521.867 158.55 522.417 155.94 522.747 155.063C524.125 151.394 527.807 150.624 527.221 145.8C526.884 143.015 524.143 141.479 522.998 139.114C522.392 137.863 521.325 134.003 523.509 134C525.035 134 524.713 135.915 525.04 137.073C526.14 140.957 530.233 141.607 529.927 147.321C529.676 152.028 525.843 153.232 525.002 156.556C524.828 157.239 524.854 158.435 524.657 158.891C524.34 159.617 523.051 159.798 522.507 159.225V159.223Z" fill="white"/>
|
||||||
|
<path d="M503.372 159.22C502.605 158.448 503.216 156.571 503.696 155.728C504.87 153.672 508.598 152.649 508.058 150.034C507.708 148.328 504.21 147.014 503.361 144.779C502.939 143.672 502.633 141.586 504.364 141.65C505.916 141.706 505.391 143.342 506.189 144.439C507.736 146.562 510.901 147.07 510.786 150.747C510.674 154.357 507.677 154.687 506.187 156.689C505.588 157.492 505.742 158.668 505.241 159.187C504.739 159.706 503.86 159.711 503.369 159.22H503.372Z" fill="white"/>
|
||||||
|
<path d="M542.309 141.699C544.148 141.328 543.705 143.294 544.378 144.335C545.758 146.47 549.077 146.853 549.075 150.563C549.069 154.795 544.835 155.102 544.02 157.582C543.764 158.356 543.981 159.571 542.649 159.571C541.317 159.571 541.289 158.257 541.422 157.28C541.882 153.895 546.551 153.245 546.392 150.369C546.285 148.382 542.562 147.094 541.691 144.918C541.302 143.946 540.969 141.97 542.309 141.699Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
<div class="idle-modal__section-head">
|
||||||
|
<span class="idle-modal__section-label">專屬推薦</span>
|
||||||
|
<span class="idle-modal__rule" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<div class="idle-modal__grid">
|
||||||
|
{% for article in idle_articles %}
|
||||||
|
<article class="idle-modal__card">
|
||||||
|
<a class="idle-modal__card-link" href="{{ article.url }}">
|
||||||
|
<span class="idle-modal__thumb">
|
||||||
|
{% if article.cover_image %}
|
||||||
|
{% image article.cover_image max-194x133 as cover %}
|
||||||
|
<img src="{{ cover.url }}" alt="{{ article.title }}" width="194" height="133">
|
||||||
|
{% else %}
|
||||||
|
{% random_default_cover as default_cover %}
|
||||||
|
<img src="{{ default_cover }}" alt="{{ article.title }}" width="194" height="133">
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="idle-modal__article-title">{{ article.title }}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
Loading…
x
Reference in New Issue
Block a user