Files
2026-09-18 13:55:25 +07:00

556 lines
22 KiB
Python

# -*- coding: utf-8 -*-
import datetime
import json
import logging
import requests
from odoo import api, fields, models, release
from odoo.exceptions import UserError
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
MODULE_VERSION = "18.0.2.0.3"
# Defaults mirrored from data/nextzen_subscription_ir_config_parameter_data.xml
_DEFAULT_ICP = (
("nextzen.subscription.server_url", "https://nextzenvn.com", "icp_server_url"),
("nextzen.subscription.api_endpoint", "nz-subscription", "icp_api_endpoint"),
(
"nextzen.subscription.mandatory_validation",
"False",
"icp_mandatory",
),
)
_CRON_XMLID = "web_responsive.ir_cron_nextzen_subscription_heartbeat"
_CRON_CODE = "model.update_notification(cron_mode=True)"
class NextzenSubscriptionContract(models.AbstractModel):
_name = "nextzen.subscription.contract"
_description = "NextZen Subscription Contract"
@api.model
def _ensure_xmlid(self, xmlid, record, noupdate=True):
"""Bind or refresh an xmlid on ``record`` (idempotent)."""
if not record:
return
self.env["ir.model.data"].sudo()._update_xmlids(
[
{
"xml_id": xmlid,
"record": record,
"noupdate": noupdate,
}
]
)
@api.model
def _ensure_defaults(self):
"""Recreate missing Server URL / endpoint / validation ICP and heartbeat cron.
Safe after a full ``nextzen.subscription.*`` wipe — does not restore
``allow_http``, codes, or status (those stay intentional blanks until
register/heartbeat). Idempotent and cheap enough for cron + session.
"""
ICP = self.env["ir.config_parameter"].sudo()
restored = []
for key, default, xml_name in _DEFAULT_ICP:
current = (ICP.get_param(key) or "").strip()
if current:
continue
ICP.set_param(key, default)
param = ICP.search([("key", "=", key)], limit=1)
self._ensure_xmlid(f"web_responsive.{xml_name}", param, noupdate=True)
restored.append(key)
cron = self._ensure_heartbeat_cron()
if restored or cron is True:
_logger.info(
"NextZen subscription defaults restored: icp=%s cron=%s",
restored or "ok",
"recreated" if cron is True else "ok",
)
return True
@api.model
def _ensure_heartbeat_cron(self):
"""Ensure weekly heartbeat cron exists. Returns True if newly created."""
Cron = self.env["ir.cron"].sudo()
cron = self.env.ref(_CRON_XMLID, raise_if_not_found=False)
if cron:
return False
model = (
self.env["ir.model"]
.sudo()
.search([("model", "=", "nextzen.subscription.contract")], limit=1)
)
if not model:
_logger.warning(
"NextZen subscription: cannot recreate cron (model missing)."
)
return False
cron = Cron.search(
[
("model_id", "=", model.id),
("state", "=", "code"),
("code", "ilike", "update_notification"),
],
limit=1,
)
created = False
if not cron:
cron = Cron.create(
{
"name": "NextZen Subscription: Heartbeat",
"model_id": model.id,
"state": "code",
"code": _CRON_CODE,
"interval_number": 1,
"interval_type": "weeks",
"active": True,
"priority": 1000,
}
)
created = True
_logger.info("NextZen subscription: recreated heartbeat cron id=%s", cron.id)
self._ensure_xmlid(_CRON_XMLID, cron, noupdate=True)
return created
@api.model
def _get_message(self):
"""Build heartbeat payload derived from upstream publisher_warranty._get_message."""
Users = self.env["res.users"]
ICP = self.env["ir.config_parameter"].sudo()
dbuuid = ICP.get_param("database.uuid")
db_create_date = ICP.get_param("database.create_date")
limit_date = fields.Datetime.now() - datetime.timedelta(days=15)
nbr_users = Users.search_count([("active", "=", True)])
nbr_active_users = Users.search_count(
[("login_date", ">=", limit_date), ("active", "=", True)]
)
nbr_share_users = 0
nbr_active_share_users = 0
if "share" in Users._fields:
nbr_share_users = Users.search_count(
[("share", "=", True), ("active", "=", True)]
)
nbr_active_share_users = Users.search_count(
[
("share", "=", True),
("login_date", ">=", limit_date),
("active", "=", True),
]
)
domain = [
("application", "=", True),
("state", "in", ["installed", "to upgrade", "to remove"]),
]
apps = self.env["ir.module.module"].sudo().search_read(domain, ["name"])
msg = {
"dbuuid": dbuuid,
"nbr_users": nbr_users,
"nbr_active_users": nbr_active_users,
"nbr_share_users": nbr_share_users,
"nbr_active_share_users": nbr_active_share_users,
"dbname": self._cr.dbname,
"db_create_date": db_create_date,
"version": release.version,
"language": self.env.user.lang,
"web_base_url": ICP.get_param("web.base.url"),
"apps": [app["name"] for app in apps],
"subscription_code": ICP.get_param("nextzen.subscription.code") or "",
"client_module_version": MODULE_VERSION,
"action": "update",
}
company = self.env.company
if company:
msg["company"] = {
"name": company.name or "",
"email": company.email or "",
"phone": company.phone or "",
}
else:
msg["company"] = {}
return msg
@api.model
def _server_url(self):
self._ensure_defaults()
ICP = self.env["ir.config_parameter"].sudo()
# Prefer server_url; fall back to legacy datacenter_url after module rename
base = (
ICP.get_param("nextzen.subscription.server_url")
or ICP.get_param("nextzen.subscription.datacenter_url")
or ""
).rstrip("/")
endpoint = (ICP.get_param("nextzen.subscription.api_endpoint") or "").strip("/")
allow_http = ICP.get_param("nextzen.subscription.allow_http") in (
"1",
"True",
"true",
)
if not base or not endpoint:
raise UserError(
_("Configure Server URL and API endpoint in Settings.")
)
if base.startswith("http://") and not allow_http:
raise UserError(
_("Server URL must use HTTPS (or enable allow_http for debug).")
)
if not (base.startswith("https://") or (allow_http and base.startswith("http://"))):
raise UserError(_("Server URL must start with https://"))
return f"{base}/{endpoint}/subscriptionHeartbeat"
@api.model
def _post_heartbeat(self, payload):
"""POST heartbeat to public Server API (no API key)."""
url = self._server_url()
headers = {
"Content-Type": "application/json",
"Accept": "application/vnd.api+json, application/json",
}
response = requests.post(url, json=payload, headers=headers, timeout=30)
response.raise_for_status()
try:
body = response.json()
except ValueError as exc:
raise UserError(_("Invalid JSON response from Server.")) from exc
# EKIKA studio wraps in data
if isinstance(body, dict) and "data" in body and isinstance(body["data"], dict):
return body["data"]
return body
@api.model
def _apply_subscription_info(self, info):
"""Apply Server subscription_info to local ICP (success or expired/blocked)."""
if not info:
return
set_param = self.env["ir.config_parameter"].sudo().set_param
if info.get("subscription_code"):
set_param("nextzen.subscription.code", info["subscription_code"])
if "expiration_date" in info:
set_param(
"nextzen.subscription.expiration_date",
info.get("expiration_date") or "",
)
if "expiration_reason" in info:
set_param(
"nextzen.subscription.expiration_reason",
info.get("expiration_reason") or "",
)
if "status" in info:
set_param("nextzen.subscription.status", info.get("status") or "")
set_param(
"nextzen.subscription.last_check",
fields.Datetime.to_string(fields.Datetime.now()),
)
if info.get("feature_flags") is not None:
set_param(
"nextzen.subscription.feature_flags",
json.dumps(info.get("feature_flags") or {}),
)
@api.model
def _days_left(self, expiration_date_str=None):
"""Days until expiration (negative if already past). Match Enterprise Math.round."""
ICP = self.env["ir.config_parameter"].sudo()
raw = expiration_date_str
if raw is None:
raw = ICP.get_param("nextzen.subscription.expiration_date") or ""
if not raw:
return None
try:
exp = fields.Datetime.to_datetime(raw)
except (TypeError, ValueError):
return None
if not exp:
return None
delta = exp - fields.Datetime.now()
# round() so ~23h remaining ⇒ 1 day (floor wrongly showed 0 ⇒ "expired")
return int(round(delta.total_seconds() / 86400.0))
@api.model
def _deferred_trial_register(self):
"""When local code is missing, register with Server (throttled).
Server checks ``dbuuid``:
- unknown → create trial customer + NZ- code + installation, return info
- known → reuse existing subscription (``trial_reused``)
Agent always persists ``subscription_info`` via ``_apply_subscription_info``.
"""
ICP = self.env["ir.config_parameter"].sudo()
if (ICP.get_param("nextzen.subscription.code") or "").strip():
return True
# Avoid hammering Server if status is polled while offline (~1/min)
raw_attempt = ICP.get_param("nextzen.subscription.last_register_attempt") or ""
if raw_attempt:
try:
last = fields.Datetime.to_datetime(raw_attempt)
if last and (fields.Datetime.now() - last).total_seconds() < 60:
return False
except (TypeError, ValueError):
pass
ICP.set_param(
"nextzen.subscription.last_register_attempt",
fields.Datetime.to_string(fields.Datetime.now()),
)
return self.register_notification()
@api.model
def register_notification(self):
"""First-install / no-code register against NextZen Server.
Sends ``action=register`` with empty ``subscription_code`` and local
``database.uuid``. Server:
1. If an installation already exists for this dbuuid → reuse that
subscription code (idempotent restore after ICP wipe / reinstall).
2. Else → create a new trial subscription + installation, return the
new NZ- code.
On success, saves code / status / expiration into agent ICP.
Soft-fails (returns False) if Server is unreachable.
"""
self._ensure_defaults()
ICP = self.env["ir.config_parameter"].sudo()
existing = (ICP.get_param("nextzen.subscription.code") or "").strip()
if existing:
return self.update_notification(cron_mode=True)
dbuuid = (ICP.get_param("database.uuid") or "").strip()
if not dbuuid:
_logger.warning(
"NextZen trial register skipped: database.uuid is missing."
)
ICP.set_param(
"nextzen.subscription.last_error",
"database.uuid missing; cannot register trial.",
)
ICP.set_param("nextzen.subscription.last_error_code", "no_dbuuid")
return False
try:
payload = self._get_message()
payload["subscription_code"] = ""
payload["dbuuid"] = dbuuid
payload["action"] = "register"
result = self._post_heartbeat(payload)
if result.get("subscription_info"):
self._apply_subscription_info(result["subscription_info"])
if result.get("ok") and result.get("subscription_info"):
ICP.set_param("nextzen.subscription.last_error", "")
ICP.set_param("nextzen.subscription.last_error_code", "")
ICP.set_param("nextzen.subscription.already_linked", "")
ICP.set_param("nextzen.subscription.last_register_attempt", "")
code = result["subscription_info"].get("subscription_code") or ""
reused = bool(result.get("trial_reused"))
_logger.info(
"NextZen trial register ok code=%s dbuuid=%s reused=%s",
code[:16],
dbuuid[:8],
reused,
)
return True
error = result.get("error") or {}
ICP.set_param(
"nextzen.subscription.last_error",
error.get("message") or "trial register failed",
)
ICP.set_param(
"nextzen.subscription.last_error_code",
error.get("code") or "error",
)
ICP.set_param(
"nextzen.subscription.last_check",
fields.Datetime.to_string(fields.Datetime.now()),
)
return False
except Exception as exc:
_logger.warning("NextZen trial register failed: %s", exc)
ICP.set_param("nextzen.subscription.last_error", str(exc)[:500])
ICP.set_param(
"nextzen.subscription.last_check",
fields.Datetime.to_string(fields.Datetime.now()),
)
return False
@api.model
def update_notification(self, cron_mode=True):
"""
Send heartbeat to NextZen Server and apply subscription_info locally.
Mirrors Odoo publisher_warranty: always apply subscription_info when the
Server returns it (including expired/blocked), then let the UI decide
from ICP status / expiration_date. Never hard-locks the ERP.
Note: the web client may call this as ``update_notification([])`` (Enterprise
pattern with args ``[[]]``); treat list/tuple as cron_mode=False.
"""
# Enterprise UI passes [[]] → first positional arg is [] (falsy / non-bool)
if isinstance(cron_mode, (list, tuple)):
cron_mode = False
else:
cron_mode = bool(cron_mode)
self._ensure_defaults()
ICP = self.env["ir.config_parameter"].sudo()
# Soft business outcomes — update ICP, do not raise (Enterprise-style)
soft_error_codes = {"expired", "blocked", "already_linked"}
try:
code = (ICP.get_param("nextzen.subscription.code") or "").strip()
if not code:
# No code yet: attempt trial register (install / cron / check)
return self.register_notification()
payload = self._get_message()
result = self._post_heartbeat(payload)
# Always apply subscription_info when present (ok or expired/blocked)
if result.get("subscription_info"):
self._apply_subscription_info(result["subscription_info"])
if result.get("ok") and result.get("subscription_info"):
set_param = ICP.set_param
set_param("nextzen.subscription.last_error", "")
set_param("nextzen.subscription.last_error_code", "")
set_param("nextzen.subscription.already_linked", "")
return True
error = result.get("error") or {}
err_code = error.get("code") or "error"
set_param = ICP.set_param
set_param("nextzen.subscription.last_error", error.get("message") or "error")
set_param("nextzen.subscription.last_error_code", err_code)
set_param(
"nextzen.subscription.last_check",
fields.Datetime.to_string(fields.Datetime.now()),
)
if error.get("already_linked"):
set_param(
"nextzen.subscription.already_linked",
json.dumps(error["already_linked"]),
)
# Ensure local status matches Server when info was missing
if err_code in ("expired", "blocked") and not result.get("subscription_info"):
set_param("nextzen.subscription.status", err_code)
if err_code == "expired":
set_param(
"nextzen.subscription.expiration_reason",
ICP.get_param("nextzen.subscription.expiration_reason")
or "expired",
)
if err_code in soft_error_codes:
# Soft fail: ICP updated; UI reads status (like Enterprise expiration panel)
return False
if not cron_mode:
raise UserError(error.get("message") or _("Subscription check failed."))
return False
except UserError:
raise
except Exception as exc:
_logger.warning("NextZen subscription heartbeat failed: %s", exc)
ICP.set_param(
"nextzen.subscription.last_error",
str(exc)[:500],
)
ICP.set_param(
"nextzen.subscription.last_check",
fields.Datetime.to_string(fields.Datetime.now()),
)
if cron_mode:
return False
raise UserError(
_("Error communicating with NextZen Server: %s") % exc
) from exc
@api.model
def submit_code(self, code):
"""Validate and store a subscription code (rollback on invalid_code)."""
code = (code or "").strip()
if not code:
raise UserError(_("Please enter a subscription code."))
ICP = self.env["ir.config_parameter"].sudo()
previous = (ICP.get_param("nextzen.subscription.code") or "").strip()
ICP.set_param("nextzen.subscription.code", code)
try:
ok = self.update_notification(cron_mode=True)
except Exception:
ICP.set_param("nextzen.subscription.code", previous)
raise
err = (ICP.get_param("nextzen.subscription.last_error_code") or "").strip()
if err == "invalid_code":
ICP.set_param("nextzen.subscription.code", previous)
return False
if err == "already_linked":
# Keep attempted code so UI can show already_linked + allow retry/new code
return False
return bool(ok)
@api.model
def get_client_status(self):
self._ensure_defaults()
# First install / wiped ICP: obtain or restore code via Server dbuuid lookup
self._deferred_trial_register()
ICP = self.env["ir.config_parameter"].sudo()
expiration_date = ICP.get_param("nextzen.subscription.expiration_date") or ""
status = ICP.get_param("nextzen.subscription.status") or ""
last_error_code = ICP.get_param("nextzen.subscription.last_error_code") or ""
days_left = self._days_left(expiration_date)
exp_dt = False
if expiration_date:
try:
exp_dt = fields.Datetime.to_datetime(expiration_date)
except (TypeError, ValueError):
exp_dt = False
# Prefer Server-confirmed active while expiration is still in the future
if status == "blocked" or last_error_code == "blocked":
status = "blocked"
elif status == "active" and exp_dt and exp_dt > fields.Datetime.now():
status = "active"
# Drop stale expired error left over before a renew/recheck
if last_error_code == "expired":
last_error_code = ""
elif (
status == "expired"
or last_error_code == "expired"
or (days_left is not None and days_left < 0)
or (exp_dt and exp_dt <= fields.Datetime.now())
):
status = "expired"
return {
"code": ICP.get_param("nextzen.subscription.code") or "",
"status": status,
"expiration_date": expiration_date,
"expiration_reason": ICP.get_param("nextzen.subscription.expiration_reason")
or "",
"days_left": days_left,
"last_check": ICP.get_param("nextzen.subscription.last_check") or "",
"last_error": ICP.get_param("nextzen.subscription.last_error") or "",
"last_error_code": last_error_code,
"already_linked": ICP.get_param("nextzen.subscription.already_linked") or "",
"mandatory_validation": ICP.get_param(
"nextzen.subscription.mandatory_validation"
)
in ("1", "True", "true"),
}