From 00e2819fa2a2d2b02b7b2cbd2e79145d064d1e78 Mon Sep 17 00:00:00 2001 From: Etienne Trimaille Date: Tue, 2 Jul 2024 11:39:38 +0200 Subject: [PATCH] Add stats API --- cadastre/cadastre_menu.py | 16 +++- cadastre/plausible.py | 133 +++++++++++++++++++++++++++++ cadastre/server/cadastre_server.py | 11 ++- cadastre/server/tools.py | 29 ------- cadastre/tools.py | 41 +++++++++ 5 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 cadastre/plausible.py delete mode 100644 cadastre/server/tools.py diff --git a/cadastre/cadastre_menu.py b/cadastre/cadastre_menu.py index 658fa45b..34871025 100644 --- a/cadastre/cadastre_menu.py +++ b/cadastre/cadastre_menu.py @@ -15,7 +15,6 @@ (at your option) any later version. """ -import configparser import os import os.path import tempfile @@ -27,9 +26,11 @@ from qgis.core import ( QgsApplication, QgsLayoutExporter, + QgsMessageLog, QgsPrintLayout, QgsProject, QgsReadWriteContext, + Qgis, ) from qgis.PyQt.QtCore import QSettings, Qt, QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon, QKeySequence @@ -53,7 +54,9 @@ from cadastre.dialogs.options_dialog import CadastreOptionDialog from cadastre.dialogs.parcelle_dialog import CadastreParcelleDialog from cadastre.dialogs.search_dialog import CadastreSearchDialog +from cadastre.plausible import Plausible from cadastre.processing.provider import CadastreProvider +from cadastre.tools import metadata_config class CadastreMenu: @@ -237,9 +240,7 @@ def initGui(self): self.open_about_dialog() # Display some messages depending on version number - self.mConfig = configparser.ConfigParser() - metadataFile = plugin_dir + "/metadata.txt" - self.mConfig.read(metadataFile, encoding='utf-8') + self.mConfig = metadata_config() # Project load or create : refresh search and identify tool self.iface.projectRead.connect(self.onProjectRead) @@ -254,6 +255,13 @@ def initGui(self): self.cadastre_search_dialog.visibilityChanged.connect(self.updateSearchButton) + # noinspection PyBroadException + try: + plausible = Plausible(server=False) + plausible.request_stat_event() + except Exception as e: + QgsMessageLog.logMessage("Error while calling the stats API : \"{}\"".format(e), 'cadastre', Qgis.Warning) + def open_import_dialog(self): """ Import dialog diff --git a/cadastre/plausible.py b/cadastre/plausible.py new file mode 100644 index 00000000..bb11e30d --- /dev/null +++ b/cadastre/plausible.py @@ -0,0 +1,133 @@ +__copyright__ = 'Copyright 2024, 3Liz' +__license__ = 'GPL version 3' +__email__ = 'info@3liz.org' + +import json +import os +import platform + +from qgis.core import Qgis, QgsNetworkAccessManager +from qgis.PyQt.QtCore import QByteArray, QDateTime, QUrl +from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest + +from cadastre.logger import Logger +from cadastre.tools import to_bool, version + +MIN_SECONDS = 3600 +ENV_SKIP_STATS = "3LIZ_SKIP_STATS" + +PLAUSIBLE_DOMAIN_PROD_SERVER = "plugin.server.lizmap.com" +PLAUSIBLE_DOMAIN_PROD_DESKTOP = "plugin.desktop.org" +PLAUSIBLE_URL_PROD = "https://bourbon.3liz.com/api/event" + +# For testing purpose, to test. +# Similar to QGIS dashboard https://feed.qgis.org/metabase/public/dashboard/df81071d-4c75-45b8-a698-97b8649d7228 +# We only collect data listed in the list below +# and the country according to IP address. +# The IP is not stored by Plausible Community Edition https://github.com/plausible/analytics +# Plausible is GDPR friendly https://plausible.io/data-policy +# The User-Agent is set by QGIS Desktop itself + + +class Plausible: + + def __init__(self, server: bool): + """ Constructor. """ + self.server = server + self.previous_date = None + + def request_stat_event(self) -> bool: + """ Request to send an event to the API. """ + if to_bool(os.getenv(ENV_SKIP_STATS), default_value=False): + # Disabled by environment variable + return False + + if to_bool(os.getenv("CI"), default_value=False): + # If running on CI, do not send stats + return False + + if version() in ('master', 'dev'): + return False + + current = QDateTime().currentDateTimeUtc() + if self.previous_date and self.previous_date.secsTo(current) < MIN_SECONDS: + # Not more than one request per hour + # It's done at plugin startup anyway + return False + + if self._send_stat_event(): + self.previous_date = current + return True + + return False + + def _send_stat_event(self) -> bool: + """ Send stats event to the API. """ + # Qgis.QGIS_VERSION → 3.34.6-Prizren + # noinspection PyUnresolvedReferences + qgis_version_full = Qgis.QGIS_VERSION.split('-')[0] + # qgis_version_full → 3.34.6 + qgis_version_branch = '.'.join(qgis_version_full.split('.')[0:2]) + # qgis_version_branch → 3.34 + + python_version_full = platform.python_version() + # python_version_full → 3.10.12 + python_version_branch = '.'.join(python_version_full.split('.')[0:2]) + # python_version_branch → 3.10 + + data = { + "props": { + # Plugin version + "plugin-version": version(), + # QGIS + "qgis-version-full": qgis_version_full, + "qgis-version-branch": qgis_version_branch, + # Python + "python-version-full": python_version_full, + "python-version-branch": python_version_branch, + }, + "url": PLAUSIBLE_URL_PROD, + } + + is_lizcloud = False + if self.server: + data["name"] = "cadastre-server" + is_lizcloud = "lizcloud" in os.getenv("QGIS_SERVER_APPLICATION_NAME", "").lower() + if is_lizcloud: + plausible_domain = os.getenv("QGIS_SERVER_PLAUSIBLE_DOMAIN_NAME", PLAUSIBLE_DOMAIN_PROD_SERVER) + else: + plausible_domain = PLAUSIBLE_DOMAIN_PROD_SERVER + else: + data["name"] = "cadastre-desktop" + plausible_domain = PLAUSIBLE_DOMAIN_PROD_DESKTOP + + data["domain"] = plausible_domain + + request = QNetworkRequest() + # noinspection PyArgumentList + request.setUrl(QUrl(PLAUSIBLE_URL_PROD)) + + # Only turn ON for debug purpose, temporary ! + extra_debug = False + if extra_debug: + request.setRawHeader(b"X-Debug-Request", b"true") + request.setRawHeader(b"X-Forwarded-For", b"127.0.0.1") + request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + + # noinspection PyArgumentList + r: QNetworkReply = QgsNetworkAccessManager.instance().post(request, QByteArray(str.encode(json.dumps(data)))) + if not self.server: + return True + + if not is_lizcloud: + return True + + logger = Logger() + message = ( + f"Request HTTP OS process '{os.getpid()}' sent to '{PLAUSIBLE_URL_PROD}' with domain '{plausible_domain} : ") + if r.error() == QNetworkReply.NoError: + logger.info(message + "OK") + else: + logger.warning(message + r.error()) + + return True diff --git a/cadastre/server/cadastre_server.py b/cadastre/server/cadastre_server.py index 876afc24..98ceae57 100644 --- a/cadastre/server/cadastre_server.py +++ b/cadastre/server/cadastre_server.py @@ -10,8 +10,9 @@ from qgis.server import QgsServerInterface from cadastre.logger import Logger +from cadastre.plausible import Plausible from cadastre.server.cadastre_service import CadastreService -from cadastre.server.tools import version +from cadastre.tools import version class CadastreServer: @@ -24,6 +25,14 @@ def __init__(self, server_iface: QgsServerInterface) -> None: Logger.info(f'Init server version "{version()}"') + # noinspection PyBroadException + try: + plausible = Plausible(server=True) + plausible.request_stat_event() + except Exception as e: + Logger.log_exception(e) + Logger.critical('Error while calling the API stats') + cache_dir_str = os.getenv('QGIS_CADASTRE_CACHE_DIR') if not cache_dir_str: # Create cache in /tmp/org.qgis.cadastre diff --git a/cadastre/server/tools.py b/cadastre/server/tools.py deleted file mode 100644 index bd5f5fa8..00000000 --- a/cadastre/server/tools.py +++ /dev/null @@ -1,29 +0,0 @@ -__copyright__ = 'Copyright 2021, 3Liz' -__license__ = 'GPL version 3' -__email__ = 'info@3liz.org' - -import configparser - -from pathlib import Path - -from qgis.core import Qgis, QgsMessageLog - - -def version() -> str: - """ Returns the plugin current version. """ - file_path = Path(__file__).parent.parent.joinpath('metadata.txt') - config = configparser.ConfigParser() - try: - config.read(file_path, encoding='utf8') - except UnicodeDecodeError: - # Issue LWC https://github.com/3liz/lizmap-web-client/issues/1908 - # Maybe a locale issue ? - # Do not use logger here, circular import - # noinspection PyTypeChecker - QgsMessageLog.logMessage( - "Error, an UnicodeDecodeError occurred while reading the metadata.txt. Is the locale " - "correctly set on the server ?", - "cadastre", Qgis.Critical) - return 'NULL' - else: - return config["general"]["version"] diff --git a/cadastre/tools.py b/cadastre/tools.py index be18a4a6..94f75f26 100644 --- a/cadastre/tools.py +++ b/cadastre/tools.py @@ -2,10 +2,12 @@ __license__ = "GPL version 3" __email__ = "info@3liz.org" +import configparser import subprocess import time from pathlib import Path +from typing import Union from qgis.utils import pluginMetadata @@ -94,3 +96,42 @@ def set_window_title() -> str: # version, current_git_hash(), next_git_tag()) return f'next {next_git_tag()}' + + +def to_bool(val: Union[str, int, float, bool, None], default_value: bool = True) -> bool: + """ Convert lizmap config value to boolean """ + if isinstance(val, bool): + return val + + if val is None or val == '': + return default_value + + if isinstance(val, str): + # For string, compare lower value to True string + return val.lower() in ('yes', 'true', 't', '1') + + elif not val: + # For value like False, 0, 0.0, None, empty list or dict returns False + return False + + return default_value + + +def metadata_config() -> configparser: + """Get the INI config parser for the metadata file. + + :return: The config parser object. + :rtype: ConfigParser + """ + path = plugin_path("metadata.txt") + config = configparser.ConfigParser() + config.read(path, encoding='utf8') + return config + + +def version(remove_v_prefix=True) -> str: + """Return the version defined in metadata.txt.""" + v = metadata_config()["general"]["version"] + if v.startswith("v") and remove_v_prefix: + v = v[1:] + return v