diff --git a/qtribu/gui/dlg_contents.py b/qtribu/gui/dlg_contents.py index 28103ead..8e7f10b5 100644 --- a/qtribu/gui/dlg_contents.py +++ b/qtribu/gui/dlg_contents.py @@ -16,8 +16,8 @@ from qtribu.constants import ICON_ARTICLE, ICON_GEORDP from qtribu.gui.form_article import ArticleForm from qtribu.gui.form_rdp_news import RdpNewsForm -from qtribu.logic import RssItem -from qtribu.logic.json_feed import JsonFeedClient +from qtribu.logic.news_feed.json_feed import JsonFeedClient +from qtribu.logic.news_feed.mdl_rss_item import RssItem from qtribu.toolbelt import PlgLogger, PlgOptionsManager from qtribu.toolbelt.commons import open_url_in_browser, open_url_in_webviewer @@ -257,7 +257,7 @@ def _build_tree_widget_item_from_content(content: RssItem) -> QTreeWidgetItem: [ content.date_pub.strftime("%d %B"), content.title, - ", ".join(content.author), + ", ".join(content.authors), tags, content.url, ] diff --git a/qtribu/logic/__init__.py b/qtribu/logic/__init__.py index f76fcbbb..16192f77 100644 --- a/qtribu/logic/__init__.py +++ b/qtribu/logic/__init__.py @@ -1,4 +1,2 @@ #! python3 # noqa: E265 -from .custom_datatypes import RssItem # noqa: F401 -from .rss_reader import RssMiniReader # noqa: F401 from .splash_changer import SplashChanger # noqa: F401 diff --git a/qtribu/logic/custom_datatypes.py b/qtribu/logic/custom_datatypes.py deleted file mode 100644 index e0020e59..00000000 --- a/qtribu/logic/custom_datatypes.py +++ /dev/null @@ -1,22 +0,0 @@ -#! python3 # noqa: E265 - -# Standard library -from collections import namedtuple - -# Data structures -RssItem = namedtuple( - typename="RssItem", - field_names=[ - "abstract", - "author", - "categories", - "date_pub", - "guid", - "image_length", - "image_type", - "image_url", - "title", - "url", - ], - defaults=(None, None, None, None, None, None, None, None, None), -) diff --git a/qtribu/logic/news_feed/__init__.py b/qtribu/logic/news_feed/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qtribu/logic/json_feed.py b/qtribu/logic/news_feed/json_feed.py similarity index 95% rename from qtribu/logic/json_feed.py rename to qtribu/logic/news_feed/json_feed.py index db358739..62f4d92a 100644 --- a/qtribu/logic/json_feed.py +++ b/qtribu/logic/news_feed/json_feed.py @@ -18,7 +18,7 @@ # plugin from qtribu.__about__ import __title__, __version__ -from qtribu.logic import RssItem +from qtribu.logic.news_feed.mdl_rss_item import RssItem from qtribu.toolbelt import NetworkRequestsManager, PlgLogger, PlgOptionsManager # -- GLOBALS -- @@ -30,7 +30,7 @@ FETCH_UPDATE_INTERVAL_SECONDS = 7200 -## -- CLASSES -- +# -- CLASSES -- class JsonFeedClient: @@ -89,7 +89,7 @@ def authors(self) -> list[str]: """ authors = [] for content in self.fetch(): - for ca in content.author: + for ca in content.authors: authors.append(" ".join([a.title() for a in ca.split(" ")])) return sorted(set(authors)) @@ -115,7 +115,7 @@ def _map_item(item: dict[str, Any]) -> RssItem: """ return RssItem( abstract=item.get("content_html"), - author=[i["name"] for i in item.get("authors")], + authors=[i["name"] for i in item.get("authors")], categories=item.get("tags", []), date_pub=datetime.fromisoformat(item.get("date_published")), guid=item.get("id"), @@ -142,7 +142,7 @@ def _matches(query: str, item: RssItem) -> bool: return all([JsonFeedClient._matches(w, item) for w in words]) return ( query.upper() in item.abstract.upper() - or query.upper() in ",".join(item.author).upper() + or query.upper() in ",".join(item.authors).upper() or query.upper() in ",".join(item.categories).upper() or query.upper() in item.date_pub.isoformat().upper() or query.upper() in item.image_url.upper() diff --git a/qtribu/logic/news_feed/mdl_rss_item.py b/qtribu/logic/news_feed/mdl_rss_item.py new file mode 100644 index 00000000..31a3fb79 --- /dev/null +++ b/qtribu/logic/news_feed/mdl_rss_item.py @@ -0,0 +1,21 @@ +#! python3 # noqa: E265 + +# Standard library +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class RssItem: + """Dataclass describing a RSS channel item.""" + + abstract: Optional[str] = None + authors: Optional[list[Optional[str]]] = None + categories: Optional[list[Optional[str]]] = None + date_pub: Optional[tuple[int, ...]] = None + guid: Optional[str] = None + image_length: Optional[str] = None + image_type: Optional[str] = None + image_url: Optional[str] = None + title: Optional[str] = None + url: Optional[str] = None diff --git a/qtribu/logic/news_feed/rss_reader.py b/qtribu/logic/news_feed/rss_reader.py new file mode 100644 index 00000000..f8261a9c --- /dev/null +++ b/qtribu/logic/news_feed/rss_reader.py @@ -0,0 +1,320 @@ +#! python3 # noqa: E265 + + +""" + Minimalist RSS reader. +""" + +# ############################################################################ +# ########## Imports ############### +# ################################## + +# Standard library +import logging +import xml.etree.ElementTree as ET +from email.utils import parsedate +from pathlib import Path +from typing import Callable, Optional + +# QGIS +from qgis.core import Qgis, QgsSettings +from qgis.PyQt.QtCore import QCoreApplication +from qgis.PyQt.QtGui import QIcon +from qgis.PyQt.QtWidgets import QAction + +# project +from qtribu.__about__ import DIR_PLUGIN_ROOT, __title__, __version__ +from qtribu.logic.news_feed.mdl_rss_item import RssItem +from qtribu.toolbelt import PlgLogger, PlgOptionsManager +from qtribu.toolbelt.file_stats import is_file_older_than +from qtribu.toolbelt.network_manager import NetworkRequestsManager + +# ############################################################################ +# ########## Globals ############### +# ################################## + +logger = logging.getLogger(__name__) + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +class RssMiniReader: + """Minimalist RSS feed parser.""" + + FEED_ITEMS: Optional[list[RssItem]] = None + HEADERS: dict = { + b"Accept": b"application/xml", + b"User-Agent": bytes(f"{__title__}/{__version__}", "utf8"), + } + PATTERN_INCLUDE: list = ["articles/", "rdp/"] + + def __init__( + self, + action_read: Optional[QAction] = None, + on_read_button: Optional[Callable] = None, + ): + """Class initialization.""" + self.log = PlgLogger().log + self.ntwk_manager = NetworkRequestsManager() + self.plg_settings = PlgOptionsManager.get_plg_settings() + self.local_feed_filepath: Path = self.plg_settings.local_app_folder.joinpath( + "rss.xml" + ) + self.action_read = action_read + self.on_read_button = on_read_button + + def process(self) -> None: + """Download, parse and read RSS feed than store items as attribute.""" + # download remote RSS feed to cache folder + self.download_feed() + if not self.local_feed_filepath.exists(): + self.log( + message=self.tr( + f"The RSS feed is not available locally: {self.local_feed_filepath}. " + "Features related to the RSS reader are disabled." + ), + log_level=Qgis.Critical, + ) + return + + # parse the local RSS feed + self.read_feed() + + # check if a new item has been published since last check + if not self.has_new_content: + self.log(message="No new item found in RSS feed.", log_level=Qgis.NoLevel) + return + # notify + if isinstance(self.latest_item, RssItem): + latest_item = self.latest_item + self.log( + message="{} {}".format( + self.tr("New content published:"), + latest_item.title, + ), + log_level=Qgis.Success, + push=PlgOptionsManager().get_plg_settings().notify_push_info, + duration=PlgOptionsManager().get_plg_settings().notify_push_duration, + button=True, + button_label=self.tr("Read it!"), + button_connect=self.on_read_button, + ) + + # change action icon + if isinstance(self.action_read, QAction): + self.action_read.setIcon( + QIcon( + str( + DIR_PLUGIN_ROOT / "resources/images/logo_orange_no_text.svg" + ) + ), + ) + + def download_feed(self) -> bool: + """Download RSS feed locally if it's older than latest 24 hours. + + :return: True is a new file has been downloaded. + :rtype: bool + """ + if is_file_older_than( + local_file_path=self.local_feed_filepath, + expiration_rotating_hours=self.plg_settings.rss_poll_frequency_hours, + ): + self.ntwk_manager.download_file_to( + remote_url=self.plg_settings.rss_source, + local_path=self.local_feed_filepath, + ) + self.log( + message=f"The remote RSS feed ({self.plg_settings.rss_source}) has been " + f"downloaded to {self.local_feed_filepath}", + log_level=Qgis.Info, + ) + return True + self.log( + message=f"A fresh local RSS feed already exists: {self.local_feed_filepath}", + log_level=Qgis.Info, + ) + return False + + def read_feed(self) -> list[RssItem]: + """Parse the feed XML as string and store items into an ordered list of RSS items. + + :return: list of RSS items dataclasses + :rtype: list[RssItem] + """ + feed_items: list[RssItem] = [] + tree = ET.parse(self.local_feed_filepath) + items = tree.findall("channel/item") + for item in items: + try: + # filter on included pattern + if not any([i in item.find("link").text for i in self.PATTERN_INCLUDE]): + self.log( + message="Item ignored because unmatches the include pattern: {}".format( + item.find("title").text + ), + log_level=Qgis.NoLevel, + ) + continue + + # feed item object + feed_item_obj = RssItem( + abstract=item.find("description").text, + authors=[author.text for author in item.findall("author")] or None, + categories=[category.text for category in item.findall("category")] + or None, + date_pub=parsedate(item.find("pubDate").text), + guid=item.find("guid").text, + image_length=item.find("enclosure").attrib.get("length"), + image_type=item.find("enclosure").attrib.get("type"), + image_url=item.find("enclosure").attrib.get("url"), + title=item.find("title").text, + url=item.find("link").text, + ) + if item.find("enclosure") is not None: + item_enclosure = item.find("enclosure") + feed_item_obj.image_length = item_enclosure.attrib.get("length") + feed_item_obj.image_type = item_enclosure.attrib.get("type") + feed_item_obj.image_url = item_enclosure.attrib.get("url") + + # add items to the feed + feed_items.append(feed_item_obj) + except Exception as err: + item_idx: Optional[int] = None + if hasattr(items, "index"): + item_idx = items.index(item) + + err_msg = f"Feed item {item_idx} triggers an error. Trace: {err}" + self.log(message=err_msg, log_level=Qgis.Critical) + + # store feed items as attribute and return it + self.FEED_ITEMS = feed_items + return feed_items + + @property + def latest_item(self) -> Optional[RssItem]: + """Returns the latest feed item, based on index 0. + + :return: latest feed item. + :rtype: RssItem + """ + if not self.FEED_ITEMS: + logger.warning( + "Feed has not been loaded, so it's impossible to " + "return the latest item." + ) + return None + + return self.FEED_ITEMS[0] + + def latest_items(self, count: int = 36) -> list[RssItem]: + """Returns the latest feed items. + :param count: number of items to fetch + :type count: int + :return: latest feed items + :rtype: List[RssItem] + """ + if count <= 0: + raise ValueError("Number of RSS items to get must be > 0") + if not self.FEED_ITEMS: + logger.warning( + "Feed has not been loaded, so it's impossible to " + "return the latest item." + ) + return [] + return self.FEED_ITEMS[:count] + + @property + def has_new_content(self) -> bool: + """Compare the saved item guid (in plugin settings) with feed latest item to \ + determine if a newer item has been published. + + :return: True is a newer item has been published. + :rtype: bool + """ + settings = PlgOptionsManager.get_plg_settings() + if ( + isinstance(self.latest_item, RssItem) + and self.latest_item.guid != settings.latest_content_guid + ): + return True + else: + return False + + def add_latest_item_to_news_feed(self) -> bool: + """Check if the news feed integration is enabled. If so, insert the latest RSS + item at the top of the news feed. + + :return: True if it worked. False if disabled or something wen wrong. + :rtype: bool + """ + + # sample stucture: + # news-feed\items\httpsfeedqgisorg\entries\items\64\title=It\x2019s OSM\x2019s... + # news-feed\items\httpsfeedqgisorg\entries\items\65\content="
The Cyber/..
" + # news-feed\items\httpsfeedqgisorg\entries\items\65\expiry=@DateTime(\0\0\0\x10\0\0\0\0\0\0%\x8b\xd1\x3\xba\xe1\x38\0) + # news-feed\items\httpsfeedqgisorg\entries\items\65\image-url=https://feed.qgis.org/media/feedimages/2023/11/09/europe.jpg + # news-feed\items\httpsfeedqgisorg\entries\items\65\link=https://www.osgeo.org/foundation-news/eu-cyber-resilience-act/ + # news-feed\items\httpsfeedqgisorg\entries\items\65\sticky=false + + plg_settings = PlgOptionsManager.get_plg_settings() + if not plg_settings.integration_qgis_news_feed: + self.log( + message="The QGIS news feed integration is disabled. Abort!", + log_level=Qgis.NoLevel, + ) + return False + + qsettings = QgsSettings() + + # get latest QGIS item id + latest_geotribu_article = self.latest_item + item_id = 99 + + qsettings.setValue( + key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/title", + value=f"[Geotribu] {latest_geotribu_article.title}", + section=QgsSettings.App, + ) + qsettings.setValue( + key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/content", + value=f"{latest_geotribu_article.abstract}
" + + self.tr("Author(s): ") + + f"{', '.join(latest_geotribu_article.authors)}
" + + self.tr("Keywords: ") + + f"{', '.join(latest_geotribu_article.categories)}
", + section=QgsSettings.App, + ) + qsettings.setValue( + key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/image-url", + value=latest_geotribu_article.image_url, + section=QgsSettings.App, + ) + qsettings.setValue( + key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/link", + value=latest_geotribu_article.url, + section=QgsSettings.App, + ) + + qsettings.sync() + + self.log( + message=f"Latest Geotribu content inserted in QGIS news feed: " + f"{latest_geotribu_article.title}", + log_level=Qgis.Info, + ) + + return True + + def tr(self, message: str) -> str: + """Get the translation for a string using Qt translation API. + + :param message: string to be translated. + :type message: str + + :returns: Translated version of message. + :rtype: str + """ + return QCoreApplication.translate(self.__class__.__name__, message) diff --git a/qtribu/logic/rss_reader.py b/qtribu/logic/rss_reader.py deleted file mode 100644 index b84a80e4..00000000 --- a/qtribu/logic/rss_reader.py +++ /dev/null @@ -1,225 +0,0 @@ -#! python3 # noqa: E265 - - -""" - Minimalist RSS reader. -""" - -# ############################################################################ -# ########## Imports ############### -# ################################## - -# Standard library -import logging -import xml.etree.ElementTree as ET -from email.utils import parsedate -from typing import List, Optional - -# QGIS -from qgis.core import Qgis, QgsSettings -from qgis.PyQt.QtCore import QCoreApplication - -# project -from qtribu.__about__ import __title__, __version__ -from qtribu.logic.custom_datatypes import RssItem -from qtribu.toolbelt import PlgLogger, PlgOptionsManager - -# ############################################################################ -# ########## Globals ############### -# ################################## - -logger = logging.getLogger(__name__) - -# ############################################################################ -# ########## Classes ############### -# ################################## - - -class RssMiniReader: - """Minimalist RSS feed parser.""" - - FEED_ITEMS: Optional[tuple] = None - HEADERS: dict = { - b"Accept": b"application/xml", - b"User-Agent": bytes(f"{__title__}/{__version__}", "utf8"), - } - PATTERN_INCLUDE: list = ["articles/", "rdp/"] - - def __init__(self): - """Class initialization.""" - self.log = PlgLogger().log - - def read_feed(self, in_xml: str) -> tuple[RssItem]: - """Parse the feed XML as string and store items into an ordered tuple of tuples. - - :param in_xml: XML as string. Must be RSS compliant. - :type in_xml: str - - :return: RSS items loaded as namedtuples - :rtype: Tuple[RssItem] - """ - feed_items = [] - tree = ET.ElementTree(ET.fromstring(in_xml)) - root = tree.getroot() - items = root.findall("channel/item") - for item in items: - try: - # filter on included pattern - if not any([i in item.find("link").text for i in self.PATTERN_INCLUDE]): - logging.debug( - "Item ignored because unmatches the include pattern: {}".format( - item.find("title") - ) - ) - continue - - # add items to the feed - feed_items.append( - RssItem( - abstract=item.find("description").text, - author=[author.text for author in item.findall("author")] - or None, - categories=[ - category.text for category in item.findall("category") - ] - or None, - date_pub=parsedate(item.find("pubDate").text), - guid=item.find("guid").text, - image_length=item.find("enclosure").attrib.get("length"), - image_type=item.find("enclosure").attrib.get("type"), - image_url=item.find("enclosure").attrib.get("url"), - title=item.find("title").text, - url=item.find("link").text, - ) - ) - except Exception as err: - err_msg = f"Feed item triggers an error. Trace: {err}" - logger.error(err_msg) - self.log(message=err_msg, log_level=2) - - # store feed items as attribute and return it - self.FEED_ITEMS = feed_items - return feed_items - - @property - def latest_item(self) -> RssItem: - """Returns the latest feed item, based on index 0. - - :return: latest feed item. - :rtype: RssItem - """ - if not self.FEED_ITEMS: - logger.warning( - "Feed has not been loaded, so it's impossible to " - "return the latest item." - ) - return None - - return self.FEED_ITEMS[0] - - def latest_items(self, count: int = 36) -> List[RssItem]: - """Returns the latest feed items. - :param count: number of items to fetch - :type count: int - :return: latest feed items - :rtype: List[RssItem] - """ - if count <= 0: - raise ValueError("Number of RSS items to get must be > 0") - if not self.FEED_ITEMS: - logger.warning( - "Feed has not been loaded, so it's impossible to " - "return the latest item." - ) - return [] - return self.FEED_ITEMS[:count] - - @property - def has_new_content(self) -> bool: - """Compare the saved item guid (in plugin settings) with feed latest item to \ - determine if a newer item has been published. - - :return: True is a newer item has been published. - :rtype: bool - """ - settings = PlgOptionsManager.get_plg_settings() - if self.latest_item.guid != settings.latest_content_guid: - return True - else: - return False - - def add_latest_item_to_news_feed(self) -> bool: - """Check if the news feed integration is enabled. If so, insert the latest RSS - item at the top of the news feed. - - :return: True if it worked. False if disabled or something wen wrong. - :rtype: bool - """ - - # sample stucture: - # news-feed\items\httpsfeedqgisorg\entries\items\64\title=It\x2019s OSM\x2019s... - # news-feed\items\httpsfeedqgisorg\entries\items\65\content="The Cyber/..
" - # news-feed\items\httpsfeedqgisorg\entries\items\65\expiry=@DateTime(\0\0\0\x10\0\0\0\0\0\0%\x8b\xd1\x3\xba\xe1\x38\0) - # news-feed\items\httpsfeedqgisorg\entries\items\65\image-url=https://feed.qgis.org/media/feedimages/2023/11/09/europe.jpg - # news-feed\items\httpsfeedqgisorg\entries\items\65\link=https://www.osgeo.org/foundation-news/eu-cyber-resilience-act/ - # news-feed\items\httpsfeedqgisorg\entries\items\65\sticky=false - - plg_settings = PlgOptionsManager.get_plg_settings() - if not plg_settings.integration_qgis_news_feed: - self.log( - message="The QGIS news feed integration is disabled. Abort!", - log_level=4, - ) - return False - - qsettings = QgsSettings() - - # get latest QGIS item id - latest_geotribu_article = self.latest_item - item_id = 99 - - qsettings.setValue( - key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/title", - value=f"[Geotribu] {latest_geotribu_article.title}", - section=QgsSettings.App, - ) - qsettings.setValue( - key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/content", - value=f"{latest_geotribu_article.abstract}
" - + self.tr("Author(s): ") - + f"{', '.join(latest_geotribu_article.author)}
" - + self.tr("Keywords: ") - + f"{', '.join(latest_geotribu_article.categories)}
", - section=QgsSettings.App, - ) - qsettings.setValue( - key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/image-url", - value=latest_geotribu_article.image_url, - section=QgsSettings.App, - ) - qsettings.setValue( - key=f"news-feed/items/httpsfeedqgisorg/entries/items/{item_id}/link", - value=latest_geotribu_article.url, - section=QgsSettings.App, - ) - - qsettings.sync() - - self.log( - message=f"Latest Geotribu content inserted in QGIS news feed: " - f"{latest_geotribu_article.title}", - log_level=Qgis.Info, - ) - - return True - - def tr(self, message: str) -> str: - """Get the translation for a string using Qt translation API. - - :param message: string to be translated. - :type message: str - - :returns: Translated version of message. - :rtype: str - """ - return QCoreApplication.translate(self.__class__.__name__, message) diff --git a/qtribu/plugin_main.py b/qtribu/plugin_main.py index 5ce9b174..7551ed3a 100644 --- a/qtribu/plugin_main.py +++ b/qtribu/plugin_main.py @@ -22,8 +22,9 @@ from qtribu.gui.dlg_settings import PlgOptionsFactory from qtribu.gui.form_article import ArticleForm from qtribu.gui.form_rdp_news import RdpNewsForm -from qtribu.logic import RssMiniReader, SplashChanger -from qtribu.toolbelt import NetworkRequestsManager, PlgLogger, PlgOptionsManager +from qtribu.logic.news_feed.rss_reader import RssMiniReader +from qtribu.logic.splash_changer import SplashChanger +from qtribu.toolbelt import PlgLogger, PlgOptionsManager from qtribu.toolbelt.commons import open_url_in_browser, open_url_in_webviewer # ############################################################################ @@ -69,7 +70,7 @@ def __init__(self, iface: QgisInterface): ) # sub-modules - self.rss_rdr = RssMiniReader() + self.rss_reader = None self.splash_chgr = SplashChanger(self) def initGui(self): @@ -88,14 +89,14 @@ def initGui(self): self.form_rdp_news = None # -- Actions - self.action_run = QAction( + self.action_show_latest_content = QAction( QIcon(str(DIR_PLUGIN_ROOT / "resources/images/logo_green_no_text.svg")), self.tr("Newest article"), self.iface.mainWindow(), ) - self.action_run.setToolTip(self.tr("Newest article")) - self.action_run.triggered.connect(self.run) + self.action_show_latest_content.setToolTip(self.tr("Newest article")) + self.action_show_latest_content.triggered.connect(self.on_show_latest_content) self.action_contents = QAction( QgsApplication.getThemeIcon("mActionOpenTableVisible.svg"), @@ -141,7 +142,7 @@ def initGui(self): self.action_splash.triggered.connect(self.splash_chgr.switch) # -- Menu - self.iface.addPluginToWebMenu(__title__, self.action_run) + self.iface.addPluginToWebMenu(__title__, self.action_show_latest_content) self.iface.addPluginToWebMenu(__title__, self.action_contents) self.iface.addPluginToWebMenu(__title__, self.action_form_rdp_news) self.iface.addPluginToWebMenu(__title__, self.action_form_article) @@ -187,12 +188,16 @@ def initGui(self): self.iface.helpMenu().addAction(self.action_osgeofr) # -- Toolbar - self.toolbar.addAction(self.action_run) + self.toolbar.addAction(self.action_show_latest_content) self.toolbar.addAction(self.action_contents) self.toolbar.addAction(self.action_form_rdp_news) self.toolbar.addAction(self.action_form_article) # -- Post UI initialization + self.rss_reader = RssMiniReader( + action_read=self.action_show_latest_content, + on_read_button=self.on_show_latest_content, + ) self.iface.initializationCompleted.connect(self.post_ui_init) def unload(self): @@ -201,7 +206,7 @@ def unload(self): self.iface.removePluginWebMenu(__title__, self.action_help) self.iface.removePluginWebMenu(__title__, self.action_form_article) self.iface.removePluginWebMenu(__title__, self.action_form_rdp_news) - self.iface.removePluginWebMenu(__title__, self.action_run) + self.iface.removePluginWebMenu(__title__, self.action_show_latest_content) self.iface.removePluginWebMenu(__title__, self.action_contents) self.iface.removePluginWebMenu(__title__, self.action_settings) self.iface.removePluginWebMenu(__title__, self.action_splash) @@ -217,7 +222,6 @@ def unload(self): self.iface.unregisterOptionsWidgetFactory(self.options_factory) # remove actions - del self.action_run del self.action_help del self.action_georezo @@ -227,52 +231,10 @@ def post_ui_init(self): :raises Exception: if there is no item in the feed """ try: - qntwk = NetworkRequestsManager() - rss_feed_content = qntwk.get_from_source( - headers=self.rss_rdr.HEADERS, - response_expected_content_type="application/xml", - ) - - self.rss_rdr.read_feed(rss_feed_content) - if not self.rss_rdr.latest_item: - raise Exception("No item found") - - # change tooltip - self.action_run.setToolTip( - "{} - {}".format( - self.tr("Newest article"), self.rss_rdr.latest_item.title - ) - ) - - # check if a new content has been published - if self.rss_rdr.has_new_content: - # change action icon - self.action_run.setIcon( - QIcon( - str( - DIR_PLUGIN_ROOT / "resources/images/logo_orange_no_text.svg" - ) - ), - ) - # notify - self.log( - message="{} {}".format( - self.tr("New content published:"), - self.rss_rdr.latest_item.title, - ), - log_level=3, - push=PlgOptionsManager().get_plg_settings().notify_push_info, - duration=PlgOptionsManager() - .get_plg_settings() - .notify_push_duration, - button=True, - button_label=self.tr("Newest article"), - button_connect=self.run, - ) - + self.rss_reader.process() except Exception as err: self.log( - message=self.tr(f"Michel, we've got a problem: {err}"), + message=self.tr(f"Reading the RSS feed failed. Trace: {err}"), log_level=2, push=True, ) @@ -280,7 +242,7 @@ def post_ui_init(self): # insert latest item within news feed try: - self.rss_rdr.add_latest_item_to_news_feed() + self.rss_reader.add_latest_item_to_news_feed() except Exception as err: self.log( message=self.tr( @@ -302,22 +264,25 @@ def tr(self, message: str) -> str: """ return QCoreApplication.translate(self.__class__.__name__, message) - def run(self): + def on_show_latest_content(self): """Main action on plugin icon pressed event.""" try: - if not self.rss_rdr.latest_item: + if not self.rss_reader.latest_item: self.post_ui_init() + rss_item = self.rss_reader.latest_item + open_url_in_webviewer(url=rss_item.url, window_title=rss_item.title) + # save latest RSS item displayed open_url_in_webviewer( - self.rss_rdr.latest_item.url, self.rss_rdr.latest_item.title + self.rss_reader.latest_item.url, self.rss_reader.latest_item.title ) - self.action_run.setIcon( + self.action_show_latest_content.setIcon( QIcon(str(DIR_PLUGIN_ROOT / "resources/images/logo_green_no_text.svg")) ) - self.action_run.setToolTip(self.tr("Newest article")) + self.action_show_latest_content.setToolTip(self.tr("Newest article")) # save latest RSS item displayed PlgOptionsManager().set_value_from_key( - key="latest_content_guid", value=self.rss_rdr.latest_item.guid + key="latest_content_guid", value=self.rss_reader.latest_item.guid ) except Exception as err: self.log( diff --git a/qtribu/toolbelt/commons.py b/qtribu/toolbelt/commons.py index 0eea3d09..a9d1b2cf 100644 --- a/qtribu/toolbelt/commons.py +++ b/qtribu/toolbelt/commons.py @@ -1,9 +1,16 @@ from qgis.PyQt.QtCore import QUrl from qgis.PyQt.QtGui import QDesktopServices -from qtribu.logic.web_viewer import WebViewer +# project +try: + from qtribu.logic.web_viewer import WebViewer -web_viewer = WebViewer() + web_viewer = WebViewer() +except ImportError: + web_viewer = None + +from qtribu.toolbelt.log_handler import PlgLogger +from qtribu.toolbelt.preferences import PlgOptionsManager def open_url_in_browser(url: str) -> bool: @@ -27,6 +34,15 @@ def open_url_in_webviewer(url: str, window_title: str) -> None: :param window_title: title to give to the webviewer window :type window_title: str """ + if web_viewer is None and PlgOptionsManager().get_plg_settings().browser == 1: + PlgLogger.log( + message="The embedded webviewer is not avaible, probably because " + "of unfilled system dependencies (QtWebEngine). Using default system " + "browser as fallback.", + log_level=2, + ) + open_url_in_browser(url=url) + web_viewer.display_web_page(url) if web_viewer.wdg_web: web_viewer.set_window_title(window_title) diff --git a/qtribu/toolbelt/file_stats.py b/qtribu/toolbelt/file_stats.py new file mode 100644 index 00000000..1578de3f --- /dev/null +++ b/qtribu/toolbelt/file_stats.py @@ -0,0 +1,80 @@ +#! python3 # noqa: E265 + +"""Check file statistcs.""" + +# ############################################################################ +# ########## IMPORTS ############# +# ################################ + +# standard library +import logging +from datetime import datetime, timedelta +from pathlib import Path +from sys import platform as opersys +from typing import Literal + +# ############################################################################ +# ########## GLOBALS ############# +# ################################ + +# logs +logger = logging.getLogger(__name__) + +# ############################################################################ +# ########## FUNCTIONS ########### +# ################################ + + +def is_file_older_than( + local_file_path: Path, + expiration_rotating_hours: int = 24, + dt_reference_mode: Literal["auto", "creation", "modification"] = "auto", +) -> bool: + """Check if the creation/modification date of the specified file is older than the \ + mount of hours. + + Args: + local_file_path (Path): local path to the file + expiration_rotating_hours (int, optional): number in hours to consider the \ + local file outdated. Defaults to 24. + dt_reference_mode (Literal['auto', 'creation', 'modification'], optional): + reference date type: auto to handle differences between operating systems, + creation for creation date, modification for last modification date. + Defaults to "auto". + + Returns: + bool: True if the creation/modification date of the file is older than the \ + specified number of hours. + """ + if not local_file_path.is_file() and not local_file_path.exists(): + logger.debug(f"{local_file_path} does not exist.") + return True + + # modification date varies depending on operating system: on some systems (like + # Unix) creation date is the time of the last metadata change, and, on others + # (like Windows), is the creation time for path. + if dt_reference_mode == "auto" and opersys == "win32": + dt_reference_mode = "modification" + else: + dt_reference_mode = "creation" + + # get file reference datetime - modification or creation + if dt_reference_mode == "modification": + f_ref_dt = datetime.fromtimestamp(local_file_path.stat().st_mtime) + dt_type = "modified" + else: + f_ref_dt = datetime.fromtimestamp(local_file_path.stat().st_ctime) + dt_type = "created" + + if (datetime.now() - f_ref_dt) < timedelta(hours=expiration_rotating_hours): + logger.debug( + f"{local_file_path} has been {dt_type} less than " + f"{expiration_rotating_hours} hours ago." + ) + return False + else: + logger.debug( + f"{local_file_path} has been {dt_type} more than " + f"{expiration_rotating_hours} hours ago." + ) + return True diff --git a/qtribu/toolbelt/log_handler.py b/qtribu/toolbelt/log_handler.py index cb765fe8..68331ef2 100644 --- a/qtribu/toolbelt/log_handler.py +++ b/qtribu/toolbelt/log_handler.py @@ -99,6 +99,15 @@ def log( button_more_text=detailed_error_message ) log(message="Plugin loaded - TEST", log_level=4, push=0) + + # also works using enums from Qgis: + # Qgis.Info, Qgis.Warning, Qgis.Critical, Qgis.Success, Qgis.NoLevel + from qgis.core import Qgis + log( + message="Something went wrong but it's not blocking", + log_level=Qgis.Warning + ) + """ # if not debug mode and not push, let's ignore INFO, SUCCESS and TEST debug_mode = plg_prefs_hdlr.PlgOptionsManager.get_plg_settings().debug_mode diff --git a/qtribu/toolbelt/network_manager.py b/qtribu/toolbelt/network_manager.py index 239a5408..62eb0ac1 100644 --- a/qtribu/toolbelt/network_manager.py +++ b/qtribu/toolbelt/network_manager.py @@ -11,7 +11,8 @@ # Standard library import logging from functools import lru_cache -from typing import Optional +from pathlib import Path +from typing import Optional, Union from urllib.parse import urlparse, urlunparse # PyQGIS @@ -180,7 +181,7 @@ def get_from_source( logger.error(err_msg) self.log(message=err_msg, log_level=2, push=True) - def download_file(self, remote_url: str, local_path: str) -> str: + def download_file_to(self, remote_url: str, local_path: Union[Path, str]) -> str: """Download a file from a remote web server accessible through HTTP. :param remote_url: remote URL @@ -190,6 +191,13 @@ def download_file(self, remote_url: str, local_path: str) -> str: :return: output path :rtype: str """ + # check if destination path is a str and if parent folder exists + if isinstance(local_path, Path): + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path = f"{local_path.resolve()}" + elif isinstance(local_path, str): + Path(local_path).parent.mkdir(parents=True, exist_ok=True) + self.log( message=f"Downloading file from {remote_url} to {local_path}", log_level=4 ) diff --git a/qtribu/toolbelt/preferences.py b/qtribu/toolbelt/preferences.py index 251166cf..e5bba271 100644 --- a/qtribu/toolbelt/preferences.py +++ b/qtribu/toolbelt/preferences.py @@ -31,14 +31,15 @@ class PlgSettingsStructure: local_app_folder: Path = get_app_dir(dir_name="cache") # RSS feed - rss_source: str = "https://geotribu.fr/feed_rss_created.xml" json_feed_source: str = "https://geotribu.fr/feed_json_created.json" + latest_content_guid: str = "" + rss_source: str = "https://geotribu.fr/feed_rss_created.xml" + rss_poll_frequency_hours: int = 24 # usage browser: int = 1 notify_push_info: bool = True notify_push_duration: int = 10 - latest_content_guid: str = "" splash_screen_enabled: bool = False license_global_accept: bool = False integration_qgis_news_feed: bool = True diff --git a/tests/qgis/test_plg_rss_rdr.py b/tests/qgis/test_plg_rss_rdr.py deleted file mode 100644 index bac65597..00000000 --- a/tests/qgis/test_plg_rss_rdr.py +++ /dev/null @@ -1,34 +0,0 @@ -#! python3 # noqa E265 - -""" - Usage from the repo root folder: - - .. code-block:: bash - # for whole tests - python -m unittest tests.test_plg_rss_rdr - # for specific test - python -m unittest tests.test_plg_rss_rdr.TestRssReader.test_version_semver -""" - -# standard library -import unittest - -# project -from qtribu.logic import RssMiniReader - -# ############################################################################ -# ########## Classes ############# -# ################################ - - -class TestRssReader(unittest.TestCase): - def test_rss_reader(self): - """Test RSS reader basic behavior.""" - self.rss_rdr = RssMiniReader() - - -# ############################################################################ -# ####### Stand-alone run ######## -# ################################ -if __name__ == "__main__": - unittest.main() diff --git a/tests/qgis/test_utils_file_stats.py b/tests/qgis/test_utils_file_stats.py new file mode 100644 index 00000000..ecb1e190 --- /dev/null +++ b/tests/qgis/test_utils_file_stats.py @@ -0,0 +1,59 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + # for whole tests + python -m unittest tests.unit.test_utils_file_stats + # for specific test + python -m unittest tests.unit.test_utils_file_stats.TestUtilsFileStats.test_created_file_is_not_expired +""" + + +# standard library +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from time import sleep + +# project +from qtribu.__about__ import __title_clean__, __version__ +from qtribu.toolbelt.file_stats import is_file_older_than + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestUtilsFileStats(unittest.TestCase): + """Test utils related to files stats.""" + + def test_created_file_is_not_expired(self): + """Test file creation 'age' is OK.""" + with TemporaryDirectory( + f"{__title_clean__}_{__version__}_not_expired_" + ) as tempo_dir: + tempo_file = Path(tempo_dir, "really_recent_file.txt") + tempo_file.touch() + sleep(3) + self.assertFalse(is_file_older_than(Path(tempo_file))) + + def test_created_file_has_expired(self): + """Test file creation 'age' is too old.""" + with TemporaryDirectory( + prefix=f"{__title_clean__}_{__version__}_expired_" + ) as tempo_dir: + tempo_file = Path(tempo_dir, "not_so_really_recent_file.txt") + tempo_file.touch() + sleep(3) + self.assertTrue( + is_file_older_than(Path(tempo_file), expiration_rotating_hours=0) + ) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main()