commit 7807f97fb35a923946fc03feb4bf7af9cbf1413c Author: Julien Denizot Date: Mon Jun 22 21:38:47 2026 +0200 release: publish Enuxia Suite v16 ARM64 R3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d2e9e6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea437f5 --- /dev/null +++ b/README.md @@ -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. diff --git a/images/Containerfile.v16-prod b/images/Containerfile.v16-prod new file mode 100644 index 0000000..2a9b246 --- /dev/null +++ b/images/Containerfile.v16-prod @@ -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"] diff --git a/manifests/apps.v16.dev.json b/manifests/apps.v16.dev.json new file mode 100644 index 0000000..1072bb7 --- /dev/null +++ b/manifests/apps.v16.dev.json @@ -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" + } +] diff --git a/manifests/apps.v16.prod.json b/manifests/apps.v16.prod.json new file mode 100644 index 0000000..a20d8e7 --- /dev/null +++ b/manifests/apps.v16.prod.json @@ -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" + } +] diff --git a/manifests/apps.v16.prod.lock.json b/manifests/apps.v16.prod.lock.json new file mode 100644 index 0000000..2548687 --- /dev/null +++ b/manifests/apps.v16.prod.lock.json @@ -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" + } +} diff --git a/patches/crm/event.py b/patches/crm/event.py new file mode 100644 index 0000000..587247f --- /dev/null +++ b/patches/crm/event.py @@ -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""" +
+

Event Reminder

+

This is a reminder for your upcoming event:

+
+

{notification.subject}

+ {f'

{notification.description}

' if notification.description else ""} +

Start Time: {event_start.strftime("%Y-%m-%d %H:%M:%S")}

+

Time Remaining: {time_remaining_text}

+
+

This is an automated reminder from your calendar system.

+
+ """ + + 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) diff --git a/resources/core/main-entrypoint.sh b/resources/core/main-entrypoint.sh new file mode 100755 index 0000000..d1e8960 --- /dev/null +++ b/resources/core/main-entrypoint.sh @@ -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 "$@" diff --git a/resources/core/start.sh b/resources/core/start.sh new file mode 100755 index 0000000..2ac1a38 --- /dev/null +++ b/resources/core/start.sh @@ -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