diff --git a/addons_extensions/firebase_integration/__init__.py b/addons_extensions/firebase_integration/__init__.py new file mode 100644 index 000000000..e47d26c5d --- /dev/null +++ b/addons_extensions/firebase_integration/__init__.py @@ -0,0 +1,2 @@ +from . import tools +from . import models diff --git a/addons_extensions/firebase_integration/__manifest__.py b/addons_extensions/firebase_integration/__manifest__.py new file mode 100644 index 000000000..1e81d8681 --- /dev/null +++ b/addons_extensions/firebase_integration/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Firebase Integration", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "IdeaCode Academy", + "license": "LGPL-3", + "summary": "Integrate Firebase services and push notifications with Odoo", + "depends": ["base", "bus","mail"], + "external_dependencies": { + "python": ["firebase-admin"], + }, + "data": [ + "views/res_users.xml", + ], + "images": ["static/description/banner.png"], + "installable": True, + "application": False, + "auto_install": False, + "price": 0.0, + "currency": "USD", +} diff --git a/addons_extensions/firebase_integration/hrmsopzento-firebase-adminsdk-fbsvc-60336fd855.json b/addons_extensions/firebase_integration/hrmsopzento-firebase-adminsdk-fbsvc-60336fd855.json new file mode 100644 index 000000000..033490c8c --- /dev/null +++ b/addons_extensions/firebase_integration/hrmsopzento-firebase-adminsdk-fbsvc-60336fd855.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "hrmsopzento", + "private_key_id": "60336fd85549183b6c890121aaa3f08256d3f0a4", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZn3agMptdcJ7n\noZIS1E0x/3PBB0uPVljoQRrqDwwEPtwKC+Sh9DY/eiXbvXyQGfzH3LkToVgvNPCY\nsFRfPnGtp9W45bwYOi0qXc8MWzYiBLRfO663hOjh90AfTEgE02wD3DOWo9eFlzMn\nJMPrOZUD0fNpzQFXQ7o8ufdrCS40d4hDQNiMGSx2hPPOnmJioz2GOr1ABhxNFJQY\nv53L1PEP1kCEnoQcCKfpuMPNY2ApzlIEndLoBhr1W5RsHp9web22CukqtJYzVCbw\naWJymZQFSkos5rmCSCvkaTvtEYBkl//4t++WjbNSxL8C48EH1O97Hg4djYULnL1D\n/GmSIYibAgMBAAECggEAVHkYk+Bw/Fk95U2LJPHxsQmmhfPt+Yqb4jN7XgVPNcqs\noN2y9saT1Bn23g/0bP8ZZv8ffCYx08kp5yry5TGY8L5oMGhElebnJz3Yo8Q4BAZt\neVXyYNwfha7y3fM/NVhX4ju0brHUc8+YFIap4gGs/Rme8Z+Y+KWagf3xs0OSAtz3\n0d6rOsMl7UfJojChrx70CrwB0y+O+3vbD+IzInWq0RBjcu78VqCiUP+X6BuKmSgN\n2qp61FrW6eNgY1M6thL2UZeDke5hEfLcjWcdSskTj7/yONErzgFiT754IiWXMGPd\n0gGKBAJKkmmBy1stGR/5JoaqV8fVt8GFypm1LZcJEQKBgQD1EEJgyV6IyPWGzu8m\nYhW80zSQ0fQxPX9Y62UP52TecUY5qMvMOmBdec8LE18IKIRgBesCvtdtv2am0rLs\nXg/1Yrfm7AsR/eEYRY3E/8Oyk+XVteUxGR6ZgAIUSLXAsnsfx3/5CkqVTszASime\nvK2httSvmHwWaWvALnAFv3oncwKBgQDjVbUa/jBxf4N2Hcf4oYTJSfkTWa2Llwvm\n63yjcPaqm6HS1psgPd4ZiITEg4UOYpL8Cngb5hqRRIusZGatdBxkUi3AunDuOORL\nYGcn2Zka3aXtTqXzXyhXR3aQH3m2wn7KGDUs4lcoUC/x30UQKqNpWvIpfelLdNgA\n5ZyniSZAOQKBgQDu0Kt/Gn3Pmtb6SorvwsIgQ0qEnrXzjlSd2Leh6gN4arbe1cnU\n+kaSkXPc/UGs958Y3GuLP2M9BjsI82d9xKSUo2FH3ltjax+CwbVId17EljByNVJm\nqG4TdJWSItFMOiKWc5oYnZjVK/eIpD0u/fvPDhbyEA1M4espW5e7Yj+uVQKBgC2g\n7ELItixxrY8tlw9+S8qjAE0z+LNF0+u7ZD7h04CW0DojPOuRv1xcnFldFH24p0vT\nRhxDaR2zJl2poTo7Td+M5wYB5dzKqne+l7XV5PcRedZRrNlWRiCOhWuUBbf6/bvO\ndA3YOCotPhJL/+6owDfLO0O8s/CjOR+k9nZh/r1xAoGAbJJoQhBrF8sCHuTi9Hev\nRrwKnL8Ck5HPB19SkifWA9lg604w2LfRJ1ijOKSm9G1ADLT5a6XfsU7tM/6oG5Np\n3tBxhgEpX03+NS3cwRgYGdzVNS+X5D/9uduCiN0Y4cDUjyin1OT+acOOhd0YtdGe\n9Rp7Ae9GrOKPQ5GvRazbmOI=\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@hrmsopzento.iam.gserviceaccount.com", + "client_id": "111625966108394286408", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40hrmsopzento.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/addons_extensions/firebase_integration/models/discuss_channel_notify.py b/addons_extensions/firebase_integration/models/discuss_channel_notify.py new file mode 100644 index 000000000..0f22c6b26 --- /dev/null +++ b/addons_extensions/firebase_integration/models/discuss_channel_notify.py @@ -0,0 +1,155 @@ +from odoo import models +import logging +import re + +_logger = logging.getLogger(__name__) + + +class DiscussChannelFirebaseNotify(models.Model): + _inherit = "discuss.channel" + + def _notify_thread(self, message, msg_vals=False, **kwargs): + """ + Extend Odoo Discuss notifications with Firebase push notifications. + """ + res = super()._notify_thread( + message, + msg_vals=msg_vals, + **kwargs + ) + + try: + recipients = self._notify_get_recipients( + message, + msg_vals or {}, + **kwargs + ) + + _logger.info( + "Firebase notification processing. Channel=%s Message=%s Recipients=%s", + self.display_name, + message.id, + len(recipients), + ) + + notifications = [] + + for recipient in recipients: + user = False + + # Direct user recipient + if recipient.get("uid"): + user = ( + self.env["res.users"] + .sudo() + .browse(recipient["uid"]) + ) + + # Channel member recipient (uid=False) + elif recipient.get("id"): + partner = ( + self.env["res.partner"] + .sudo() + .browse(recipient["id"]) + ) + + user = partner.user_ids[:1] + + if not user: + continue + + token = user.token + + if not token: + _logger.info( + "Skipping user %s: no token", + user.name, + ) + continue + + # Record name + record_name = ( + msg_vals.get("record_name") + if msg_vals and isinstance(msg_vals, dict) + else message.record_name + ) + + # Author + author = message.author_id + + if self.channel_type == "chat": + title = author.name or "New Message" + + elif self.channel_type == "channel": + title = ( + f"#{record_name} - {author.name}" + if record_name + else author.name + ) + + elif self.channel_type == "group": + title = ( + f"{record_name} - {author.name}" + if record_name + else author.name + ) + + else: + title = record_name or author.name or "New Message" + + # Remove html tags + body_html = ( + msg_vals.get("body") + if msg_vals and msg_vals.get("body") + else message.body + ) + + body = re.sub( + r"<[^>]+>", + "", + body_html or "", + ).strip() + + payload = { + "action": "mail.action_discuss", + "channel_id": str(self.id), + "message_id": str(message.id), + } + + if self.channel_type: + payload["channel_type"] = self.channel_type + + notifications.append({ + "token": token, + "title": title, + "body": body, + "data": payload, + }) + + _logger.info( + "Prepared Firebase notification for user=%s", + user.name, + ) + + if notifications: + _logger.info( + "Sending %s Firebase notifications", + len(notifications), + ) + + self.env["firebase.service"].send_notification( + notifications + ) + + else: + _logger.info( + "No Firebase notifications to send" + ) + + except Exception as exc: + _logger.exception( + "Firebase notification error: %s", + exc + ) + + return res \ No newline at end of file diff --git a/addons_extensions/firebase_integration/static/description/banner.png b/addons_extensions/firebase_integration/static/description/banner.png new file mode 100644 index 000000000..a7b53e291 Binary files /dev/null and b/addons_extensions/firebase_integration/static/description/banner.png differ diff --git a/addons_extensions/firebase_integration/static/description/icon.png b/addons_extensions/firebase_integration/static/description/icon.png new file mode 100644 index 000000000..16dd80994 Binary files /dev/null and b/addons_extensions/firebase_integration/static/description/icon.png differ diff --git a/addons_extensions/firebase_integration/static/description/index.html b/addons_extensions/firebase_integration/static/description/index.html new file mode 100644 index 000000000..a175cea46 --- /dev/null +++ b/addons_extensions/firebase_integration/static/description/index.html @@ -0,0 +1,21 @@ +
+
+

