diff --git a/allianceauth/services/hooks.py b/allianceauth/services/hooks.py index a2da7827..75ed30dc 100644 --- a/allianceauth/services/hooks.py +++ b/allianceauth/services/hooks.py @@ -8,6 +8,7 @@ from django.utils.functional import cached_property from allianceauth.hooks import get_hooks from allianceauth.menu.hooks import MenuItemHook +from allianceauth.templatetags.admin_status import AppAnnouncementHook from .models import NameFormatConfig @@ -145,6 +146,16 @@ class MenuItemHook(MenuItemHook): def __init_subclass__(cls) -> None: return super().__init_subclass__() +class AppAnnouncementHook(AppAnnouncementHook): + """ + AppAnnouncementHook shim to allianceauth.templatetags.admin_status + + :param AppAnnouncementHook: _description_ + :type AppAnnouncementHook: _type_ + """ + def __init_subclass__(cls) -> None: + return super().__init_subclass__() + class UrlHook: """A hook for registering the URLs of a Django app. diff --git a/allianceauth/templates/allianceauth/admin-status/overview.html b/allianceauth/templates/allianceauth/admin-status/overview.html index 47484a57..867322b6 100644 --- a/allianceauth/templates/allianceauth/admin-status/overview.html +++ b/allianceauth/templates/allianceauth/admin-status/overview.html @@ -46,6 +46,30 @@ {% endif %} +{% if application_notifications %} +
+
+
+ {% translate "Application Notifications" as widget_title %} + {% include "framework/dashboard/widget-title.html" with title=widget_title %} + +
+ + {# TODO maybe add some disclaimer that those are managed by application devs? #} +
+
+
+
+{% endif %} +
diff --git a/allianceauth/templatetags/admin_status.py b/allianceauth/templatetags/admin_status.py index d7084dc5..6768a9a7 100644 --- a/allianceauth/templatetags/admin_status.py +++ b/allianceauth/templatetags/admin_status.py @@ -1,4 +1,7 @@ import logging +from dataclasses import dataclass +from enum import Enum +from urllib.parse import quote_plus import requests from packaging.version import InvalidVersion, Version as Pep440Version @@ -11,6 +14,7 @@ from allianceauth import __version__ from allianceauth.authentication.task_statistics.counters import ( dashboard_results, ) +from allianceauth.hooks import get_hooks register = template.Library() @@ -32,6 +36,72 @@ GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = ( logger = logging.getLogger(__name__) +@dataclass +class AppAnnouncementHook: + """ + A hook for an application to send GitHub/GitLab issues as announcements on the dashboard + + Args: + - app_name: The name of your application + - repository_namespace: The namespace of the remote repository of your application source code. + It should look like `/`. + - repository_kind: Enumeration to determine if your repository is a GitHub or GitLab repository. + - label: The label applied to issues that should be seen as announcements, case-sensitive. + Default value: `announcement` + """ + class RepositoryKind(Enum): + """Simple enumeration to determine which api should be called to access issues""" + GITLAB = "gitlab" + GITHUB = "github" + + app_name: str + repository_namespace: str + repository_kind: RepositoryKind + label: str = "announcement" + + + def get_announcement_list(self) -> list: + """ + Checks the application repository to find issues with the `Announcement` tag and return their title and link to + be displayed. + """ + match self.repository_kind: + case AppAnnouncementHook.RepositoryKind.GITHUB: + announcement_list = self._get_github_announcement_list() + case AppAnnouncementHook.RepositoryKind.GITLAB: + announcement_list = self._get_gitlab_announcement_list() + case _: + return [] + + for announcement in announcement_list: + announcement["app_name"] = self.app_name + + return announcement_list + + + def _get_github_announcement_list(self) -> list: + """ + Return the issue list for a GitHub repository + Will filter if the `pull_request` attribute is present + """ + raw_list = _fetch_list_from_github( + f"https://api.github.com/repos/{self.repository_namespace}/issues" + f"?labels={self.label}" + ) + # Translates GitHub attributes to GitLab and filters out pull requests + clean_list = [] + for element in raw_list: + if not element.get("pull_request"): + element["web_url"] = element["html_url"] + element["iid"] = element["number"] + clean_list.append(element) + return clean_list + + def _get_gitlab_announcement_list(self) -> list: + """Return the issues list for a GitLab repository""" + return _fetch_list_from_gitlab( + f"https://gitlab.com/api/v4/projects/{quote_plus(self.repository_namespace)}/issues" + f"?labels={self.label}&state=opened") @register.simple_tag() def decimal_widthratio(this_value, max_value, max_width) -> str: @@ -89,8 +159,28 @@ def _current_notifications() -> dict: else: top_notifications = [] + app_notifications = [] + hooks = [fn() for fn in get_hooks("app_announcement_hook")] + for hook in hooks: + logger.debug(hook) + try: + app_notifications.extend(cache.get_or_set( + f"{hook.app_name}_notification_issues", + hook.get_announcement_list, + NOTIFICATION_CACHE_TIME, + )) + except requests.HTTPError: + logger.warning("Error when getting %s notifications", hook, exc_info=True) + + if app_notifications: + logger.debug(app_notifications) + application_notifications = app_notifications[:10] + else: + application_notifications = [] + response = { 'notifications': top_notifications, + 'application_notifications': application_notifications, } return response @@ -199,3 +289,38 @@ def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list: break return result + +def _fetch_list_from_github(url: str, max_pages: int = MAX_PAGES) -> list: + """returns a list from the GitHub API. Supports paging""" + + result = [] + for page in range(1, max_pages+1): + try: + request = requests.get( + url, + params={'page': page}, + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + ) + request.raise_for_status() + except requests.exceptions.RequestException as e: + error_str = str(e) + + logger.warning( + f'Unable to fetch from GitHub API. Error: {error_str}', + exc_info=True, + ) + + return result + + result += request.json() + logger.debug(request.json()) + + # https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28 + # See Example creating a pagination method + if not ('link' in request.headers and 'rel=\"next\"' in request.headers['link']): + break + + return result diff --git a/docs/development/custom/app-announcement-hooks.md b/docs/development/custom/app-announcement-hooks.md new file mode 100644 index 00000000..03650a87 --- /dev/null +++ b/docs/development/custom/app-announcement-hooks.md @@ -0,0 +1,52 @@ +# Announcement Hooks + +This hook allows the issues opened on your application repository to be displayed on the alliance auth front page to +administrators. + +![app_announcement_hook_example](img/app_announcement_hook_example.png) + +To register an AppAnnouncementHook class, you would do the following: + +```python +from allianceauth import hooks +from allianceauth.services.hooks import AppAnnouncementHook + +@hooks.register('app_announcement_hook') +def announcement_hook(): + return AppAnnouncementHook("Your app name", "USERNAME/REPOSITORY_NAME", AppAnnouncementHook.RepositoryKind.GITLAB) +``` + +```{eval-rst} +.. autoclass:: allianceauth.services.hooks.AppAnnouncementHook + :members: __init__ + :undoc-members: +``` + +## Parameters + +### app_name + +The name of your application. + +### repository_namespace + +Here you should enter the namespace of your repository. +The structure stays the same for both GitHub and GitLab repositories. \ +A repository with the url `https://gitlab.com/username/appname` will have a namespace of `username/appname`. + +### repository_kind + +This variable is an enumeration of the class `AppAnnouncemementHook.RepositoryKind` + +It is mandatory to specify this variable so alliance auth contacts the correct API when fetching your repository issues. + +```{eval-rst} +.. autoclass:: allianceauth.services.hooks.AppAnnouncementHook.RepositoryKind + :members: GITLAB, GITHUB + :undoc-members: +``` + +### label + +The label that will determine if issues should be seen as an announcement. +This value is case-sensitive and the default value is `"announcement"`. diff --git a/docs/development/custom/img/app_announcement_hook_example.png b/docs/development/custom/img/app_announcement_hook_example.png new file mode 100755 index 00000000..b57c6950 Binary files /dev/null and b/docs/development/custom/img/app_announcement_hook_example.png differ diff --git a/docs/development/custom/index.md b/docs/development/custom/index.md index 72be4d4d..3ed4008b 100644 --- a/docs/development/custom/index.md +++ b/docs/development/custom/index.md @@ -8,6 +8,7 @@ This section describes how to extend **Alliance Auth** with custom apps, service integrating-services menu-hooks url-hooks +app-announcement-hooks logging custom-themes aa-framework