From 5125bb65f378730a9581d9d3cfd61f96ff545db4 Mon Sep 17 00:00:00 2001
From: Robert Goldmann <deadlocker@gmx.de>
Date: Fri, 9 Oct 2020 19:56:13 +0200
Subject: [PATCH] #3 - split pages in tiles + introduce tileservice +
 scheduling

---
 Pipfile                                       |  4 +-
 src/DashboardLeaf.py                          | 57 ++++++++++++-
 src/page/Page.py                              | 32 -------
 src/page/PageManager.py                       | 34 ++++----
 src/page/pages/ClockPage.py                   | 20 -----
 src/tile/Tile.py                              | 36 ++++++++
 .../PageRegistry.py => tile/TileRegistry.py}  | 29 ++++---
 src/tile/TileService.py                       | 85 +++++++++++++++++++
 src/{page/pages => tile}/__init__.py          |  0
 src/tile/tiles/ClockTile.py                   | 20 +++++
 src/tile/tiles/__init__.py                    |  0
 11 files changed, 229 insertions(+), 88 deletions(-)
 delete mode 100644 src/page/Page.py
 delete mode 100644 src/page/pages/ClockPage.py
 create mode 100644 src/tile/Tile.py
 rename src/{page/PageRegistry.py => tile/TileRegistry.py} (51%)
 create mode 100644 src/tile/TileService.py
 rename src/{page/pages => tile}/__init__.py (100%)
 create mode 100644 src/tile/tiles/ClockTile.py
 create mode 100644 src/tile/tiles/__init__.py

diff --git a/Pipfile b/Pipfile
index 693b74c..68ba84a 100644
--- a/Pipfile
+++ b/Pipfile
@@ -13,10 +13,12 @@ python_version = "3"
 
 [packages]
 flask = "==1.1.2"
-gevent = "==20.6.1"
+gevent = "==20.9.0"
 TheCodeLabs-BaseUtils = "*"
 TheCodeLabs-FlaskUtils = "*"
 Pillow = "==7.2.0"
+flask-socketio= "==4.3.1"
+apscheduler = "==3.6.3"
 
 # services
 python-jenkins = "==1.5.0"
diff --git a/src/DashboardLeaf.py b/src/DashboardLeaf.py
index f46b401..2bd35f4 100644
--- a/src/DashboardLeaf.py
+++ b/src/DashboardLeaf.py
@@ -1,13 +1,18 @@
+import json
+import logging
 import os
 
 from TheCodeLabs_BaseUtils.DefaultLogger import DefaultLogger
 from TheCodeLabs_FlaskUtils.FlaskBaseApp import FlaskBaseApp
+from flask import Flask
+from flask_socketio import SocketIO
 
 from blueprints import Routes
 from logic import Constants
-from page.PageManager import PageManager
-from page.PageRegistry import PageRegistry
 from logic.services.JenkinsSingleJobService import JenkinsSingleJobService
+from page.PageManager import PageManager
+from tile.TileRegistry import TileRegistry
+from tile.TileService import TileService
 
 LOGGER = DefaultLogger().create_logger_if_not_exists(Constants.APP_NAME)
 
@@ -19,13 +24,57 @@ class DashboardLeaf(FlaskBaseApp):
 
     def __init__(self, appName: str):
         super().__init__(appName, os.path.dirname(__file__), LOGGER, serveRobotsTxt=True)
-        self._pageRegistry = PageRegistry('page.pages')
-        self._pageManager = PageManager(Constants.ROOT_DIR, self._pageRegistry)
+        self._pageManager = PageManager(Constants.ROOT_DIR)
+        self._tileRegistry = TileRegistry('tile.tiles')
+        self._socketio = None
+        self._tileService = None
+
+    def _create_flask_app(self):
+        app = Flask(self._rootDir)
+        self._socketio = SocketIO(app)
+        logging.getLogger('flask_socketio').setLevel(logging.ERROR)
+
+        @self._socketio.on('refresh', namespace='/update')
+        def Refresh(tileName):
+            raise NotImplementedError
+            # tileService.ForceRefresh(tileName)
+
+        @self._socketio.on('connect', namespace='/update')
+        def Connect():
+            raise NotImplementedError
+            # LOGGER.debug('Client connected')
+            # tileService.EmitFromCache()
+
+        self._tileService = self.__register_tiles(app)
+
+        return app
 
     def _register_blueprints(self, app):
         app.register_blueprint(Routes.construct_blueprint(self._settings, self._pageManager))
         return app
 