Firebase Integration

+ +

Configuration

+

Add the following key to your odoo.conf file:

+
[options]
+firebase_private_key_path = /home/documents/cre.json
+ +

Usage

+

Import and call the function anywhere within your backend Odoo modules:

+
from odoo.addons.firebase_integration.tools.firebase import send_firebase_notifications
+
+msg = [
+    {'token': 'DEVICE_TOKEN_1', 'body': 'Notification Body', 'title': 'Notification Title'},
+    {'token': 'DEVICE_TOKEN_2', 'body': 'Another Body', 'title': 'Another Title'}
+]
+
+success_sent_message_count = send_firebase_notifications(msg)
+
+
diff --git a/addons_extensions/firebase_integration/tools/__init__.py b/addons_extensions/firebase_integration/tools/__init__.py new file mode 100644 index 000000000..40dee207e --- /dev/null +++ b/addons_extensions/firebase_integration/tools/__init__.py @@ -0,0 +1,2 @@ +from . import firebase +from . import firebase_service \ No newline at end of file diff --git a/addons_extensions/firebase_integration/tools/firebase.py b/addons_extensions/firebase_integration/tools/firebase.py new file mode 100644 index 000000000..b4744e924 --- /dev/null +++ b/addons_extensions/firebase_integration/tools/firebase.py @@ -0,0 +1,109 @@ +import logging +import firebase_admin +from firebase_admin import credentials, messaging +from odoo.tools import config + +# Fetch the credential file path safely from odoo.conf +firebase_key_path = config.get("firebase_private_key_path") +_logger = logging.getLogger(__name__) + +# Initialize the app safely to prevent "app already exists" runtime crashes +try: + firebase_app = firebase_admin.get_app() +except ValueError: + if firebase_key_path: + firebase_cred = credentials.Certificate(firebase_key_path) + firebase_app = firebase_admin.initialize_app(firebase_cred) + else: + # Fallback to default application credentials if config key is missing + firebase_app = firebase_admin.initialize_app() + + +def send_firebase_notifications(messages: list) -> int: + """Sends list of notification messages using Firebase Cloud Messaging. + + Expecting input layout: + [ + {'token': '...', 'body': '...', 'title': '...'}, + ] + """ + if not messages: + return 0 + + # Build the payload using native Firebase messaging objects + fcm_messages = [] + for msg in messages: + token = msg.get("token") + if not token: + continue + notification = messaging.Notification( + title=msg.get("title", ""), + body=msg.get("body", ""), + ) + # include optional data payload + data_payload = msg.get("data") + fcm_messages.append( + messaging.Message( + notification=notification, + token=token, + data=data_payload if isinstance(data_payload, dict) else None, + ) + ) + + if not fcm_messages: + return 0 + + # Execute the batch transmission using the Firebase Admin SDK + try: + # The SDK / FCM backend limits batch sizes; be conservative and chunk (500 is a safe upper bound). + success_total = 0 + chunk_size = 500 + + for i in range(0, len(fcm_messages), chunk_size): + batch = fcm_messages[i : i + chunk_size] + + # Try newer API first (send_all), fall back to send_multicast for older SDK versions + if hasattr(messaging, 'send_all'): + response = messaging.send_all(batch) + _logger.info( + "Firebase Success Count: %s", + response.success_count + ) + + _logger.info( + "Firebase Failure Count: %s", + response.failure_count + ) + + for idx, resp in enumerate(response.responses): + if not resp.success: + _logger.error( + "Firebase Error %s: %s", + idx, + resp.exception + ) + else: + # Older firebase-admin SDK: use send_multicast + from firebase_admin.messaging import MulticastMessage + multicast = MulticastMessage(tokens=[m.token for m in batch if m.token]) + # send_multicast doesn't support per-token custom payloads; use individual sends as fallback + success_count = 0 + for msg in batch: + try: + messaging.send(msg) + success_count += 1 + except Exception as e: + _logger.debug("Failed to send message to token %s: %s", msg.token, e) + response = type('obj', (object,), {'success_count': success_count})() + + sent = getattr(response, "success_count", 0) + success_total += sent + _logger.info("Firebase: sent %s/%s messages in chunk", sent, len(batch)) + return success_total + except Exception as exc: # catch firebase exceptions and log them + _logger.exception("Failed to send firebase notifications: %s", exc) + return 0 + + + +