Create multi page layout (#147)

Created two pages (home and company), page reloads after company
selection in dropdown or clicking the home button.
This commit is contained in:
KM-R
2023-09-26 18:38:40 +02:00
committed by GitHub
parent 5fa7cd230a
commit 9566276047
13 changed files with 261 additions and 71 deletions

12
poetry.lock generated
View File

@ -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]

View File

@ -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 = [

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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/<value>", 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),
)

View File

@ -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."),
]
)

View File

@ -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

View File

@ -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}"])

33
tests/ui/app_test.py Normal file
View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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: