diff --git a/src/onegov/org/custom.py b/src/onegov/org/custom.py index 639b7b88f1..d0406e50a4 100644 --- a/src/onegov/org/custom.py +++ b/src/onegov/org/custom.py @@ -5,7 +5,7 @@ from onegov.form.collection import FormCollection, SurveyCollection from onegov.org import _, OrgApp from onegov.org.models import ( - GeneralFileCollection, ImageFileCollection, Organisation) + GeneralFileCollection, ImageFileCollection, Organisation, Dashboard) from onegov.pay import PaymentProviderCollection, PaymentCollection from onegov.ticket import TicketCollection from onegov.ticket.collection import ArchivedTicketCollection @@ -66,6 +66,14 @@ def get_global_tools(request: OrgRequest) -> Iterator[Link | LinkGroup]: if request.is_manager: links = [] + links.append( + Link( + text=_('Dashboard'), + url=request.class_link(Dashboard), + attrs={'class': 'show-dashboard'} + ) + ) + links.append( Link( _('Timeline'), request.class_link(MessageCollection), diff --git a/src/onegov/org/directives.py b/src/onegov/org/directives.py index 6f13b32436..96784b8a2d 100644 --- a/src/onegov/org/directives.py +++ b/src/onegov/org/directives.py @@ -70,6 +70,7 @@ class SettingsDict(TypedDict): class BoardletConfig(TypedDict): cls: type[_Boardlet] order: tuple[int, int] + icon: str class HomepageWidgetAction(Action): @@ -258,7 +259,13 @@ class Boardlet(Action): 'boardlets_registry': dict } - def __init__(self, name: str, order: tuple[int, int]): + def __init__( + self, + name: str, + order: tuple[int, int], + icon: str = '' + ) -> None: + assert isinstance(order, tuple) and len(order) == 2, """ The order should consist of two values, a group and an order within the group. @@ -266,6 +273,7 @@ def __init__(self, name: str, order: tuple[int, int]): self.name = name self.order = order + self.icon = icon def identifier( # type:ignore[override] self, @@ -280,5 +288,6 @@ def perform( # type:ignore[override] ) -> None: boardlets_registry[self.name] = { 'cls': func, - 'order': self.order + 'order': self.order, + 'icon': self.icon, } diff --git a/src/onegov/org/models/dashboard.py b/src/onegov/org/models/dashboard.py index 904ce85320..26c5abf154 100644 --- a/src/onegov/org/models/dashboard.py +++ b/src/onegov/org/models/dashboard.py @@ -26,10 +26,12 @@ def boardlets(self) -> list[tuple[Boardlet, ...]]: instances = [] - for name, data in self.request.app.config.boardlets_registry.items(): + for name, data in ( + self.request.app.config.boardlets_registry.items()): instances.append(data['cls']( name=name, order=data['order'], + icon=data['icon'], request=self.request )) @@ -49,7 +51,7 @@ class Boardlet: from onegov.app import App - @App.boardlet(name='foo', order=(1, 1)) + @App.boardlet(name='foo', order=(1, 1), icon='') class MyBoardlet(Boardlet): pass @@ -59,10 +61,12 @@ def __init__( self, name: str, order: tuple[int, int], + icon: str, request: OrgRequest ) -> None: self.name = name self.order = order + self.icon = icon or '' self.request = request @property @@ -95,10 +99,19 @@ def state(self) -> Literal['success', 'warning', 'failure']: class BoardletFact: """ A single boardlet fact. """ - # the text of the fact (includes the metric) + # the text of the fact (not including the metric) text: str + # the metric of the fact + number: int | float | str | None = None + + # link to be displayed as tuple of link, link text + link: tuple[str, str] | None = None + # the font awesome (fa-*) icon to use, if any icon: str | None = None + # title of the icon (hover text) + icon_title: str | None = None + css_class: str | None = None diff --git a/src/onegov/town6/boardlets.py b/src/onegov/town6/boardlets.py new file mode 100644 index 0000000000..a4f8987147 --- /dev/null +++ b/src/onegov/town6/boardlets.py @@ -0,0 +1,136 @@ +from __future__ import annotations +from datetime import timedelta +from functools import cached_property +from sedate import utcnow +from typing import TYPE_CHECKING + +from onegov.org.layout import DefaultLayout +from onegov.org.models import Boardlet, BoardletFact, News +from onegov.page import Page +from onegov.ticket import Ticket +from onegov.town6 import TownApp, _ + +if TYPE_CHECKING: + from collections.abc import Iterator + from sqlalchemy.orm import Session + + from onegov.town6.request import TownRequest + + +class TownBoardlet(Boardlet): + + request: TownRequest + + @cached_property + def session(self) -> Session: + return self.request.session + + @cached_property + def layout(self) -> DefaultLayout: + return DefaultLayout(None, self.request) + + +@TownApp.boardlet(name='ticket', order=(1, 1), icon='fa-ticket-alt') +class TicketBoardlet(TownBoardlet): + + @property + def title(self) -> str: + return 'Tickets' + + @property + def facts(self) -> Iterator[BoardletFact]: + + yield BoardletFact( + text=_('Open Tickets'), + number=self.session.query(Ticket).filter_by(state='open').count(), + icon='fa-hourglass' + ) + + yield BoardletFact( + text=_('Pending Tickets'), + number=self.session.query(Ticket).filter_by( + state='pending').count(), + icon='fa-hourglass-half' + ) + + time_7d_ago = utcnow() - timedelta(days=7) + + new_tickets = self.session.query(Ticket).filter( + Ticket.created <= time_7d_ago).count() + yield BoardletFact( + text=_('New Tickets in the Last Week'), + number=new_tickets, + icon='fa-plus-circle' + ) + + closed_tickets = self.session.query(Ticket).filter_by( + state='closed').filter( + Ticket.last_change >= time_7d_ago).count() + yield BoardletFact( + text=_('Closed Tickets in the Last Week'), + number=closed_tickets, + icon='fa-check-circle' + ) + + +def get_icon_for_visibility(visibility: str) -> str: + visibility_icons = { + 'public': 'fa-eye', + 'secret': 'fa-user-secret', + 'private': 'fa-lock', + 'member': 'fa-users' + } + + if visibility not in visibility_icons: + raise ValueError(f'Invalid visibility: {visibility}') + + return visibility_icons[visibility] + + +def get_icon_title(request: TownRequest, visibility: str) -> str: + if visibility not in ['public', 'secret', 'private', 'member']: + raise ValueError(f'Invalid visibility: {visibility}') + + return request.translate(_('Visibility ${visibility}', + mapping={'visibility': visibility})) + + +@TownApp.boardlet(name='pages', order=(1, 2), icon='fa-edit') +class EditedPagesBoardlet(TownBoardlet): + + @property + def title(self) -> str: + return 'Last Edited Pages' + + @property + def facts(self) -> Iterator[BoardletFact]: + + last_edited_pages = self.session.query(Page).order_by( + Page.last_change.desc()).limit(8) + for p in last_edited_pages: + yield BoardletFact( + text='', + link=(self.layout.request.link(p), p.title), + icon=get_icon_for_visibility(p.access), # type:ignore[attr-defined] + icon_title=get_icon_title(self.request, p.access) # type:ignore[attr-defined] + ) + + +@TownApp.boardlet(name='news', order=(1, 3), icon='fa-edit') +class EditedNewsBoardlet(TownBoardlet): + + @property + def title(self) -> str: + return 'Last Edited News' + + @property + def facts(self) -> Iterator[BoardletFact]: + last_edited_news = self.session.query(News).order_by( + Page.last_change.desc()).limit(8) + for n in last_edited_news: + yield BoardletFact( + text='', + link=(self.layout.request.link(n), n.title), + icon=get_icon_for_visibility(n.access), + icon_title=get_icon_title(self.request, n.access) + ) diff --git a/src/onegov/town6/templates/dashboard.pt b/src/onegov/town6/templates/dashboard.pt index 8c9a4096db..686e86ad72 100644 --- a/src/onegov/town6/templates/dashboard.pt +++ b/src/onegov/town6/templates/dashboard.pt @@ -3,19 +3,29 @@ ${title} -
-
-

${boardlet.title}

-
    -
  • - - - - ${fact.text} - -
  • -
+
+
+
+

+ ${boardlet.number|''}
${boardlet.title}

+ + + + + + +
+ + ${fact.number} + - + + ${fact.text} + + ${fact.link[1]} + +
+
-
+
diff --git a/src/onegov/town6/theme/styles/dashboard.scss b/src/onegov/town6/theme/styles/dashboard.scss index 931cf47427..4e04a6257f 100644 --- a/src/onegov/town6/theme/styles/dashboard.scss +++ b/src/onegov/town6/theme/styles/dashboard.scss @@ -1,39 +1,84 @@ /* Dashboard */ -.boardlet-group { - display: flex; - flex-wrap: wrap; - justify-content: space-between; -} .boardlet { - background: $gray-pastel; - border-top: 10px solid $green-light; + background: $white-smoke; margin-bottom: 1.5rem; - padding: .5rem 1rem 1rem; - width: calc((100% / 3) - 1rem); - - h2 { - font-size: 1.3rem; - font-weight: bold; - text-align: center; - word-wrap: break-word; + border-top: 5px solid $green-light; + padding: 1rem; + border-radius: .7rem; + + h2.h4 { + margin-bottom: 1rem; + margin-top: -.2rem; } li { - font-size: .9rem; - } + &.boardlet-failure { + border-top-color: $red-light; + } - &.boardlet-warning { - border-top-color: $yellow-light; - } + h2.h4 { + padding: .5rem; + background: $white; + text-align: center; + border-radius: .7rem; + } + + .title { + font-weight: normal; + } + + .facts { + width: 100%; + table-layout: auto; + border-collapse: collapse; + + tbody { + background: transparent; + } - &.boardlet-failure { - border-top-color: $red-light; + tr { + background: transparent; + + &.none { + color: $base; + } + } + + td { + padding: .2rem 0; + background: transparent; + } + + td:first-child { + white-space: nowrap; + width: 1%; + } + } + + .fact-number, + .fact-icon { + background: $white; + padding: .2rem .5rem; + border-radius: .2rem; + width: 100%; + display: block; + text-align: right; + } + + .fact-icon { + padding: .3rem; + } + + .fact-text { + margin-left: .5rem; + } } } + @include breakpoint(medium only) { .boardlet { width: calc(50% - 1rem); diff --git a/src/onegov/town6/theme/town_theme.py b/src/onegov/town6/theme/town_theme.py index 91a4c698ac..4dd0832923 100644 --- a/src/onegov/town6/theme/town_theme.py +++ b/src/onegov/town6/theme/town_theme.py @@ -143,6 +143,7 @@ def post_imports(self) -> list[str]: 'print', 'chat', 'bar-graph', + 'dashboard', ] @property