+    def __register_tiles(self, app) -> TileService:
+        tileService = TileService(self._socketio)
+
+        with open(os.path.join(Constants.ROOT_DIR, 'pageSettings.json'), 'r') as f:
+            config = json.load(f)
+
+        # TODO
+        for tileConfig in config[0]['tiles']:
+            tileType = tileConfig['tileType']
+            if tileType not in self._tileRegistry.get_all_available_tile_types():
+                LOGGER.error(f'Skipping unknown tile with type "{tileType}"')
+                continue
+
+            tile = self._tileRegistry.get_tile_by_type(tileType)(uniqueName=tileConfig['uniqueName'],
+                                                                 settings=tileConfig['settings'],
+                                                                 intervalInSeconds=tileConfig['intervalInSeconds'])
+            tileService.RegisterTile(tile)
+            app.register_blueprint(tile.ConstructBlueprint(tileService=tileService))
+
+        tileService.Run()
+        return tileService
+
 
 if __name__ == '__main__':
     website = DashboardLeaf(Constants.APP_NAME)
diff --git a/src/page/Page.py b/src/page/Page.py
deleted file mode 100644
index 7603a9b..0000000
--- a/src/page/Page.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import List, Dict
-
-from TheCodeLabs_BaseUtils import CachedService
-
-
-class Page(ABC):
-    """
-    Abstract page class. Custom implementations must inherit from this class in order to work.
-    Page implementations are dynamically scanned via the PageRegistry.
-    """
-    def __init__(self, name: str, settings: Dict):
-        self._name = name
-        self._settings = settings
-
-    # user can choose from dropdown, list, whatever in editor
-    @abstractmethod
-    def register_services(self) -> List[CachedService]:
-        pass
-
-    # user must implement this methods in website textarea in editor
-    @abstractmethod
-    def fetch(self, services: Dict) -> Dict:
-        pass
-
-    @abstractmethod
-    def render(self, data: Dict) -> str:
-        pass
-
-    def update(self) -> str:
-        data = self.fetch({})
-        return self.render(data)
diff --git a/src/page/PageManager.py b/src/page/PageManager.py
index 4445708..7c38746 100644
--- a/src/page/PageManager.py
+++ b/src/page/PageManager.py
@@ -2,21 +2,17 @@ import json
 import os
 from typing import List, Dict
 
-from page.Page import Page
-from page.PageRegistry import PageRegistry
-
 
 class PageManager:
     """
     Handles the page settings (order, additional settings per pages) and provides access to the corresponding page
     instances.
     """
-    def __init__(self, settingsFolder: str, pageRegistry: PageRegistry):
+    def __init__(self, settingsFolder: str):
         self._settingsFolder = settingsFolder
-        self._pageRegistry = pageRegistry
         self._pageSettingsPath = os.path.join(self._settingsFolder, 'pageSettings.json')
         self._pageSettings = self.__load_settings()
-        self._pageInstances = self.__create_page_instances()
+        # self._pageInstances = self.__create_page_instances()
 
     def __load_settings(self) -> List[Dict]:
         if not os.path.exists(self._pageSettingsPath):
@@ -29,20 +25,20 @@ class PageManager:
         with open(self._pageSettingsPath, 'w', encoding='UTF-8') as f:
             json.dump(self._pageSettings, f)
 
-    def __create_page_instances(self) -> Dict[str, Page]:
-        pageInstances = {}
-        for pageSetting in self._pageSettings:
-            pageType = pageSetting['pageType']
-            uniqueName = pageSetting['uniqueName']
-            settings = pageSetting['settings']
-            pageInstance = self._pageRegistry.get_page_by_type(pageType)
-            pageInstances[uniqueName] = pageInstance(uniqueName, settings)
-        return pageInstances
+    # def __create_page_instances(self) -> Dict:
+    #     pageInstances = {}
+    #     for pageSetting in self._pageSettings:
+    #         pageType = pageSetting['pageType']
+    #         uniqueName = pageSetting['uniqueName']
+    #         settings = pageSetting['settings']
+    #         pageInstance = self._pageRegistry.get_tile_by_type(pageType)
+    #         pageInstances[uniqueName] = pageInstance(uniqueName, settings)
+    #     return pageInstances
 
     def save_and_load(self):
         self.__save_settings()
         self._pageSettings = self.__load_settings()
-        self._pageInstances = self.__create_page_instances()
+        # self._pageInstances = self.__create_page_instances()
 
     def add_page(self, index: int, pageName: str, uniqueName: str, settings: Dict):
         self._pageSettings.insert(index, {'pageName': pageName, 'uniqueName': uniqueName, 'settings': settings})
@@ -53,7 +49,7 @@ class PageManager:
         self.save_and_load()
 
     def get_all_available_page_names(self):
-        return list(self._pageInstances.keys())
+        return [page['uniqueName'] for page in self._pageSettings]
 
-    def get_page_instance_by_name(self, name: str):
-        return self._pageInstances[name]
+    # def get_page_instance_by_name(self, name: str):
+    #     return self._pageInstances[name]
diff --git a/src/page/pages/ClockPage.py b/src/page/pages/ClockPage.py
deleted file mode 100644
index da25f1d..0000000
--- a/src/page/pages/ClockPage.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from datetime import datetime
-from typing import Dict, List
-
-from TheCodeLabs_BaseUtils import CachedService
-
-from page.Page import Page
-
-
-class ClockPage(Page):
-    def __init__(self, uniqueName: str, settings: Dict):
-        super().__init__(uniqueName, settings)
-
-    def register_services(self) -> List[CachedService]:
-        pass
-
-    def fetch(self, services: Dict) -> Dict:
-        return {'time': datetime.now()}
-
-    def render(self, data: Dict) -> str:
-        return f'Current time: {data["time"]}'
diff --git a/src/tile/Tile.py b/src/tile/Tile.py
new file mode 100644
index 0000000..f7144b3
--- /dev/null
+++ b/src/tile/Tile.py
@@ -0,0 +1,36 @@
+from abc import ABC, abstractmethod
+from typing import Dict, Tuple
+
+
+class Tile(ABC):
+    """
+    Abstract tile class. Custom implementations must inherit from this class in order to work.
+    Tile implementations are dynamically scanned via the TileRegistry.
+    """
+
+    def __init__(self, uniqueName: str, settings: Dict, intervalInSeconds: int):
+        self._uniqueName = uniqueName
+        self._settings = settings
+        self._intervalInSeconds = intervalInSeconds
+
+    def get_uniqueName(self) -> str:
+        return self._uniqueName
+
+    def get_intervalInSeconds(self) -> int:
+        return self._intervalInSeconds
+
+    @abstractmethod
+    def fetch(self, services: Dict) -> Dict:
+        pass
+
+    @abstractmethod
+    def render(self, data: Dict) -> str:
+        pass
+
+    def update(self) -> Tuple[str, str]:
+        data = self.fetch({})
+        return self._uniqueName, self.render(data)
+
+    @abstractmethod
+    def ConstructBlueprint(self, *args, **kwargs):
+        pass
diff --git a/src/page/PageRegistry.py b/src/tile/TileRegistry.py
similarity index 51%
rename from src/page/PageRegistry.py
rename to src/tile/TileRegistry.py
index 4c33baf..01e3e33 100644
--- a/src/page/PageRegistry.py
+++ b/src/tile/TileRegistry.py
@@ -1,28 +1,30 @@
 import inspect
 import logging
 import pkgutil
+from typing import List
 
 from logic import Constants
-from page.Page import Page
+from tile.Tile import Tile
 
 LOGGER = logging.getLogger(Constants.APP_NAME)
 
 
-class PageRegistry:
+class TileRegistry:
     """
-    Scans for available page implementations and provides access to them via class name
+    Scans for available tile implementations and provides access to them via class name
     """
+
     def __init__(self, package: str):
         self._package = package
-        self._availablePages = {}
+        self._availableTiles = {}
         self.reload()
 
     def reload(self):
-        self._availablePages = self.__scan_package(self._package)
+        self._availableTiles = self.__scan_package(self._package)
 
     @staticmethod
     def __scan_package(package: str):
-        availablePages = {}
+        availableTiles = {}
         imported_package = __import__(package, fromlist=['blah'])
 
         for _, pluginName, isPkg in pkgutil.iter_modules(imported_package.__path__, imported_package.__name__ + '.'):
@@ -30,10 +32,13 @@ class PageRegistry:
                 pluginModule = __import__(pluginName, fromlist=['blah'])
                 clsMembers = inspect.getmembers(pluginModule, inspect.isclass)
                 for (_, c) in clsMembers:
-                    if issubclass(c, Page) and c is not Page:
-                        availablePages[c.__name__] = c
-        LOGGER.debug(f'Found {len(availablePages)} pages {list(availablePages.keys())}')
-        return availablePages
+                    if issubclass(c, Tile) and c is not Tile:
+                        availableTiles[c.__name__] = c
+        LOGGER.debug(f'Found {len(availableTiles)} tiles {list(availableTiles.keys())}')
+        return availableTiles
+
+    def get_tile_by_type(self, tileType: str) -> Tile:
+        return self._availableTiles[tileType]
 
-    def get_page_by_type(self, pageType: str) -> Page:
-        return self._availablePages[pageType]
+    def get_all_available_tile_types(self) -> List[str]:
+        return list(self._availableTiles.keys())
diff --git a/src/tile/TileService.py b/src/tile/TileService.py
new file mode 100644
index 0000000..b8ee759
--- /dev/null
+++ b/src/tile/TileService.py
@@ -0,0 +1,85 @@
+import json
+import logging
+from datetime import datetime
+from typing import Dict
+
+from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
+from apscheduler.job import Job
+from apscheduler.schedulers.gevent import GeventScheduler
+
+from logic import Constants
+from tile.Tile import Tile
+
+LOGGER = logging.getLogger(Constants.APP_NAME)
+
+
+class TileService:
+    def __init__(self, socketio):
+        self.__socketio = socketio
+        self.__jobs = {}
+        self.__tiles = {}
+        self.__cache = {}
+        self.__scheduler = GeventScheduler()
+
+    def RegisterTile(self, tile: Tile):
+        name = tile.get_uniqueName()
+        if name in self.__jobs:
+            LOGGER.warning(f'Tile "{name}" already registered')
+
+        job = self.__scheduler.add_job(tile.update, 'interval',
+                                       seconds=tile.get_intervalInSeconds(),
+                                       next_run_time=datetime.now())
+
+        self.__jobs[name] = job
+        self.__cache[name] = None
+        self.__tiles[name] = tile
+        LOGGER.debug(f'Registered "{name}" (scheduled every {tile.get_intervalInSeconds()} seconds)')
+
+    def UnregisterTile(self, tile: Tile):
+        name = tile.get_uniqueName()
+        if name not in self.__jobs:
+            LOGGER.warning(f'Tile "{name}" is not registered')
+
+        self.__jobs[name].remove()
+        del self.__jobs[name]
+        del self.__cache[name]
+        del self.__tiles[name]
+        LOGGER.debug(f'Unregistered "{name}"')
+
+    def EmitFromCache(self):
+        for name, value in self.__cache.items():
+            self.__EmitUpdate(name, value)
+
+    def Run(self):
+        def JobListener(event):
+            if event.exception:
+                LOGGER.error(event.exception)
+            else:
+                name, value = event.retval
+                self.__cache[name] = value
+                self.__EmitUpdate(name, value)
+
+        self.__scheduler.add_listener(JobListener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
+        self.__scheduler.start()
+
+    def __EmitUpdate(self, uniqueName: str, content: str):
+        data = {'uniqueName': uniqueName, 'content': content}
+        self.__socketio.emit('tileUpdate', json.dumps(data), namespace='/update')
+
+    def GetTiles(self) -> Dict[str, Tile]:
+        return self.__tiles
+
+    def GetJobs(self) -> Dict[str, Job]:
+        return self.__jobs
+
+    def ForceRefresh(self, tileName):
+        job = self.__GetJobByName(tileName)
+        if job is not None:
+            LOGGER.debug(f'Manual refresh for tile "{tileName}"')
+            job.modify(next_run_time=datetime.now())
+
+    def __GetJobByName(self, tileName) -> Job or None:
+        if tileName not in self.__jobs:
+            LOGGER.warning(f'Ignoring request to refresh non-existing tile "{tileName}"')
+            return None
+        return self.__jobs[tileName]
diff --git a/src/page/pages/__init__.py b/src/tile/__init__.py
similarity index 100%
rename from src/page/pages/__init__.py
rename to src/tile/__init__.py
diff --git a/src/tile/tiles/ClockTile.py b/src/tile/tiles/ClockTile.py
new file mode 100644
index 0000000..70c3337
--- /dev/null
+++ b/src/tile/tiles/ClockTile.py
@@ -0,0 +1,20 @@
+from datetime import datetime
+from typing import Dict
+
+from flask import Blueprint
+
+from tile.Tile import Tile
+
+
+class ClockTile(Tile):
+    def __init__(self, uniqueName: str, settings: Dict, intervalInSeconds: int):
+        super().__init__(uniqueName, settings, intervalInSeconds)
+
+    def fetch(self, services: Dict) -> Dict:
+        return {'time': datetime.now()}
+
+    def render(self, data: Dict) -> str:
+        return f'Current time: {data["time"]}'
+
+    def ConstructBlueprint(self, *args, **kwargs):
+        return Blueprint('clock_{}'.format(self.get_uniqueName()), __name__)
diff --git a/src/tile/tiles/__init__.py b/src/tile/tiles/__init__.py
new file mode 100644
index 0000000..e69de29
-- 
GitLab