release: publish Enuxia Suite v16 ARM64 R3
This commit is contained in:
+26
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Executable
+12
@@ -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 "$@"
|
||||
Executable
+20
@@ -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
|
||||
Reference in New Issue
Block a user