556 lines
22 KiB
Python
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"),
|
|
}
|