diff --git a/src/gisaf/api/dashboard.py b/src/gisaf/api/dashboard.py
new file mode 100644
index 0000000..59e7ac4
--- /dev/null
+++ b/src/gisaf/api/dashboard.py
@@ -0,0 +1,137 @@
+import logging
+from pathlib import Path
+from fastapi import Depends, FastAPI, HTTPException, status, responses
+from sqlalchemy.orm import selectinload
+from sqlmodel import select
+
+from gisaf.config import conf
+from gisaf.database import pandas_query, fastapi_db_session as db_session
+from gisaf.models.authentication import User
+from gisaf.models.dashboard import (
+ DashboardPage, DashboardPageSection,
+ DashboadPageSectionType, DashboardPage_,
+ DashboardGroup, DashboardHome,
+)
+from gisaf.models.misc import NotADataframeError
+from gisaf.security import get_current_active_user
+
+logger = logging.getLogger(__name__)
+
+
+api = FastAPI(
+ default_response_class=responses.ORJSONResponse,
+)
+
+@api.get('/groups')
+async def get_groups(
+ db_session: db_session,
+ ) -> list[DashboardGroup]:
+ query = select(DashboardPage)
+ data = await db_session.exec(query)
+ groups: dict[str, DashboardPage_] = {}
+ for page in data.all():
+ page_field = DashboardPage_(
+ name=page.name,
+ group=page.group,
+ description=page.description,
+ )
+ group = groups.get(page.group)
+ if group is None:
+ group = DashboardGroup(
+ name=page.group,
+ pages=[page_field]
+ )
+ groups[page.group] = group
+ else:
+ group.pages.append(page_field)
+ return groups.values()
+
+
+@api.get('/home')
+async def get_home() -> DashboardHome:
+ content_path = Path(conf.gisaf.dashboard_home.content_file).expanduser()
+ footer_path = Path(conf.gisaf.dashboard_home.footer_file).expanduser()
+ if content_path.is_file():
+ content = content_path.read_text()
+ else:
+ content = 'Gisaf is free, open source software for geomatics and GIS: Gisaf.'
+ if footer_path.is_file():
+ footer = footer_path.read_text()
+ else:
+ footer = '
'
+ return DashboardHome(
+ title=conf.gisaf.dashboard_home.title,
+ content=content,
+ footer=footer,
+ )
+
+
+@api.get('/page/{group}/{name}')
+async def get_dashboard_page(group: str, name: str,
+ db_session: db_session,
+ user: User = Depends(get_current_active_user),
+ ) -> DashboardPage_:
+ query1 = select(DashboardPage).where((DashboardPage.name==name)
+ & (DashboardPage.group==group))
+ data1 = await db_session.exec(query1)
+ page = data1.one_or_none()
+ if not page:
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
+ query2 = select(DashboardPageSection)\
+ .where(DashboardPageSection.dashboard_page_id==page.id)\
+ .options(selectinload(DashboardPageSection.dashboard_page))\
+ .order_by(DashboardPageSection.name)
+ data2 = await db_session.exec(query2)
+ sections = data2.all()
+ if page.viewable_role:
+ if not(user and user.has_role(page.viewable_role)):
+ username = user.username if user is not None else "Anonymous"
+ logger.info(f'{username} tried to access dashboard page {name}')
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED)
+ dashboard_page = DashboardPage_(
+ name=page.name,
+ group=page.group,
+ description=page.description,
+ html=page.html,
+ time=page.time,
+ notebook=page.get_notebook_url(),
+ attachment=page.get_attachment_url(),
+ expandedPanes=[
+ p.strip()
+ for p in page.expanded_panes.split(',')
+ ] if page.expanded_panes else [],
+ sections=[
+ DashboadPageSectionType(
+ name=dps.name,
+ plot=dps.get_plot_url()
+ )
+ for dps in sections
+ ]
+ )
+ try:
+ df = page.get_page_df()
+ if df is not None:
+ ## TODO: plot as external file, like for sections
+ ## Convert Geopandas dataframe to Pandas
+ if isinstance(df, gpd.GeoDataFrame):
+ gdf = pd.DataFrame(df.drop(columns=['geometry']))
+ df = gdf
+ dashboard_page.dfData = df.to_json(orient='table', double_precision=2)
+ except NotADataframeError:
+ logger.warning(f'Dashboard: cannot read dataframe for page {page.name}')
+ except Exception as err:
+ logger.warning(f'Dashboard: cannot add dataframe for page {page.name}, see debug message')
+ logger.exception(err)
+ if page.plot:
+ try:
+ plot = page.get_plot()
+ plotData = {
+ 'data': [d.to_plotly_json() for d in plot.data],
+ 'layout': plot.layout.to_plotly_json(),
+ }
+ except Exception as err:
+ logger.warning(f'Dashboard: cannot add plot for page {page.name}, see debug message')
+ logger.exception(err)
+ else:
+ dashboard_page.plotData = dumps(plotData, cls=NumpyEncoder)
+ return dashboard_page
\ No newline at end of file
diff --git a/src/gisaf/geoapi.py b/src/gisaf/api/geoapi.py
similarity index 100%
rename from src/gisaf/geoapi.py
rename to src/gisaf/api/geoapi.py
diff --git a/src/gisaf/api.py b/src/gisaf/api/v2.py
similarity index 97%
rename from src/gisaf/api.py
rename to src/gisaf/api/v2.py
index 6159895..c051a24 100644
--- a/src/gisaf/api.py
+++ b/src/gisaf/api/v2.py
@@ -28,6 +28,7 @@ from gisaf.models.to_migrate import (
FeatureInfo, InfoItem, Attachment, InfoCategory
)
from gisaf.live_utils import get_live_feature_info
+from gisaf.api.dashboard import api as dashboard_api
logger = logging.getLogger(__name__)
@@ -36,8 +37,7 @@ api = FastAPI(
default_response_class=responses.ORJSONResponse,
)
#api.add_middleware(SessionMiddleware, secret_key=conf.crypto.secret)
-
-#db_session = Annotated[AsyncSession, Depends(get_db_session)]
+api.mount('/dashboard', dashboard_api)
@api.get('/bootstrap')
@@ -62,18 +62,6 @@ async def login_for_access_token(
expires_delta=timedelta(seconds=conf.crypto.expire))
return Token(access_token=access_token, token_type='bearer')
-@api.get("/list")
-async def list_data_providers() -> list[DataProvider]:
- """
- Return a list of data providers, for use with the api (graphs, etc)
- :return:
- """
- return [
- DataProvider(
- name=model.get_store_name(),
- values=[value.get_store_name() for value in values]
- ) for model, values in registry.values_for_model.items()]
-
@api.get("/users")
async def get_users(
@@ -107,6 +95,19 @@ async def get_categories_p(
df = await db_session.run_sync(pandas_query, query)
return df.to_dict(orient="records")
+# @api.get("/list")
+@api.get("/data-providers")
+async def list_data_providers() -> list[DataProvider]:
+ """
+ Return a list of data providers, for use with the api (graphs, etc)
+ :return:
+ """
+ return [
+ DataProvider(
+ name=model.get_store_name(),
+ values=[value.get_store_name() for value in values]
+ ) for model, values in registry.values_for_model.items()]
+
@api.get("/stores")
async def get_stores() -> list[Store]:
df = registry.stores.reset_index().\
diff --git a/src/gisaf/application.py b/src/gisaf/application.py
index 5d97e3e..c0daa2e 100644
--- a/src/gisaf/application.py
+++ b/src/gisaf/application.py
@@ -3,8 +3,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, responses
-from gisaf.api import api
-from gisaf.geoapi import api as geoapi
+from gisaf.api.v2 import api
+from gisaf.api.geoapi import api as geoapi
from gisaf.config import conf
from gisaf.registry import registry
from gisaf.redis_tools import setup_redis, setup_redis_cache, shutdown_redis
diff --git a/src/gisaf/config.py b/src/gisaf/config.py
index 0ce2131..8fd2089 100644
--- a/src/gisaf/config.py
+++ b/src/gisaf/config.py
@@ -213,7 +213,7 @@ class Plot(BaseSettings):
class Dashboard(BaseSettings):
base_source_url: str
base_storage_dir: str
- base_storage_url: str
+ base_storage_url: str = '/dashboard-attachment/'
class Widgets(BaseSettings):
footer: str
diff --git a/src/gisaf/models/authentication.py b/src/gisaf/models/authentication.py
index ff07b93..986101e 100644
--- a/src/gisaf/models/authentication.py
+++ b/src/gisaf/models/authentication.py
@@ -32,12 +32,14 @@ class User(UserBase, table=True):
password: str | None = None
def can_view(self, model) -> bool:
- viewable_role = getattr(model, 'viewable_role', None)
- if viewable_role:
- return viewable_role in (role.name for role in self.roles)
+ role = getattr(model, 'viewable_role', None)
+ if role:
+ return self.has_role(role)
else:
return True
+ def has_role(self, role: str) -> bool:
+ return role in (role.name for role in self.roles)
class RoleBase(SQLModel):
name: str = Field(unique=True)
diff --git a/src/gisaf/models/dashboard.py b/src/gisaf/models/dashboard.py
new file mode 100644
index 0000000..183f000
--- /dev/null
+++ b/src/gisaf/models/dashboard.py
@@ -0,0 +1,296 @@
+from io import BytesIO
+from pickle import loads
+from pathlib import Path
+from datetime import datetime
+import logging
+
+from sqlmodel import Field, Relationship, String
+from pydantic import BaseModel
+import pandas as pd
+
+from gisaf.config import conf
+from gisaf.models.metadata import gisaf
+from gisaf.models.models_base import Model
+from gisaf.models.misc import NotADataframeError
+
+logger = logging.getLogger(__name__)
+
+try:
+ import matplotlib.pyplot as plt
+except ImportError:
+ plt = None
+
+
+class DashboardPageSource(Model, table=True):
+ __tablename__ = 'dashboard_page_source'
+ __table_args__ = gisaf.table_args
+
+ id: str = Field(primary_key=True)
+ name: str
+
+
+class DashboardPageCommon:
+ """
+ Base class for DashboardPage and DashboardPageSection, where some methods
+ are common, eg. attachments
+ """
+
+ def get_attachment_url(self):
+ ## Serve through web front-end (nginx static file)
+ if not self.attachment:
+ return
+ base_storage_url = conf.dashboard.base_storage_url
+ if not base_storage_url:
+ base_storage_url = '/dashboard-attachment/'
+ return f'{base_storage_url}{self.group}/{self.attachment}'
+
+ def save_plot(self, plot):
+ """
+ Render the matplotlib plot (or figure) and save it in the filesystem
+ :param plot: matplotlib plot or figure...
+ :return:
+ """
+ self.ensure_dir_exists()
+
+ ## Different types of figures supported
+ fig = None
+ if plt:
+ if isinstance(plot, plt.Axes):
+ fig = plot.figure
+ elif isinstance(plot, plt.Figure):
+ fig = plot
+ if fig:
+ fig.savefig(self.get_plot_file_path(), bbox_inches='tight')
+ plt.close(fig)
+
+ if plot and not fig:
+ logger.warning('Cannot save dashboard attachment (unknown attachment type)')
+ return
+
+ #logger.info(f'Saved attachment of dashboard page {self.group}/{self.name} '
+ #f'in {self.get_attachment_file_name()}')
+ return self.get_plot_file_name()
+
+ def save_attachment(self, attached, name=None):
+ """
+ Save the attachment in the filesystem
+ :param attached: matplotlib plot or figure...
+ :return:
+ """
+ if not self.attachment:
+ ## Not set yet (creation)
+ self.attachment = f'{self.name}.png'
+
+ self.ensure_dir_exists()
+
+ ## Different types of figures supported
+ fig = None
+ if plt:
+ if isinstance(attached, plt.Axes):
+ fig = attached.figure
+ elif isinstance(attached, plt.Figure):
+ fig = attached
+ if fig:
+ fig.savefig(self.get_attachment_file_name(), bbox_inches='tight')
+ plt.close(fig)
+
+ if attached and not fig:
+ logger.warning('Cannot save dashboard attachment (unknown attachment type)')
+ return
+
+ #logger.info(f'Saved attachment of dashboard page {self.group}/{self.name} '
+ #f'in {self.get_attachment_file_name()}')
+ return self.attachment
+
+ def get_page_df(self):
+ """
+ Get the dataframe of the page
+ """
+ if not self.df:
+ return
+ try:
+ return pd.read_pickle(BytesIO(self.df), compression=None)
+ except KeyError:
+ raise NotADataframeError()
+
+ def get_plot(self):
+ return loads(self.plot)
+
+
+class DashboardPage(Model, DashboardPageCommon, table=True):
+ __tablename__ = 'dashboard_page'
+ __table_args__ = gisaf.table_args
+
+ class Admin:
+ menu = 'Dashboard'
+
+ id: int = Field(primary_key=True)
+ name: str
+ notebook: str
+ group: str
+ description: str
+ attachment: str
+ html: str
+ viewable_role: str
+ df: bytes
+ plot: bytes
+ source_id: int = Field(foreign_key=gisaf.table('dashboard_page_source.id'))
+ time: datetime
+ expanded_panes: str
+ source: DashboardPageSource = Relationship()
+
+ def __str__(self):
+ return f'{self.group:s}/{self.name:s}'
+
+ def __repr__(self):
+ return f''
+
+ @classmethod
+ def selectinload(cls):
+ return [cls.source]
+
+ def ensure_dir_exists(self):
+ """
+ Make sure the directory exists, before saving the file
+ """
+ dir_name = Path(conf.dashboard.base_storage_dir) / self.group
+ dir_name.mkdir(exist_ok=True)
+ return dir_name
+
+ def get_attachment_file_name(self):
+ """
+ Get the file name of the attachment
+ :return:
+ """
+ if self.attachment:
+ base_dir = Path(conf.dashboard.base_storage_dir)
+ if base_dir:
+ return base_dir/self.group/self.attachment
+ else:
+ raise UserWarning('Cannot save attachment: no notebook/base_storage_dir in gisaf config')
+
+ def get_notebook_url(self):
+ if self.notebook:
+ base_url = conf.dashboard.base_source_url
+ if base_url:
+ return f'{base_url}{self.notebook}'
+ else:
+ logger.debug('Notebook: no base_url in gisaf config')
+
+
+class DashboardPageSection(Model, DashboardPageCommon, table=True):
+ __tablename__ = 'dashboard_page_section'
+ __table_args__ = gisaf.table_args
+
+ class Admin:
+ menu = 'Dashboard'
+
+ id: str = Field(primary_key=True)
+ name: str
+ dashboard_page_id: int = Field(foreign_key=gisaf.table('dashboard_page.id'))
+ dashboard_page: DashboardPage = Relationship()
+
+ description: str
+ attachment: str
+ html: str
+ df: bytes
+ plot: str
+
+ def __str__(self):
+ return f'{self.name} for dashboard page #{self.dashboard_page_id}'
+
+ def __repr__(self):
+ return f''
+
+ @classmethod
+ def selectinload(cls):
+ return [cls.dashboard_page]
+
+ def get_plot_url(self):
+ ## Serve through web front-end (nginx static file)
+ if not self.plot:
+ return
+ return conf.dashboard.base_storage_url \
+ + self.dashboard_page.group + '/' \
+ + self.dashboard_page.name + '/' \
+ + self.name + '.png'
+
+ def ensure_dir_exists(self):
+ """
+ Make sure the directory exists, before saving the file
+ """
+ dir_name = Path(conf.dashboard.base_storage_dir) / self.page.group / self.page.name
+ dir_name.mkdir(exist_ok=True)
+ return dir_name
+
+ def get_attachment_file_name(self):
+ """
+ Get the file name of the attachment
+ :return:
+ """
+ if not self.attachment:
+ return
+ base_dir = Path(conf.dashboard.base_storage_dir)
+ if not base_dir:
+ raise UserWarning('Cannot save attachment: no notebook/base_storage_dir in gisaf config')
+ return base_dir/self.page.group/self.page.name/self.attachment
+
+ def get_plot_file_name(self):
+ return f'{self.name}.png'
+
+ def get_plot_file_path(self):
+ """
+ Get the file name of the plot
+ :return:
+ """
+ if not self.plot:
+ return
+ base_dir = Path(conf.dashboard.base_storage_dir)
+ if not base_dir:
+ raise UserWarning('Cannot save attachment: no notebook/base_storage_dir in gisaf config')
+ return base_dir/self.page.group/self.page.name/self.get_plot_file_name()
+
+
+class Widget(Model, table=True):
+ __tablename__ = 'widget'
+ __table_args__ = gisaf.table_args
+ ## CREATE TABLE gisaf.widget (name char(50) not null PRIMARY KEY, title varchar, subtitle varchar, notebook varchar, content varchar, time timestamp);
+ name: str = Field(primary_key=True, sa_type=String(50))
+ title: str
+ subtitle: str
+ content: str
+ time: datetime
+ notebook: str
+
+ class Admin:
+ menu = 'Dashboard'
+
+
+class DashboadPageSectionType(BaseModel):
+ name: str
+ plot: str
+
+
+class DashboardPage_(BaseModel):
+ name: str
+ group: str
+ description: str
+ time: datetime | None = Field(default_factory=datetime.now)
+ html: str | None = None
+ attachment: str | None = None
+ dfData: str | None = None
+ plotData: str | None = None
+ notebook: str | None = None
+ expandedPanes: list[str] | None = None
+ sections: list[DashboadPageSectionType] | None = None
+
+
+class DashboardGroup(BaseModel):
+ name: str
+ pages: list[DashboardPage_]
+
+
+class DashboardHome(BaseModel):
+ title: str
+ content: str
+ footer: str
\ No newline at end of file