From 956627604797f15b18730ebf752f063248dfa93b Mon Sep 17 00:00:00 2001 From: KM-R <129882581+KM-R@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:38:40 +0200 Subject: [PATCH] Create multi page layout (#147) Created two pages (home and company), page reloads after company selection in dropdown or clicking the home button. --- poetry.lock | 12 +-- pyproject.toml | 7 +- src/aki_prj23_transparenzregister/ui/app.py | 95 +++++++++++++++++++ .../ui/company_finance_dash.py | 2 +- .../ui/company_stats_dash.py | 51 ---------- .../ui/pages/company.py | 38 ++++++++ .../ui/pages/home.py | 12 +++ .../ui/session_handler.py | 8 ++ .../ui/ui_elements.py | 38 +++++++- tests/ui/app_test.py | 33 +++++++ tests/ui/company_page_test.py | 27 ++++++ tests/ui/company_stats_dash_test.py | 7 -- tests/ui/ui_elements_test.py | 2 +- 13 files changed, 261 insertions(+), 71 deletions(-) create mode 100644 src/aki_prj23_transparenzregister/ui/app.py delete mode 100644 src/aki_prj23_transparenzregister/ui/company_stats_dash.py create mode 100644 src/aki_prj23_transparenzregister/ui/pages/company.py create mode 100644 src/aki_prj23_transparenzregister/ui/pages/home.py create mode 100644 src/aki_prj23_transparenzregister/ui/session_handler.py create mode 100644 tests/ui/app_test.py create mode 100644 tests/ui/company_page_test.py delete mode 100644 tests/ui/company_stats_dash_test.py diff --git a/poetry.lock b/poetry.lock index b317719..83264b6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4796,13 +4796,13 @@ stats = ["scipy (>=1.3)", "statsmodels (>=0.10)"] [[package]] name = "selenium" -version = "4.12.0" +version = "4.13.0" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "selenium-4.12.0-py3-none-any.whl", hash = "sha256:b2c48b1440db54a0653300d9955f5421390723d53b36ec835e18de8e13bbd401"}, - {file = "selenium-4.12.0.tar.gz", hash = "sha256:95be6aa449a0ab4ac1198bb9de71bbe9170405e04b9752f4b450dc7292a21828"}, + {file = "selenium-4.13.0-py3-none-any.whl", hash = "sha256:f0f9185c01ae249a321529c4e3aa0edc2a900642e61fdbb76988cd72d2762ece"}, + {file = "selenium-4.13.0.tar.gz", hash = "sha256:3c413a4f1b8af67824703195e3b1c19cfb1c3186c799efa035d55fd59d6dd59f"}, ] [package.dependencies] @@ -5486,13 +5486,13 @@ telegram = ["requests"] [[package]] name = "traitlets" -version = "5.10.0" +version = "5.10.1" description = "Traitlets Python configuration system" optional = false python-versions = ">=3.8" files = [ - {file = "traitlets-5.10.0-py3-none-any.whl", hash = "sha256:417745a96681fbb358e723d5346a547521f36e9bd0d50ba7ab368fff5d67aa54"}, - {file = "traitlets-5.10.0.tar.gz", hash = "sha256:f584ea209240466e66e91f3c81aa7d004ba4cf794990b0c775938a1544217cd1"}, + {file = "traitlets-5.10.1-py3-none-any.whl", hash = "sha256:07ab9c5bf8a0499fd7b088ba51be899c90ffc936ffc797d7b6907fc516bcd116"}, + {file = "traitlets-5.10.1.tar.gz", hash = "sha256:db9c4aa58139c3ba850101913915c042bdba86f7c8a0dda1c6f7f92c5da8e542"}, ] [package.extras] diff --git a/pyproject.toml b/pyproject.toml index 1f68ddb..d71e9b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ warn_unused_ignores = false authors = ["AKI Projektgruppe 23"] description = "A project analysing the german transparenzregister and other data sources to find shared business interests and shared personal and other links for lots of companies." name = "aki-prj23-transparenzregister" -packages = [{include = "aki_prj23_transparenzregister", from = "src"}] +packages = [{ include = "aki_prj23_transparenzregister", from = "src" }] readme = "README.md" version = "0.1.0" @@ -41,7 +41,7 @@ cachetools = "^5.3.1" dash = "^2.13.0" dash-auth = "^2.0.0" dash-bootstrap-components = "^1.5.0" -deutschland = {git = "https://github.com/TrisNol/deutschland.git", branch = "hotfix/python-3.11-support"} +deutschland = { git = "https://github.com/TrisNol/deutschland.git", branch = "hotfix/python-3.11-support" } loguru = "^0.7.0" matplotlib = "^3.7.2" psycopg2-binary = "^2.9.7" @@ -59,7 +59,7 @@ processing = [] web-server = ["dash", "dash-auth", "dash-bootstrap-components", "matplotlib", "seaborn"] [tool.poetry.group.develop.dependencies] -black = {extras = ["jupyter"], version = "^23.9.1"} +black = { extras = ["jupyter"], version = "^23.9.1" } jupyterlab = "^4.0.6" nbconvert = "^7.8.0" openpyxl = "^3.1.2" @@ -105,6 +105,7 @@ pytest-repeat = "^0.9.1" copy-sql = "aki_prj23_transparenzregister.utils.sql.copy_sql:copy_db_cli" data-transfer = {reference = "aki_prj23_transparenzregister.utils.data_transfer:transfer_data", extras = ["processing"], type = "console"} reset-sql = {reference = "aki_prj23_transparenzregister.utils.sql.connector:reset_all_tables", extras = ["processing"], type = "console"} +webserver = "aki_prj23_transparenzregister.ui.app:main" [tool.ruff] exclude = [ diff --git a/src/aki_prj23_transparenzregister/ui/app.py b/src/aki_prj23_transparenzregister/ui/app.py new file mode 100644 index 0000000..c7e5822 --- /dev/null +++ b/src/aki_prj23_transparenzregister/ui/app.py @@ -0,0 +1,95 @@ +"""Main Dash app.""" + +import dash +import dash_bootstrap_components as dbc +from cachetools import TTLCache, cached +from dash import Dash, Input, Output, dcc, html + +from aki_prj23_transparenzregister.config.config_providers import JsonFileConfigProvider +from aki_prj23_transparenzregister.ui import ui_elements +from aki_prj23_transparenzregister.ui.session_handler import SessionHandler +from aki_prj23_transparenzregister.utils.sql import connector + +app = Dash( + __name__, use_pages=True, external_stylesheets=[dbc.icons.BOOTSTRAP] +) # use dbc for icons + +app.layout = html.Div( + className="page_content", + children=[ + ui_elements.create_header(ui_elements.get_options(SessionHandler.session)), + dash.page_container, + dcc.Location(id="url", refresh=True), + ], +) + + +@app.callback( + Output("url", "href", allow_duplicate=True), + Input("home-button", "n_clicks"), + prevent_initial_call=True, +) +def go_to_home(click: int) -> str: + """Updates pages after using the home button. + + Args: + click: Recognizes the number of clicks on the home icon + + Returns: + Returns the href of the home page. + """ + return "/" + + +@app.callback( + Output("url", "href", allow_duplicate=True), + Input("select_company", "value"), + prevent_initial_call=True, +) +def go_to_company_page(value: int) -> str: + """Updates pages after selecting a company or using the home button. + + Args: + value: Represents the company id of the chosen company in the dropdown + + Returns: + Returns the href of the company page. + """ + return f"/Unternehmensdetails/{value}" + + +@app.callback( + Output("select_company", "options"), Input("select_company", "search_value") +) +@cached(cache=TTLCache(maxsize=100, ttl=60), key=lambda search_value: search_value) +def update_options(search_value: str) -> list: + """Update dropdown options based on user input. + + Args: + search_value: The input string in the dropdown field entered by the user. + + Returns: + The available companies matching the input. + """ + if not search_value: + return [ + {"label": o, "value": key} + for key, o in ui_elements.get_options(SessionHandler.session).items() + ] + return [ + {"label": o, "value": key} + for key, o in ui_elements.get_options(SessionHandler.session).items() + if search_value.upper() in o.upper() + ] + + +def main() -> None: + """The main application starting the Dashboard.""" + SessionHandler.session = connector.get_session( + JsonFileConfigProvider("./secrets.json") + ) + app.run(debug=False) + + +if __name__ == "__main__": + main() diff --git a/src/aki_prj23_transparenzregister/ui/company_finance_dash.py b/src/aki_prj23_transparenzregister/ui/company_finance_dash.py index 9a621d5..5318ba9 100644 --- a/src/aki_prj23_transparenzregister/ui/company_finance_dash.py +++ b/src/aki_prj23_transparenzregister/ui/company_finance_dash.py @@ -78,7 +78,7 @@ if __name__ == "__main__": return ( create_company_header(selected_company), create_company_stats(selected_company_stats), - create_tabs(selected_finance_df), + create_tabs(value_chosen, selected_finance_df), ) app.run_server(debug=False) diff --git a/src/aki_prj23_transparenzregister/ui/company_stats_dash.py b/src/aki_prj23_transparenzregister/ui/company_stats_dash.py deleted file mode 100644 index 44fd66e..0000000 --- a/src/aki_prj23_transparenzregister/ui/company_stats_dash.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Dash.""" - -import pandas as pd -from dash import Dash, Input, Output, callback, dash_table, dcc, html - -from aki_prj23_transparenzregister.config.config_providers import ( - EnvironmentConfigProvider, -) -from aki_prj23_transparenzregister.ui.protection import add_auth -from aki_prj23_transparenzregister.utils.sql import entities -from aki_prj23_transparenzregister.utils.sql.connector import ( - get_session, - init_db, -) - -if __name__ == "__main__": - session = get_session(EnvironmentConfigProvider()) - init_db(session) - query = session.query(entities.Company) - - companies_df: pd.DataFrame = pd.read_sql(str(query), session.bind) # type: ignore - app = Dash(__name__) - app.title = "Company stats Dashboard" - add_auth(app) - - app.layout = html.Div( - [ - html.H1(children="Company Data", style={"textAlign": "center"}), - html.Div( - [ - dcc.Dropdown( - companies_df.company_name.unique(), - "Firma 1", - id="dropdown-selection", - ), - ] - ), - html.Div(id="data_table"), - ] - ) - - @callback(Output("data_table", "children"), Input("dropdown-selection", "value")) - def display_table(value: str) -> dash_table: - """Output table with company stats based on dropdown value.""" - dff = companies_df[companies_df.company_name == value] - return dash_table.DataTable( - data=dff.to_dict("records"), - columns=[{"id": c, "name": c} for c in companies_df.columns], - ) - - app.run(debug=False) diff --git a/src/aki_prj23_transparenzregister/ui/pages/company.py b/src/aki_prj23_transparenzregister/ui/pages/company.py new file mode 100644 index 0000000..f304c3c --- /dev/null +++ b/src/aki_prj23_transparenzregister/ui/pages/company.py @@ -0,0 +1,38 @@ +"""Company detail page.""" +import dash +from dash import html + +from aki_prj23_transparenzregister.ui import ui_elements +from aki_prj23_transparenzregister.ui.session_handler import SessionHandler + +dash.register_page( + __name__, path_template="/Unternehmensdetails/", title="Unternehmensdetails" +) + + +def layout(value: str = "1") -> html: + """Defines the layout of the company page. + + Args: + value: Company id of the chosen company on the home page dropdown. + + Returns: + The html divs for the company page. + """ + if not value: + return html.Div("Diese Seite kann nicht angezeigt werden.") + session = SessionHandler.session + if not session: + raise ValueError("Initialise the session first.") + company_id = int(value) + # get all necessary data of the selected company + selected_company_stats = ui_elements.get_company_data(session).loc[company_id] + selected_company_name = selected_company_stats.loc["company_name"] + finance_df = ui_elements.get_finance_data(session) + selected_finance_df = finance_df.loc[finance_df["company_id"] == value] + # create all company page divs + return ( + ui_elements.create_company_header(selected_company_name), + ui_elements.create_company_stats(selected_company_stats), + ui_elements.create_tabs(company_id, selected_finance_df), + ) diff --git a/src/aki_prj23_transparenzregister/ui/pages/home.py b/src/aki_prj23_transparenzregister/ui/pages/home.py new file mode 100644 index 0000000..169c21e --- /dev/null +++ b/src/aki_prj23_transparenzregister/ui/pages/home.py @@ -0,0 +1,12 @@ +"""Content of home page.""" +import dash +from dash import html + +dash.register_page(__name__, path="/") + +layout = html.Div( + [ + html.H1("This is our Home page", style={"margin": 0}), + html.Div("This is our Home page content."), + ] +) diff --git a/src/aki_prj23_transparenzregister/ui/session_handler.py b/src/aki_prj23_transparenzregister/ui/session_handler.py new file mode 100644 index 0000000..cba5d96 --- /dev/null +++ b/src/aki_prj23_transparenzregister/ui/session_handler.py @@ -0,0 +1,8 @@ +"""A module containing a Session handler for Dash.""" +from sqlalchemy.orm import Session + + +class SessionHandler: + """A Session to be shared over the Dashboard.""" + + session: Session | None = None diff --git a/src/aki_prj23_transparenzregister/ui/ui_elements.py b/src/aki_prj23_transparenzregister/ui/ui_elements.py index ca06b1a..539b38f 100644 --- a/src/aki_prj23_transparenzregister/ui/ui_elements.py +++ b/src/aki_prj23_transparenzregister/ui/ui_elements.py @@ -2,6 +2,7 @@ import pandas as pd import plotly.graph_objs as go +from cachetools import TTLCache, cached from dash import dash_table, dcc, html from sqlalchemy.engine import Engine from sqlalchemy.orm import Session @@ -38,7 +39,7 @@ def get_company_data(session: Session) -> pd.DataFrame: def get_finance_data(session: Session) -> pd.DataFrame: - """Creates a session to the database and get's all available company data. + """Collects all available company data. Args: session: A session connecting to the database. @@ -57,6 +58,24 @@ def get_finance_data(session: Session) -> pd.DataFrame: return pd.read_sql(str(query_finance), engine) +@cached( # type: ignore + cache=TTLCache(maxsize=1, ttl=300), + key=lambda session: 0 if session is None else str(session.bind), +) +def get_options(session: Session | None) -> dict[int, str]: + """Collects the search options for the companies. + + Args: + session: A session connecting to the database. + + Returns: + A dict containing the company id as key and its name. + """ + if not session: + return {} + return get_company_data(session)["company_name"].to_dict() + + def create_header(options: dict) -> html: """Creates header for dashboard. @@ -73,6 +92,8 @@ def create_header(options: dict) -> html: className="header-title", children=[ html.I( + id="home-button", + n_clicks=0, className="bi-house-door-fill", ), html.H1( @@ -230,7 +251,7 @@ def create_company_stats(selected_company_data: pd.Series) -> html: ) -def create_tabs(selected_finance_df: pd.DataFrame) -> html: +def create_tabs(selected_company_id: int, selected_finance_df: pd.DataFrame) -> html: """Create tabs for more company information. Args: @@ -271,6 +292,7 @@ def create_tabs(selected_finance_df: pd.DataFrame) -> html: value="tab-4", className="tab-style", selected_className="selected-tab-style", + children=[network_layout(selected_company_id)], ), ], ), @@ -329,3 +351,15 @@ def financials_figure(selected_finance_df: pd.DataFrame, metric: str) -> go.Figu plot_bgcolor=COLORS["light"], ) return fig_line + + +def network_layout(selected_company_id: int) -> html: + """Create network tab. + + Args: + selected_company_id: Id of the chosen company in the dropdown. + + Returns: + The html div to create the network tab of the company page. + """ + return html.Div([f"Netzwerk von Unternehmen mit ID: {selected_company_id}"]) diff --git a/tests/ui/app_test.py b/tests/ui/app_test.py new file mode 100644 index 0000000..e4cde6a --- /dev/null +++ b/tests/ui/app_test.py @@ -0,0 +1,33 @@ +"""Test for the main app dashboard.""" +from collections.abc import Generator + +import pytest +from sqlalchemy.orm import Session + +from aki_prj23_transparenzregister.ui import app +from aki_prj23_transparenzregister.ui.session_handler import SessionHandler + + +@pytest.fixture(autouse=True) +def _set_session(full_db: Session) -> Generator[None, None, None]: + """Sets a session for the dash application to be used.""" + SessionHandler.session = full_db + yield + SessionHandler.session = None + + +def test_import() -> None: + """Checks if an import of the dash app can be made.""" + assert app is not None + + +def test_go_to_home() -> None: + """Checks if the go_to_home callback yields a result.""" + output = app.go_to_home(1) + assert output == "/" + + +def test_go_to_company_page() -> None: + """Checks if the go_to_company_page callback yields a result.""" + output = app.go_to_company_page(1) + assert output == "/Unternehmensdetails/1" diff --git a/tests/ui/company_page_test.py b/tests/ui/company_page_test.py new file mode 100644 index 0000000..18b7870 --- /dev/null +++ b/tests/ui/company_page_test.py @@ -0,0 +1,27 @@ +"""Tests for the company page dashboard.""" +from collections.abc import Generator + +import pytest +from sqlalchemy.orm import Session + +from aki_prj23_transparenzregister.ui.pages import company +from aki_prj23_transparenzregister.ui.session_handler import SessionHandler + + +@pytest.fixture(autouse=True) +def _set_session(full_db: Session) -> Generator[None, None, None]: + """Sets a session for the dash application to be used.""" + SessionHandler.session = full_db + yield + SessionHandler.session = None + + +def test_import() -> None: + """Checks if an import of the company page can be made.""" + assert company is not None + + +def test_layout() -> None: + """Checks if the company page can be created.""" + selected_company_id = "2" + company.layout(selected_company_id) diff --git a/tests/ui/company_stats_dash_test.py b/tests/ui/company_stats_dash_test.py deleted file mode 100644 index fa8bbcd..0000000 --- a/tests/ui/company_stats_dash_test.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Test the compy stats dash file.""" -from aki_prj23_transparenzregister.ui import company_stats_dash - - -def test_company_stats_dash_import() -> None: - """Since there is no single method to test the import is tested instead.""" - assert company_stats_dash diff --git a/tests/ui/ui_elements_test.py b/tests/ui/ui_elements_test.py index 21650df..8071715 100644 --- a/tests/ui/ui_elements_test.py +++ b/tests/ui/ui_elements_test.py @@ -105,7 +105,7 @@ def test_create_tabs(full_db: Session) -> None: selected_finance_df = finance_df.loc[ finance_df["company_id"] == selected_company_id ] - ui_elements.create_tabs(selected_finance_df) + ui_elements.create_tabs(selected_company_id, selected_finance_df) def test_kennzahlen_layout(full_db: Session) -> None: