release: publish Enuxia Suite v16 ARM64 R3

This commit is contained in:
Julien Denizot
2026-06-22 21:38:47 +02:00
commit 7807f97fb3
9 changed files with 1030 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
.env
.env.*
!.env.example
*.log
*.bak
*.backup
*.sql
*.sql.gz
*.tar
*.tar.gz
*.zip
sites/
data/
releases/
backups/
secrets/
credentials/
site_config.json
common_site_config.json
__pycache__/
*.pyc
.DS_Store
+73
View File
@@ -0,0 +1,73 @@
# Enuxia Suite
Public ARM64 production image for Frappe Framework and ERPNext v16, maintained by Enuxia.
## Published image
```bash
docker pull git.enuxia.fr/enuxia-public/enuxia-suite:16.23.1-prod-arm64-20260622-r3
```
Rolling tag:
```bash
docker pull git.enuxia.fr/enuxia-public/enuxia-suite:16-prod-arm64
```
Platform: `linux/arm64`
## Versions
- Frappe Framework 16.23.1
- ERPNext 16.23.1
- 21 additional applications
- Exact application revisions are recorded in `manifests/apps.v16.prod.lock.json`
## Repository layout
- `images/Containerfile.v16-prod`: production image definition
- `manifests/apps.v16.prod
## Repository layout
- `images/Containerfile.v16-prod`: production image definition
- `manifests/apps.v16.prod.json`: production application manifest
- `manifests/apps.v16.prod.lock.json`: revisions used by the validated release
- `manifests/apps.v16.dev.json`: development application manifest
- `patches/`: compatibility corrections included in the release
- `resources/core/`: runtime entrypoint scripts
## Build
Run from the repository root with Docker BuildKit:
```bash
docker buildx build \
--platform linux/arm64 \
--secret id=apps_json,src=manifests/apps.v16.prod.json \
--file images/Containerfile.v16-prod \
--tag enuxia-suite:16-prod-arm64 \
--load \
.
```
The immutable OCI image is the reference production artifact. Upstream branches in the build manifest may move; the lock file records the exact source revisions used by release R3.
## Validation
Release R3 was validated on a native ARM64 Raspberry Pi environment with:
- fresh-site installation
- migration of an existing Frappe v16 site
- application schema migration
- scheduler hook synchronization
- background workers
- websocket service
- Desk session boot
- HTTP health check
## Security
Secrets, site configuration, customer data, backups and deployment credentials are intentionally excluded from this repository.
Each bundled application retains its own upstream licence and copyright.
+108
View File
@@ -0,0 +1,108 @@
ARG FRAPPE_BRANCH=version-16
ARG FRAPPE_IMAGE_PREFIX=frappe
FROM ${FRAPPE_IMAGE_PREFIX}/build:${FRAPPE_BRANCH} AS builder
ARG FRAPPE_BRANCH=version-16
ARG FRAPPE_PATH=https://github.com/frappe/frappe
ARG CACHE_BUST=""
COPY --chown=frappe:frappe \
patches/crm/event.py \
/opt/enuxia-patches/crm/event.py
USER frappe
RUN --mount=type=secret,id=apps_json,target=/opt/frappe/apps.json,uid=1000,gid=1000 \
set -eux; \
: "${CACHE_BUST}"; \
APP_INSTALL_ARGS=""; \
if [ -s /opt/frappe/apps.json ]; then \
APP_INSTALL_ARGS="--apps_path=/opt/frappe/apps.json"; \
fi; \
bench init ${APP_INSTALL_ARGS} \
--frappe-branch="${FRAPPE_BRANCH}" \
--frappe-path="${FRAPPE_PATH}" \
--no-procfile \
--no-backups \
--skip-redis-config-generation \
--verbose \
/home/frappe/frappe-bench; \
cd /home/frappe/frappe-bench; \
echo "{}" > sites/common_site_config.json; \
\
echo "=== Correctif LMS ==="; \
LMS_SOURCE="apps/lms/lms/lms/patches/v2_0/add_video_watch_duration_index.py"; \
LMS_TARGET="apps/lms/lms/patches/v2_0/add_video_watch_duration_index.py"; \
test -s "${LMS_SOURCE}"; \
mkdir -p "$(dirname "${LMS_TARGET}")"; \
cp "${LMS_SOURCE}" "${LMS_TARGET}"; \
chmod 0644 "${LMS_TARGET}"; \
cmp "${LMS_SOURCE}" "${LMS_TARGET}"; \
\
echo "=== Correctif CRM ==="; \
CRM_TARGET="apps/crm/crm/api/event.py"; \
test -s /opt/enuxia-patches/crm/event.py; \
cp /opt/enuxia-patches/crm/event.py "${CRM_TARGET}"; \
chmod 0644 "${CRM_TARGET}"; \
grep -q "^def trigger_offset_event_notifications" "${CRM_TARGET}"; \
grep -q "^def trigger_hourly_event_notifications" "${CRM_TARGET}"; \
grep -q "^def trigger_daily_event_notifications" "${CRM_TARGET}"; \
grep -q "^def trigger_weekly_event_notifications" "${CRM_TARGET}"; \
\
echo "=== Correctif Gameplan ==="; \
GAMEPLAN_FILE="apps/gameplan/gameplan/api.py"; \
OLD_IMPORT="from frappe.config import get_modules_from_all_apps_for_user"; \
NEW_IMPORT="from frappe.utils.modules import get_modules_from_all_apps_for_user"; \
test "$(grep -cF "${OLD_IMPORT}" "${GAMEPLAN_FILE}")" -eq 1; \
sed -i "s|${OLD_IMPORT}|${NEW_IMPORT}|" "${GAMEPLAN_FILE}"; \
grep -F "${NEW_IMPORT}" "${GAMEPLAN_FILE}"; \
! grep -F "${OLD_IMPORT}" "${GAMEPLAN_FILE}"; \
\
./env/bin/python -m py_compile \
"${LMS_TARGET}" \
"${CRM_TARGET}" \
"${GAMEPLAN_FILE}"; \
\
rm -rf /opt/enuxia-patches; \
find apps -mindepth 1 -path "*/.git" -prune -exec rm -rf {} +
FROM ${FRAPPE_IMAGE_PREFIX}/base:${FRAPPE_BRANCH} AS backend
LABEL org.opencontainers.image.title="Enuxia Suite"
LABEL org.opencontainers.image.description="Production Frappe and ERPNext v16 image maintained by Enuxia"
LABEL org.opencontainers.image.version="16.23.1-r3"
LABEL org.opencontainers.image.source="https://git.enuxia.fr/Enuxia-Public/enuxia-suite"
USER frappe
COPY --from=builder --chown=frappe:frappe \
/home/frappe/frappe-bench \
/home/frappe/frappe-bench
WORKDIR /home/frappe/frappe-bench
RUN cp -r \
/home/frappe/frappe-bench/sites/assets \
/home/frappe/frappe-bench/assets && \
rm -rf /home/frappe/frappe-bench/sites/assets
VOLUME [ \
"/home/frappe/frappe-bench/sites", \
"/home/frappe/frappe-bench/logs" \
]
USER root
COPY resources/core/main-entrypoint.sh /usr/local/bin/entrypoint.sh
COPY resources/core/start.sh /usr/local/bin/start.sh
RUN chmod 755 \
/usr/local/bin/entrypoint.sh \
/usr/local/bin/start.sh
USER frappe
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["start.sh"]
+90
View File
@@ -0,0 +1,90 @@
[
{
"url": "https://github.com/frappe/erpnext",
"branch": "version-16"
},
{
"url": "https://github.com/earthians/marley",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/hrms",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/payments",
"branch": "develop"
},
{
"url": "https://github.com/frappe/telephony",
"branch": "develop"
},
{
"url": "https://github.com/frappe/lms",
"branch": "main"
},
{
"url": "https://github.com/frappe/helpdesk",
"branch": "main"
},
{
"url": "https://github.com/frappe/education",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/insights",
"branch": "main"
},
{
"url": "https://github.com/frappe/wiki",
"branch": "develop"
},
{
"url": "https://github.com/frappe/webshop",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/crm",
"branch": "main"
},
{
"url": "https://github.com/frappe/meet",
"branch": "develop"
},
{
"url": "https://github.com/frappe/builder",
"branch": "develop"
},
{
"url": "https://github.com/frappe/gameplan",
"branch": "main"
},
{
"url": "https://github.com/frappe/print_designer",
"branch": "develop"
},
{
"url": "https://github.com/frappe/newsletter",
"branch": "develop"
},
{
"url": "https://github.com/frappe/agriculture",
"branch": "develop"
},
{
"url": "https://github.com/frappe/slides",
"branch": "develop"
},
{
"url": "https://github.com/frappe/blog",
"branch": "develop"
},
{
"url": "https://github.com/frappe/cafe",
"branch": "develop"
},
{
"url": "https://git.enuxia.fr/Enuxia-Public/enuxia_einvoice.git",
"branch": "version-16"
}
]
+86
View File
@@ -0,0 +1,86 @@
[
{
"url": "https://github.com/frappe/erpnext",
"branch": "version-16"
},
{
"url": "https://github.com/earthians/marley",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/hrms",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/payments",
"branch": "develop"
},
{
"url": "https://github.com/frappe/telephony",
"branch": "develop"
},
{
"url": "https://github.com/frappe/lms",
"branch": "main"
},
{
"url": "https://github.com/frappe/helpdesk",
"branch": "main"
},
{
"url": "https://github.com/frappe/education",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/wiki",
"branch": "develop"
},
{
"url": "https://github.com/frappe/webshop",
"branch": "version-16"
},
{
"url": "https://github.com/frappe/crm",
"branch": "main"
},
{
"url": "https://github.com/frappe/meet",
"branch": "develop"
},
{
"url": "https://github.com/frappe/builder",
"branch": "develop"
},
{
"url": "https://github.com/frappe/gameplan",
"branch": "main"
},
{
"url": "https://github.com/frappe/print_designer",
"branch": "develop"
},
{
"url": "https://github.com/frappe/newsletter",
"branch": "develop"
},
{
"url": "https://github.com/frappe/agriculture",
"branch": "develop"
},
{
"url": "https://github.com/frappe/slides",
"branch": "develop"
},
{
"url": "https://github.com/frappe/blog",
"branch": "develop"
},
{
"url": "https://github.com/frappe/cafe",
"branch": "develop"
},
{
"url": "https://git.enuxia.fr/Enuxia-Public/enuxia_einvoice.git",
"branch": "version-16"
}
]
+243
View File
@@ -0,0 +1,243 @@
{
"agriculture": {
"idx": 18,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "109c8815ff1cec73340b39cc66a946896f055804"
},
"url": "https://github.com/frappe/agriculture",
"version": "0.0.1"
},
"blog": {
"idx": 20,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "e9282d2843f26c3e41dc8769929a8e47d4d78e46"
},
"url": "https://github.com/frappe/blog",
"version": "0.0.1"
},
"builder": {
"idx": 14,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "804817d6f7545cc7165d184a00dcd9a2f9d64ca6"
},
"url": "https://github.com/frappe/builder",
"version": "1.0.0-dev"
},
"cafe": {
"idx": 21,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "dab234cce5f2cfc33bc50ae6809e816b680c0f28"
},
"url": "https://github.com/frappe/cafe",
"version": "0.0.1"
},
"crm": {
"idx": 12,
"is_repo": true,
"required": [],
"resolution": {
"branch": "main",
"commit_hash": "8cb85d7180256d674c268d2f66f14ac58c559721"
},
"url": "https://github.com/frappe/crm",
"version": "1.74.0"
},
"education": {
"idx": 9,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "80c7a6dc79f1d037d09c9ecda5d384bc880d1be6"
},
"url": "https://github.com/frappe/education",
"version": "16.0.1"
},
"enuxia_einvoice": {
"idx": 22,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "d91ad72a5bc5d070f69cf4be28bbacbce6c8f7a9"
},
"url": "https://git.enuxia.fr/Enuxia-Public/enuxia_einvoice.git",
"version": "0.0.1"
},
"erpnext": {
"idx": 2,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "d47aa4917a7eec2be2031b7dc94188c196c34cfa"
},
"url": "https://github.com/frappe/erpnext",
"version": "16.23.1"
},
"frappe": {
"idx": 1,
"required": [],
"resolution": {
"branch": "v16.23.1",
"commit_hash": "18d855b6bedc6ef17525c3eb27a6ae34471b1115"
},
"url": "https://github.com/frappe/frappe",
"version": "16.23.1"
},
"gameplan": {
"idx": 15,
"is_repo": true,
"required": [],
"resolution": {
"branch": "main",
"commit_hash": "30d4f16f9f2c5b18ad684252c61e851228943162"
},
"url": "https://github.com/frappe/gameplan",
"version": "0.0.1"
},
"healthcare": {
"idx": 3,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "9b4581f2422c3173a0ed89125b5b80880d0b26df"
},
"url": "https://github.com/earthians/marley",
"version": "16.2.0"
},
"helpdesk": {
"idx": 8,
"is_repo": true,
"required": [],
"resolution": {
"branch": "main",
"commit_hash": "47aaf15b1e9a78ec8a844e0fc43fac4d1869e03e"
},
"url": "https://github.com/frappe/helpdesk",
"version": "1.26.2"
},
"hrms": {
"idx": 4,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "1e1d96c1878ce46cfdc40bee36ee05d9cc13bd24"
},
"url": "https://github.com/frappe/hrms",
"version": "16.10.0"
},
"lms": {
"idx": 7,
"is_repo": true,
"required": [],
"resolution": {
"branch": "main",
"commit_hash": "863c50afac81703e3c8dac3b2d86e8fba91ef738"
},
"url": "https://github.com/frappe/lms",
"version": "2.56.1"
},
"meet": {
"idx": 13,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "4af1d4933f3ba5d15b51996e295da207bbfb91dd"
},
"url": "https://github.com/frappe/meet",
"version": "0.0.1"
},
"newsletter": {
"idx": 17,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "e5ed3645199104818c354617cb495cbdf90b94fe"
},
"url": "https://github.com/frappe/newsletter",
"version": "0.0.1"
},
"payments": {
"idx": 5,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "aa3516827fe51d5557975b74e574e3bca9a3070d"
},
"url": "https://github.com/frappe/payments",
"version": "0.0.1"
},
"print_designer": {
"idx": 16,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "e831894643c37ea07fb25c765e18cd34fc1c7711"
},
"url": "https://github.com/frappe/print_designer",
"version": "1.6.5"
},
"slides": {
"idx": 19,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "f66f430656e767b61831c54cbf12d673a6ebf3e4"
},
"url": "https://github.com/frappe/slides",
"version": "0.0.1"
},
"telephony": {
"idx": 6,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "58d32184e44b193e27498d3dd156085c793b7528"
},
"url": "https://github.com/frappe/telephony",
"version": "0.0.1"
},
"webshop": {
"idx": 11,
"is_repo": true,
"required": [],
"resolution": {
"branch": "version-16",
"commit_hash": "3afd734a495d5869f05a4f0ae36fc76330eb7734"
},
"url": "https://github.com/frappe/webshop",
"version": "0.0.1"
},
"wiki": {
"idx": 10,
"is_repo": true,
"required": [],
"resolution": {
"branch": "develop",
"commit_hash": "950a31e610b6b87bae726d600424bd16a83da895"
},
"url": "https://github.com/frappe/wiki",
"version": "3.0.0"
}
}
+372
View File
@@ -0,0 +1,372 @@
"""
Event notification handling for CRM.
This module handles event notifications for the CRM system, supporting both:
1. Custom event notifications set on individual events
2. Global default notifications from CRM Settings for events without custom notifications
The notification system processes events that are either:
- Starting from now or in the future (normal case)
- Currently in progress (between starts_on and ends_on) to handle delayed notifications
Global notifications are configured in CRM Settings under the Calendar tab and are applied
to events that don't have custom notifications set. This ensures all events can receive
reminders even if users don't configure them individually.
"""
from datetime import datetime, timedelta
import frappe
from frappe.utils import add_to_date, now_datetime
def trigger_offset_event_notifications():
"""Trigger event notifications for offset-based intervals (minutes)."""
_process_event_notifications_by_interval("minutes")
_process_event_notifications_by_interval("hours")
def trigger_hourly_event_notifications():
"""Trigger event notifications for hourly intervals."""
_process_event_notifications_by_interval("hours")
def trigger_daily_event_notifications():
"""Trigger event notifications for daily intervals."""
_process_event_notifications_by_interval("days")
def trigger_weekly_event_notifications():
"""Trigger event notifications for weekly intervals."""
_process_event_notifications_by_interval("weeks")
def _process_event_notifications_by_interval(interval):
"""
Process event notifications for a specific interval.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
"""
if frappe.flags.in_import or frappe.flags.in_patch:
return
current_time = now_datetime()
current_user = frappe.session.user
all_events_data = frappe.db.sql(
"""
SELECT
e.name as event_name,
e.subject,
e.starts_on,
e.ends_on,
e.owner,
e.description,
e.all_day as all_day_event,
en.type as notification_type,
en.before as before_value,
en.time as time_of_day,
en.interval as notification_interval,
ep.email as participant_email,
ep_all.participant_emails_csv,
CASE WHEN en.parent IS NULL THEN 0 ELSE 1 END as has_custom_notifications
FROM `tabEvent` e
LEFT JOIN `tabEvent Notifications` en ON e.name = en.parent AND en.interval = %s
LEFT JOIN `tabEvent Participants` ep ON e.name = ep.parent AND ep.email = %s
LEFT JOIN (
SELECT parent, GROUP_CONCAT(email) AS participant_emails_csv
FROM `tabEvent Participants`
GROUP BY parent
) AS ep_all ON ep_all.parent = e.name
WHERE (e.starts_on >= %s OR (%s >= e.starts_on AND %s < e.ends_on))
AND (e.owner = %s OR ep.email = %s)
AND e.status != 'Cancelled'
ORDER BY e.starts_on, e.name
""",
(interval, current_user, current_time, current_time, current_time, current_user, current_user),
as_dict=True,
)
for event_data in all_events_data:
participant_emails_csv = event_data.pop("participant_emails_csv", None)
event_data["event_participants"] = _split_participant_emails(participant_emails_csv)
notifications = _process_unified_event_data(all_events_data, interval)
for notification in notifications:
try:
event_start = notification.get("starts_on")
event_end = notification.get("ends_on")
before_value = notification.get("before_value", 1)
trigger_datetime = _calculate_trigger_datetime(
event_start,
before_value,
interval,
notification.get("all_day_event"),
notification.get("time_of_day"),
)
event_is_in_progress = event_start <= current_time < event_end
trigger_time_passed = current_time > trigger_datetime
if event_is_in_progress and trigger_time_passed:
trigger_window_start = trigger_datetime
trigger_window_end = event_end
else:
window_duration = _get_trigger_window_duration(interval)
trigger_window_start = add_to_date(
trigger_datetime, **{k: -v for k, v in window_duration.items()}
)
trigger_window_end = add_to_date(trigger_datetime, **window_duration)
if not (trigger_window_start <= current_time <= trigger_window_end):
continue
if notification.get("notification_type") == "Email":
_send_email_notification(notification, event_start, before_value, interval)
elif notification.get("notification_type") == "Notification":
_send_system_notification(notification)
except Exception as e:
frappe.log_error(
f"Error processing {interval} notification for event {notification.get('event_name', 'Unknown')}: {e!s}"
)
continue
def _process_unified_event_data(all_events_data, interval):
"""
Process unified event data that includes both events with and without custom notifications.
Args:
all_events_data (list): List of event data from the unified query
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
list: List of processed notifications ready for sending
"""
notifications = []
events_without_notifications = []
for event_data in all_events_data:
if event_data.get("has_custom_notifications") == 1:
notifications.append(event_data)
else:
event_key = event_data.get("event_name")
if not any(e.get("event_name") == event_key for e in events_without_notifications):
events_without_notifications.append(event_data)
if events_without_notifications:
global_notifications = _apply_global_notifications_to_events(events_without_notifications, interval)
notifications.extend(global_notifications)
return notifications
def _apply_global_notifications_to_events(events_without_notifications, interval):
"""
Apply global CRM Settings notifications to events that don't have custom notifications.
Args:
events_without_notifications (list): List of events without custom notifications
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
list: List of notification dictionaries using global settings
"""
fcrm_settings = frappe.get_single("FCRM Settings")
global_notifications = []
if hasattr(fcrm_settings, "event_notifications"):
for notification in fcrm_settings.event_notifications:
if notification.interval == interval:
notification._table_type = "regular"
global_notifications.append(notification)
if hasattr(fcrm_settings, "all_day_event_notifications"):
for notification in fcrm_settings.all_day_event_notifications:
if notification.interval == interval:
notification._table_type = "all_day"
global_notifications.append(notification)
if not global_notifications:
return []
notifications = []
for event in events_without_notifications:
for global_notification in global_notifications:
if (global_notification._table_type == "all_day" and not event.all_day_event) or (
global_notification._table_type == "regular" and event.all_day_event
):
continue
notification = {
"event_name": event.event_name,
"notification_type": global_notification.type,
"before_value": global_notification.before,
"time_of_day": global_notification.time,
"subject": event.subject,
"starts_on": event.starts_on,
"ends_on": event.ends_on,
"owner": event.owner,
"description": event.description,
"all_day_event": event.all_day_event,
"event_participants": event.get("event_participants", []),
}
notifications.append(notification)
return notifications
def _calculate_trigger_datetime(event_start, before_value, interval, all_day_event, time_of_day):
"""
Calculate when the notification should be triggered based on event start time and interval.
Args:
event_start (datetime): Event start datetime
before_value (int): How many units before the event
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
all_day_event (bool): Whether this is an all-day event
time_of_day (time): Specific time to send notification for all-day events
Returns:
datetime: When the notification should be triggered
"""
if all_day_event and time_of_day and interval in ["days", "weeks"]:
if interval == "days":
trigger_date = event_start.date() - timedelta(days=before_value)
elif interval == "weeks":
trigger_date = event_start.date() - timedelta(weeks=before_value)
trigger_datetime = datetime.combine(trigger_date, time_of_day)
else:
interval_kwargs = _get_interval_kwargs(interval, before_value)
trigger_datetime = add_to_date(event_start, **interval_kwargs)
return trigger_datetime
def _get_interval_kwargs(interval, before_value):
"""
Get the appropriate keyword arguments for add_to_date based on interval type.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
before_value (int): How many units before the event
Returns:
dict: Keyword arguments for add_to_date with negative values
"""
interval_mapping = {
"minutes": {"minutes": -before_value},
"hours": {"hours": -before_value},
"days": {"days": -before_value},
"weeks": {"weeks": -before_value},
}
return interval_mapping.get(interval, {"hours": -before_value})
def _get_trigger_window_duration(interval):
"""
Get the trigger window duration based on interval type.
Args:
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
dict: Window duration to be used symmetrically around the trigger time
"""
window_mapping = {
"minutes": {"minutes": 5},
"hours": {"hours": 1},
"days": {"hours": 8},
"weeks": {"days": 4},
}
return window_mapping.get(interval, {"hours": 1})
def _split_participant_emails(participant_emails_csv):
"""Return a clean list of participant emails from a comma-separated string."""
if not participant_emails_csv:
return []
return [email.strip() for email in participant_emails_csv.split(",") if email and email.strip()]
def _send_email_notification(notification, event_start, before_value, interval):
"""Send email notification for an event"""
try:
recipients = set()
subject = f"Event Reminder: {notification.subject}"
if notification.owner and notification.owner != "Administrator":
recipients.add(notification.owner)
participant_emails = notification.get("event_participants") or []
if participant_emails:
recipients.update(participant_emails)
else:
event_doc = frappe.get_doc("Event", notification.event_name)
for participant in event_doc.get("event_participants", []):
email = getattr(participant, "email", None)
if email:
recipients.add(email)
recipients = [email for email in recipients if email]
if not recipients:
return
time_remaining_text = _format_time_remaining(before_value, interval)
message = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<h2 style="color: #333;">Event Reminder</h2>
<p>This is a reminder for your upcoming event:</p>
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #007bff; margin: 20px 0;">
<h3 style="margin: 0; color: #007bff;">{notification.subject}</h3>
{f'<p style="margin: 10px 0; color: #666;">{notification.description}</p>' if notification.description else ""}
<p style="margin: 5px 0;"><strong>Start Time:</strong> {event_start.strftime("%Y-%m-%d %H:%M:%S")}</p>
<p style="margin: 5px 0;"><strong>Time Remaining:</strong> {time_remaining_text}</p>
</div>
<p style="color: #666; font-size: 12px;">This is an automated reminder from your calendar system.</p>
</div>
"""
frappe.sendmail(
recipients=recipients,
subject=subject,
message=message,
reference_doctype="Event",
reference_name=notification.event_name,
now=True,
)
except Exception as e:
frappe.log_error(f"Failed to send email for event {notification.event_name}: {e!s}")
def _format_time_remaining(before_value, interval):
"""
Format the time remaining message based on the interval and before value.
Args:
before_value (int): The number of units before the event
interval (str): The interval type ('minutes', 'hours', 'days', 'weeks')
Returns:
str: Formatted time remaining message
"""
interval_labels = {"minutes": "minute(s)", "hours": "hour(s)", "days": "day(s)", "weeks": "week(s)"}
interval_label = interval_labels.get(interval, "unit(s)")
return f"{before_value} {interval_label}"
def _send_system_notification(notification):
"""Send system notification for an event"""
frappe.publish_realtime("event_notification", notification)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
ASSETS_PATH="/home/frappe/frappe-bench/sites/assets"
BAKED_PATH="/home/frappe/frappe-bench/assets"
echo "Linking fresh assets to volume..."
rm -rf "$ASSETS_PATH"
mkdir -p "$(dirname "$ASSETS_PATH")"
ln -s "$BAKED_PATH" "$ASSETS_PATH"
exec "$@"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -e
#Gunicorn defaults
GUNICORN_THREADS=${GUNICORN_THREADS:-4}
GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-120}
echo "Booting Gunicorn with $GUNICORN_WORKERS workers and $GUNICORN_THREADS threads..."
exec /home/frappe/frappe-bench/env/bin/gunicorn \
--chdir=/home/frappe/frappe-bench/sites \
--bind=0.0.0.0:8000 \
--threads="$GUNICORN_THREADS" \
--workers="$GUNICORN_WORKERS" \
--worker-class=gthread \
--worker-tmp-dir=/dev/shm \
--timeout="$GUNICORN_TIMEOUT" \
--preload \
frappe.app:application