mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2025-07-09 04:20:17 +02:00
Merge branch 'app-announcement' into 'v5.x'
Application Announcement Hook See merge request allianceauth/allianceauth!1717
This commit is contained in:
commit
6dede0ddb5
0
allianceauth/admin_status/__init__.py
Normal file
0
allianceauth/admin_status/__init__.py
Normal file
19
allianceauth/admin_status/admin.py
Normal file
19
allianceauth/admin_status/admin.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""Admin site for admin status applicaton"""
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ApplicationAnnouncement)
|
||||||
|
class ApplicationAnnouncementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["application_name", "announcement_number", "announcement_text", "hide_announcement"]
|
||||||
|
list_filter = ["hide_announcement"]
|
||||||
|
ordering = ["application_name", "announcement_number"]
|
||||||
|
readonly_fields = ["application_name", "announcement_number", "announcement_text", "announcement_url"]
|
||||||
|
fields = ["application_name", "announcement_number", "announcement_text", "announcement_url", "hide_announcement"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
6
allianceauth/admin_status/apps.py
Normal file
6
allianceauth/admin_status/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AdminStatusApplication(AppConfig):
|
||||||
|
name = 'allianceauth.admin_status'
|
||||||
|
label = 'admin_status'
|
207
allianceauth/admin_status/hooks.py
Normal file
207
allianceauth/admin_status/hooks.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from allianceauth.hooks import get_hooks, register
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# timeout for all requests
|
||||||
|
REQUESTS_TIMEOUT = 5 # 5 seconds
|
||||||
|
# max pages to be fetched from gitlab
|
||||||
|
MAX_PAGES = 50
|
||||||
|
# Cache time
|
||||||
|
NOTIFICATION_CACHE_TIME = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Announcement:
|
||||||
|
"""
|
||||||
|
Dataclass storing all data for an announcement to be sent arround
|
||||||
|
"""
|
||||||
|
application_name: str
|
||||||
|
announcement_url: str
|
||||||
|
announcement_number: int
|
||||||
|
announcement_text: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_from_gitlab_issue_dict(cls, application_name: str, gitlab_issue: dict) -> "Announcement":
|
||||||
|
"""Builds the announcement from the JSON dict of a GitLab issue"""
|
||||||
|
return Announcement(application_name, gitlab_issue["web_url"], gitlab_issue["iid"], gitlab_issue["title"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_from_github_issue_dict(cls, application_name: str, github_issue: dict) -> "Announcement":
|
||||||
|
"""Builds the announcement from the JSON dict of a GitHub issue"""
|
||||||
|
return Announcement(application_name, github_issue["html_url"], github_issue["number"], github_issue["title"])
|
||||||
|
|
||||||
|
def get_hash(self):
|
||||||
|
"""Get a hash of the Announcement for comparison"""
|
||||||
|
name = f"{self.application_name}.{self.announcement_number}"
|
||||||
|
hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
||||||
|
return hash_value
|
||||||
|
|
||||||
|
|
||||||
|
@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 `<username>/<application_name>`.
|
||||||
|
- 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 Service(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: Service
|
||||||
|
label: str = "announcement"
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcement_list(self) -> list[Announcement]:
|
||||||
|
"""
|
||||||
|
Checks the application repository to find issues with the `Announcement` tag and return their title and link to
|
||||||
|
be displayed.
|
||||||
|
"""
|
||||||
|
logger.debug("Getting announcement list for the app %s", self.app_name)
|
||||||
|
match self.repository_kind:
|
||||||
|
case AppAnnouncementHook.Service.GITHUB:
|
||||||
|
announcement_list = self._get_github_announcement_list()
|
||||||
|
case AppAnnouncementHook.Service.GITLAB:
|
||||||
|
announcement_list = self._get_gitlab_announcement_list()
|
||||||
|
case _:
|
||||||
|
announcement_list = []
|
||||||
|
|
||||||
|
logger.debug("Announcements for app %s: %s", self.app_name, announcement_list)
|
||||||
|
return announcement_list
|
||||||
|
|
||||||
|
def _get_github_announcement_list(self) -> list[Announcement]:
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
return [Announcement.build_from_github_issue_dict(self.app_name, github_issue) for github_issue in raw_list]
|
||||||
|
|
||||||
|
def _get_gitlab_announcement_list(self) -> list[Announcement]:
|
||||||
|
"""Return the issues list for a GitLab repository"""
|
||||||
|
raw_list = _fetch_list_from_gitlab(
|
||||||
|
f"https://gitlab.com/api/v4/projects/{quote_plus(self.repository_namespace)}/issues"
|
||||||
|
f"?labels={self.label}&state=opened")
|
||||||
|
return [Announcement.build_from_gitlab_issue_dict(self.app_name, gitlab_issue) for gitlab_issue in raw_list]
|
||||||
|
|
||||||
|
@register("app_announcement_hook")
|
||||||
|
def alliance_auth_announcements_hook():
|
||||||
|
return AppAnnouncementHook("AllianceAuth", "allianceauth/allianceauth", AppAnnouncementHook.Service.GITLAB)
|
||||||
|
|
||||||
|
def get_all_applications_announcements() -> list[Announcement]:
|
||||||
|
"""
|
||||||
|
Retrieve all known application announcements and returns them
|
||||||
|
"""
|
||||||
|
application_notifications = []
|
||||||
|
|
||||||
|
hooks = [fn() for fn in get_hooks("app_announcement_hook")]
|
||||||
|
for hook in hooks:
|
||||||
|
logger.debug(hook)
|
||||||
|
try:
|
||||||
|
application_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)
|
||||||
|
|
||||||
|
logger.debug(application_notifications)
|
||||||
|
if application_notifications:
|
||||||
|
application_notifications = application_notifications[:10]
|
||||||
|
|
||||||
|
return application_notifications
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list:
|
||||||
|
"""returns a list from the GitLab API. Supports paging"""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for page in range(1, max_pages + 1):
|
||||||
|
try:
|
||||||
|
request = requests.get(
|
||||||
|
url, params={'page': page}, timeout=REQUESTS_TIMEOUT
|
||||||
|
)
|
||||||
|
request.raise_for_status()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_str = str(e)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f'Unable to fetch from GitLab API. Error: {error_str}',
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
result += request.json()
|
||||||
|
|
||||||
|
if 'x-total-pages' in request.headers:
|
||||||
|
try:
|
||||||
|
total_pages = int(request.headers['x-total-pages'])
|
||||||
|
except ValueError:
|
||||||
|
total_pages = None
|
||||||
|
else:
|
||||||
|
total_pages = None
|
||||||
|
|
||||||
|
if not total_pages or page >= total_pages:
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
timeout=REQUESTS_TIMEOUT,
|
||||||
|
)
|
||||||
|
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
|
57
allianceauth/admin_status/managers.py
Normal file
57
allianceauth/admin_status/managers.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from allianceauth.admin_status.hooks import (
|
||||||
|
Announcement,
|
||||||
|
get_all_applications_announcements,
|
||||||
|
)
|
||||||
|
from allianceauth.services.hooks import get_extension_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .models import ApplicationAnnouncement
|
||||||
|
|
||||||
|
logger = get_extension_logger(__name__)
|
||||||
|
|
||||||
|
class ApplicationAnnouncementManager(models.Manager):
|
||||||
|
|
||||||
|
def sync_and_return(self):
|
||||||
|
"""
|
||||||
|
Checks all hooks if new notifications need to be created.
|
||||||
|
Return all notification objects after
|
||||||
|
"""
|
||||||
|
logger.info("Syncing announcements")
|
||||||
|
current_announcements = get_all_applications_announcements()
|
||||||
|
self._delete_obsolete_announcements(current_announcements)
|
||||||
|
self._store_new_announcements(current_announcements)
|
||||||
|
|
||||||
|
return self.all()
|
||||||
|
|
||||||
|
def _delete_obsolete_announcements(self, current_announcements: list[Announcement]):
|
||||||
|
"""Deletes all announcements stored in the database that aren't retrieved anymore"""
|
||||||
|
hashes = [announcement.get_hash() for announcement in current_announcements]
|
||||||
|
self.exclude(announcement_hash__in=hashes).delete()
|
||||||
|
|
||||||
|
def _store_new_announcements(self, current_announcements: list[Announcement]):
|
||||||
|
"""Stores a new database object for new application announcements"""
|
||||||
|
|
||||||
|
for current_announcement in current_announcements:
|
||||||
|
try:
|
||||||
|
announcement = self.get(announcement_hash=current_announcement.get_hash())
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
self.create_from_announcement(current_announcement)
|
||||||
|
else:
|
||||||
|
# if exists update the text only
|
||||||
|
if announcement.announcement_text != current_announcement.announcement_text:
|
||||||
|
announcement.announcement_text = current_announcement.announcement_text
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
def create_from_announcement(self, announcement: Announcement) -> "ApplicationAnnouncement":
|
||||||
|
"""Creates from the Announcement dataclass"""
|
||||||
|
return self.create(
|
||||||
|
application_name=announcement.application_name,
|
||||||
|
announcement_number=announcement.announcement_number,
|
||||||
|
announcement_text=announcement.announcement_text,
|
||||||
|
announcement_url=announcement.announcement_url,
|
||||||
|
announcement_hash=announcement.get_hash(),
|
||||||
|
)
|
33
allianceauth/admin_status/migrations/0001_initial.py
Normal file
33
allianceauth/admin_status/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.9 on 2025-05-18 15:43
|
||||||
|
|
||||||
|
import django.db.models.manager
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ApplicationAnnouncement',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('application_name', models.CharField(help_text='Name of the application that issued the announcement', max_length=50)),
|
||||||
|
('announcement_number', models.IntegerField(help_text='Issue number on the notification source')),
|
||||||
|
('announcement_text', models.TextField(help_text='Issue title text displayed on the dashboard', max_length=300)),
|
||||||
|
('announcement_url', models.TextField(max_length=200)),
|
||||||
|
('announcement_hash', models.CharField(default=None, editable=False, help_text='hash of an announcement. Must be nullable for unique comparison.', max_length=64, null=True, unique=True)),
|
||||||
|
('hide_announcement', models.BooleanField(default=False, help_text='Set to true if the announcement should not be displayed on the dashboard')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('application_name', 'announcement_number'), name='functional_pk_applicationissuenumber')],
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('object', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
allianceauth/admin_status/migrations/__init__.py
Normal file
0
allianceauth/admin_status/migrations/__init__.py
Normal file
45
allianceauth/admin_status/models.py
Normal file
45
allianceauth/admin_status/models.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from allianceauth.admin_status.managers import ApplicationAnnouncementManager
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationAnnouncement(models.Model):
|
||||||
|
"""
|
||||||
|
Announcement originating from an application
|
||||||
|
"""
|
||||||
|
object = ApplicationAnnouncementManager()
|
||||||
|
|
||||||
|
application_name = models.CharField(max_length=50, help_text=_("Name of the application that issued the announcement"))
|
||||||
|
announcement_number = models.IntegerField(help_text=_("Issue number on the notification source"))
|
||||||
|
announcement_text = models.TextField(max_length=300, help_text=_("Issue title text displayed on the dashboard"))
|
||||||
|
announcement_url = models.TextField(max_length=200)
|
||||||
|
|
||||||
|
announcement_hash = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
default=None,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
help_text="hash of an announcement."
|
||||||
|
)
|
||||||
|
|
||||||
|
hide_announcement = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_("Set to true if the announcement should not be displayed on the dashboard")
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
# Should be updated to a composite key when the switch to Django 5.2 is made
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/composite-primary-key/
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["application_name", "announcement_number"], name="functional_pk_applicationissuenumber"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.application_name} announcement #{self.announcement_number}"
|
||||||
|
|
||||||
|
def is_hidden(self) -> bool:
|
||||||
|
"""Function in case rules are made in the future to force hide/force show some announcements"""
|
||||||
|
return self.hide_announcement
|
@ -2,44 +2,24 @@
|
|||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% if notifications %}
|
{% if notifications %}
|
||||||
<div id="aa-dashboard-panel-admin-notifications" class="col-12 mb-3">
|
<div id="aa-dashboard-panel-admin-application-notifications" class="col-12 mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% translate "Alliance Auth Notifications" as widget_title %}
|
{% translate "Announcements" as widget_title %}
|
||||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
{% for notif in notifications %}
|
{% for notif in notifications %}
|
||||||
<li class="list-group-item">
|
{% if not notif.is_hidden %}
|
||||||
{% if notif.state == 'opened' %}
|
<li class="list-group-item">
|
||||||
<span class="badge bg-success me-2">{% translate "Open" %}</span>
|
<span class="badge bg-info me-2">{{ notif.application_name }}</span>
|
||||||
{% else %}
|
<a href="{{ notif.announcement_url }}" target="_blank">#{{ notif.announcement_number }} {{ notif.announcement_text }}</a>
|
||||||
<span class="badge bg-danger me-2">{% translate "Closed" %}</span>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ notif.web_url }}" target="_blank">#{{ notif.iid }} {{ notif.title }}</a>
|
|
||||||
</li>
|
|
||||||
{% empty %}
|
|
||||||
<div class="alert alert-primary" role="alert">
|
|
||||||
{% translate "No notifications at this time" %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{# TODO maybe add some disclaimer that those are managed by application devs? #}
|
||||||
<div class="text-end pt-3">
|
|
||||||
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none">
|
|
||||||
<span class="badge" style="background-color: rgb(230 83 40);">
|
|
||||||
<i class="fab fa-gitlab" aria-hidden="true"></i>
|
|
||||||
{% translate 'Powered by GitLab' %}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
|
|
||||||
<span class="badge" style="background-color: rgb(110 133 211);">
|
|
||||||
<i class="fab fa-discord" aria-hidden="true"></i>
|
|
||||||
{% translate 'Support Discord' %}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -107,9 +87,9 @@
|
|||||||
style="height: 21px;"
|
style="height: 21px;"
|
||||||
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
|
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
|
||||||
>
|
>
|
||||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
|
{% include "admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
|
||||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
|
{% include "admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
|
||||||
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
|
{% include "admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@ -118,6 +98,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-end pt-3">
|
||||||
|
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none">
|
||||||
|
<span class="badge" style="background-color: rgb(230 83 40);">
|
||||||
|
<i class="fab fa-gitlab" aria-hidden="true"></i>
|
||||||
|
{% translate 'Powered by GitLab' %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
|
||||||
|
<span class="badge" style="background-color: rgb(110 133 211);">
|
||||||
|
<i class="fab fa-discord" aria-hidden="true"></i>
|
||||||
|
{% translate 'Support Discord' %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
0
allianceauth/admin_status/templatetags/__init__.py
Normal file
0
allianceauth/admin_status/templatetags/__init__.py
Normal file
@ -8,6 +8,7 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from allianceauth import __version__
|
from allianceauth import __version__
|
||||||
|
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||||
from allianceauth.authentication.task_statistics.counters import (
|
from allianceauth.authentication.task_statistics.counters import (
|
||||||
dashboard_results,
|
dashboard_results,
|
||||||
)
|
)
|
||||||
@ -25,10 +26,6 @@ MAX_PAGES = 50
|
|||||||
GITLAB_AUTH_REPOSITORY_TAGS_URL = (
|
GITLAB_AUTH_REPOSITORY_TAGS_URL = (
|
||||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
|
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
|
||||||
)
|
)
|
||||||
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
|
|
||||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
|
|
||||||
'?labels=announcement&state=opened'
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,7 +38,7 @@ def decimal_widthratio(this_value, max_value, max_width) -> str:
|
|||||||
return str(round(this_value / max_value * max_width, 2))
|
return str(round(this_value / max_value * max_width, 2))
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('allianceauth/admin-status/overview.html')
|
@register.inclusion_tag('admin-status/overview.html')
|
||||||
def status_overview() -> dict:
|
def status_overview() -> dict:
|
||||||
response = {
|
response = {
|
||||||
"notifications": [],
|
"notifications": [],
|
||||||
@ -73,32 +70,15 @@ def _celery_stats() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _current_notifications() -> dict:
|
def _current_notifications() -> dict:
|
||||||
"""returns the newest 5 announcement issues"""
|
"""returns announcements from AllianceAuth and third party applications"""
|
||||||
try:
|
|
||||||
notifications = cache.get_or_set(
|
application_notifications = ApplicationAnnouncement.object.sync_and_return()
|
||||||
'gitlab_notification_issues',
|
|
||||||
_fetch_notification_issues_from_gitlab,
|
|
||||||
NOTIFICATION_CACHE_TIME
|
|
||||||
)
|
|
||||||
except requests.HTTPError:
|
|
||||||
logger.warning('Error while getting gitlab notifications', exc_info=True)
|
|
||||||
top_notifications = []
|
|
||||||
else:
|
|
||||||
if notifications:
|
|
||||||
top_notifications = notifications[:5]
|
|
||||||
else:
|
|
||||||
top_notifications = []
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
'notifications': top_notifications,
|
'notifications': application_notifications,
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _fetch_notification_issues_from_gitlab() -> list:
|
|
||||||
return _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, max_pages=10)
|
|
||||||
|
|
||||||
|
|
||||||
def _current_version_summary() -> dict:
|
def _current_version_summary() -> dict:
|
||||||
"""returns the current version info"""
|
"""returns the current version info"""
|
||||||
try:
|
try:
|
0
allianceauth/admin_status/tests/__init__.py
Normal file
0
allianceauth/admin_status/tests/__init__.py
Normal file
194
allianceauth/admin_status/tests/test_hooks.py
Normal file
194
allianceauth/admin_status/tests/test_hooks.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import requests_mock
|
||||||
|
|
||||||
|
from allianceauth.admin_status.hooks import Announcement
|
||||||
|
from allianceauth.services.hooks import AppAnnouncementHook
|
||||||
|
from allianceauth.utils.testing import NoSocketsTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestHooks(NoSocketsTestCase):
|
||||||
|
|
||||||
|
@requests_mock.mock()
|
||||||
|
def test_fetch_gitlab(self, requests_mocker):
|
||||||
|
# given
|
||||||
|
announcement_hook = AppAnnouncementHook("test GitLab app", "r0kym/allianceauth-example-plugin",
|
||||||
|
AppAnnouncementHook.Service.GITLAB)
|
||||||
|
requests_mocker.get(
|
||||||
|
"https://gitlab.com/api/v4/projects/r0kym%2Fallianceauth-example-plugin/issues?labels=announcement&state=opened",
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"id": 166279127,
|
||||||
|
"iid": 1,
|
||||||
|
"project_id": 67653102,
|
||||||
|
"title": "Test GitLab issue",
|
||||||
|
"description": "Test issue",
|
||||||
|
"state": "opened",
|
||||||
|
"created_at": "2025-04-20T21:26:57.914Z",
|
||||||
|
"updated_at": "2025-04-21T11:04:30.501Z",
|
||||||
|
"closed_at": None,
|
||||||
|
"closed_by": None,
|
||||||
|
"labels": [
|
||||||
|
"announcement"
|
||||||
|
],
|
||||||
|
"milestone": None,
|
||||||
|
"assignees": [],
|
||||||
|
"author": {
|
||||||
|
"id": 14491514,
|
||||||
|
"username": "r0kym",
|
||||||
|
"public_email": "",
|
||||||
|
"name": "T'rahk Rokym",
|
||||||
|
"state": "active",
|
||||||
|
"locked": False,
|
||||||
|
"avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/14491514/avatar.png",
|
||||||
|
"web_url": "https://gitlab.com/r0kym"
|
||||||
|
},
|
||||||
|
"type": "ISSUE",
|
||||||
|
"assignee": None,
|
||||||
|
"user_notes_count": 0,
|
||||||
|
"merge_requests_count": 0,
|
||||||
|
"upvotes": 0,
|
||||||
|
"downvotes": 0,
|
||||||
|
"due_date": None,
|
||||||
|
"confidential": False,
|
||||||
|
"discussion_locked": None,
|
||||||
|
"issue_type": "issue",
|
||||||
|
"web_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
"time_stats": {
|
||||||
|
"time_estimate": 0,
|
||||||
|
"total_time_spent": 0,
|
||||||
|
"human_time_estimate": None,
|
||||||
|
"human_total_time_spent": None
|
||||||
|
},
|
||||||
|
"task_completion_status": {
|
||||||
|
"count": 0,
|
||||||
|
"completed_count": 0
|
||||||
|
},
|
||||||
|
"blocking_issues_count": 0,
|
||||||
|
"has_tasks": True,
|
||||||
|
"task_status": "0 of 0 checklist items completed",
|
||||||
|
"_links": {
|
||||||
|
"self": "https://gitlab.com/api/v4/projects/67653102/issues/1",
|
||||||
|
"notes": "https://gitlab.com/api/v4/projects/67653102/issues/1/notes",
|
||||||
|
"award_emoji": "https://gitlab.com/api/v4/projects/67653102/issues/1/award_emoji",
|
||||||
|
"project": "https://gitlab.com/api/v4/projects/67653102",
|
||||||
|
"closed_as_duplicate_of": None
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"short": "#1",
|
||||||
|
"relative": "#1",
|
||||||
|
"full": "r0kym/allianceauth-example-plugin#1"
|
||||||
|
},
|
||||||
|
"severity": "UNKNOWN",
|
||||||
|
"moved_to_id": None,
|
||||||
|
"imported": False,
|
||||||
|
"imported_from": "none",
|
||||||
|
"service_desk_reply_to": None
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# when
|
||||||
|
announcements = announcement_hook.get_announcement_list()
|
||||||
|
# then
|
||||||
|
self.assertEqual(len(announcements), 1)
|
||||||
|
self.assertIn(Announcement(
|
||||||
|
application_name="test GitLab app",
|
||||||
|
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="Test GitLab issue"
|
||||||
|
), announcements)
|
||||||
|
|
||||||
|
@requests_mock.mock()
|
||||||
|
def test_fetch_github(self, requests_mocker):
|
||||||
|
# given
|
||||||
|
announcement_hook = AppAnnouncementHook("test GitHub app", "r0kym/test", AppAnnouncementHook.Service.GITHUB)
|
||||||
|
requests_mocker.get(
|
||||||
|
"https://api.github.com/repos/r0kym/test/issues?labels=announcement",
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"url": "https://api.github.com/repos/r0kym/test/issues/1",
|
||||||
|
"repository_url": "https://api.github.com/repos/r0kym/test",
|
||||||
|
"labels_url": "https://api.github.com/repos/r0kym/test/issues/1/labels{/name}",
|
||||||
|
"comments_url": "https://api.github.com/repos/r0kym/test/issues/1/comments",
|
||||||
|
"events_url": "https://api.github.com/repos/r0kym/test/issues/1/events",
|
||||||
|
"html_url": "https://github.com/r0kym/test/issues/1",
|
||||||
|
"id": 3007269496,
|
||||||
|
"node_id": "I_kwDOOc2YvM6zP0p4",
|
||||||
|
"number": 1,
|
||||||
|
"title": "GitHub issue",
|
||||||
|
"user": {
|
||||||
|
"login": "r0kym",
|
||||||
|
"id": 56434393,
|
||||||
|
"node_id": "MDQ6VXNlcjU2NDM0Mzkz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/56434393?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/r0kym",
|
||||||
|
"html_url": "https://github.com/r0kym",
|
||||||
|
"followers_url": "https://api.github.com/users/r0kym/followers",
|
||||||
|
"following_url": "https://api.github.com/users/r0kym/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/r0kym/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/r0kym/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/r0kym/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/r0kym/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/r0kym/repos",
|
||||||
|
"events_url": "https://api.github.com/users/r0kym/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/r0kym/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": False
|
||||||
|
},
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"id": 8487814480,
|
||||||
|
"node_id": "LA_kwDOOc2YvM8AAAAB-enFUA",
|
||||||
|
"url": "https://api.github.com/repos/r0kym/test/labels/announcement",
|
||||||
|
"name": "announcement",
|
||||||
|
"color": "aaaaaa",
|
||||||
|
"default": False,
|
||||||
|
"description": None
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "open",
|
||||||
|
"locked": False,
|
||||||
|
"assignee": None,
|
||||||
|
"assignees": [],
|
||||||
|
"milestone": None,
|
||||||
|
"comments": 0,
|
||||||
|
"created_at": "2025-04-20T22:41:10Z",
|
||||||
|
"updated_at": "2025-04-21T11:05:08Z",
|
||||||
|
"closed_at": None,
|
||||||
|
"author_association": "OWNER",
|
||||||
|
"active_lock_reason": None,
|
||||||
|
"sub_issues_summary": {
|
||||||
|
"total": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"percent_completed": 0
|
||||||
|
},
|
||||||
|
"body": None,
|
||||||
|
"closed_by": None,
|
||||||
|
"reactions": {
|
||||||
|
"url": "https://api.github.com/repos/r0kym/test/issues/1/reactions",
|
||||||
|
"total_count": 0,
|
||||||
|
"+1": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"laugh": 0,
|
||||||
|
"hooray": 0,
|
||||||
|
"confused": 0,
|
||||||
|
"heart": 0,
|
||||||
|
"rocket": 0,
|
||||||
|
"eyes": 0
|
||||||
|
},
|
||||||
|
"timeline_url": "https://api.github.com/repos/r0kym/test/issues/1/timeline",
|
||||||
|
"performed_via_github_app": None,
|
||||||
|
"state_reason": None
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# when
|
||||||
|
announcements = announcement_hook.get_announcement_list()
|
||||||
|
# then
|
||||||
|
self.assertEqual(len(announcements), 1)
|
||||||
|
self.assertIn(Announcement(
|
||||||
|
application_name="test GitHub app",
|
||||||
|
announcement_url="https://github.com/r0kym/test/issues/1",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitHub issue"
|
||||||
|
), announcements)
|
75
allianceauth/admin_status/tests/test_managers.py
Normal file
75
allianceauth/admin_status/tests/test_managers.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from allianceauth.admin_status.hooks import Announcement
|
||||||
|
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||||
|
from allianceauth.utils.testing import NoSocketsTestCase
|
||||||
|
|
||||||
|
MODULE_PATH = 'allianceauth.admin_status.managers'
|
||||||
|
|
||||||
|
DEFAULT_ANNOUNCEMENTS = [
|
||||||
|
Announcement(
|
||||||
|
application_name="Test GitHub Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitHub issue",
|
||||||
|
announcement_url="https://github.com/r0kym/test/issues/1",
|
||||||
|
),
|
||||||
|
Announcement(
|
||||||
|
application_name="Test Gitlab Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitLab issue",
|
||||||
|
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
class TestSyncManager(NoSocketsTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
ApplicationAnnouncement.object.create(
|
||||||
|
application_name="Test GitHub Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitHub issue",
|
||||||
|
announcement_url="https://github.com/r0kym/test/issues/1",
|
||||||
|
announcement_hash="9dbedb9c47529bb43cfecb704768a35d085b145930e13cced981623e5f162a85",
|
||||||
|
)
|
||||||
|
ApplicationAnnouncement.object.create(
|
||||||
|
application_name="Test Gitlab Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitLab issue",
|
||||||
|
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
announcement_hash="8955a9c12a1cfa9e1776662bdaf111147b84e35c79f24bfb758e35333a18b1bd",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + '.get_all_applications_announcements')
|
||||||
|
def test_announcements_stay_as_is(self, all_announcements_mocker):
|
||||||
|
# given
|
||||||
|
announcement_ids = set(ApplicationAnnouncement.object.values_list("id", flat=True))
|
||||||
|
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS
|
||||||
|
# when
|
||||||
|
ApplicationAnnouncement.object.sync_and_return()
|
||||||
|
# then
|
||||||
|
self.assertEqual(ApplicationAnnouncement.object.count(), 2)
|
||||||
|
self.assertEqual(set(ApplicationAnnouncement.object.values_list("id", flat=True)), announcement_ids)
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + '.get_all_applications_announcements')
|
||||||
|
def test_announcement_add(self, all_announcements_mocker):
|
||||||
|
# given
|
||||||
|
returned_announcements = DEFAULT_ANNOUNCEMENTS + [Announcement(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com")]
|
||||||
|
all_announcements_mocker.return_value = returned_announcements
|
||||||
|
# when
|
||||||
|
ApplicationAnnouncement.object.sync_and_return()
|
||||||
|
# then
|
||||||
|
self.assertEqual(ApplicationAnnouncement.object.count(), 3)
|
||||||
|
self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com"))
|
||||||
|
|
||||||
|
@patch(MODULE_PATH + '.get_all_applications_announcements')
|
||||||
|
def test_announcement_remove(self, all_announcements_mocker):
|
||||||
|
# given
|
||||||
|
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS
|
||||||
|
ApplicationAnnouncement.object.sync_and_return()
|
||||||
|
self.assertEqual(ApplicationAnnouncement.object.count(), 2)
|
||||||
|
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS[:1]
|
||||||
|
# when
|
||||||
|
ApplicationAnnouncement.object.sync_and_return()
|
||||||
|
# then
|
||||||
|
self.assertEqual(ApplicationAnnouncement.object.count(), 1)
|
||||||
|
self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test GitHub Application").exists())
|
@ -8,23 +8,61 @@ from packaging.version import Version as Pep440Version
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from allianceauth.templatetags.admin_status import (
|
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||||
|
from allianceauth.admin_status.templatetags.admin_status import (
|
||||||
_current_notifications,
|
_current_notifications,
|
||||||
_current_version_summary,
|
_current_version_summary,
|
||||||
_fetch_list_from_gitlab,
|
_fetch_list_from_gitlab,
|
||||||
_fetch_notification_issues_from_gitlab,
|
|
||||||
_latests_versions,
|
_latests_versions,
|
||||||
status_overview,
|
status_overview,
|
||||||
)
|
)
|
||||||
|
|
||||||
MODULE_PATH = 'allianceauth.templatetags'
|
MODULE_PATH = 'allianceauth.admin_status.templatetags'
|
||||||
|
|
||||||
|
|
||||||
def create_tags_list(tag_names: list):
|
def create_tags_list(tag_names: list):
|
||||||
return [{'name': str(tag_name)} for tag_name in tag_names]
|
return [{'name': str(tag_name)} for tag_name in tag_names]
|
||||||
|
|
||||||
|
def get_app_announcement_as_dict(app_announcement: ApplicationAnnouncement) -> dict:
|
||||||
|
"""Transforms an app announcement object in a dict easy to compare"""
|
||||||
|
return {
|
||||||
|
"application_name": app_announcement.application_name,
|
||||||
|
"announcement_number": app_announcement.announcement_number,
|
||||||
|
"announcement_text": app_announcement.announcement_text,
|
||||||
|
"announcement_url": app_announcement.announcement_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
|
GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
|
||||||
|
STORED_NOTIFICATIONS = [
|
||||||
|
ApplicationAnnouncement(
|
||||||
|
application_name="Test GitHub Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitHub issue",
|
||||||
|
announcement_url="https://github.com/r0kym/test/issues/1",
|
||||||
|
announcement_hash="hash1",
|
||||||
|
),
|
||||||
|
ApplicationAnnouncement(
|
||||||
|
application_name="Test Gitlab Application",
|
||||||
|
announcement_number=1,
|
||||||
|
announcement_text="GitLab issue",
|
||||||
|
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
announcement_hash="hash2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ANNOUNCEMENT_DICT = [
|
||||||
|
{
|
||||||
|
"application_name": "Test GitHub Application",
|
||||||
|
"announcement_number": 1,
|
||||||
|
"announcement_text": "GitHub issue",
|
||||||
|
"announcement_url": "https://github.com/r0kym/test/issues/1",
|
||||||
|
}, {
|
||||||
|
"application_name": "Test Gitlab Application",
|
||||||
|
"announcement_number": 1,
|
||||||
|
"announcement_text": "GitLab issue",
|
||||||
|
"announcement_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
|
||||||
|
}
|
||||||
|
]
|
||||||
GITHUB_NOTIFICATION_ISSUES = [
|
GITHUB_NOTIFICATION_ISSUES = [
|
||||||
{
|
{
|
||||||
'id': 1,
|
'id': 1,
|
||||||
@ -52,6 +90,10 @@ GITHUB_NOTIFICATION_ISSUES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
TEST_VERSION = '2.6.5'
|
TEST_VERSION = '2.6.5'
|
||||||
|
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
|
||||||
|
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
|
||||||
|
'?labels=announcement&state=opened'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestStatusOverviewTag(TestCase):
|
class TestStatusOverviewTag(TestCase):
|
||||||
@ -107,18 +149,19 @@ class TestNotifications(TestCase):
|
|||||||
)
|
)
|
||||||
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
|
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
|
||||||
# when
|
# when
|
||||||
result = _fetch_notification_issues_from_gitlab()
|
result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10)
|
||||||
# then
|
# then
|
||||||
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
|
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
|
||||||
|
|
||||||
@patch(MODULE_PATH + '.admin_status.cache')
|
@patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement')
|
||||||
def test_current_notifications_normal(self, mock_cache):
|
def test_current_notifications_normal(self, mock_application_announcement):
|
||||||
# given
|
# given
|
||||||
mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
|
mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS
|
||||||
# when
|
# when
|
||||||
result = _current_notifications()
|
result = _current_notifications()
|
||||||
# then
|
# then
|
||||||
self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5])
|
for notification in result["notifications"]:
|
||||||
|
self.assertIn(get_app_announcement_as_dict(notification), ANNOUNCEMENT_DICT)
|
||||||
|
|
||||||
@requests_mock.mock()
|
@requests_mock.mock()
|
||||||
def test_current_notifications_failed(self, requests_mocker):
|
def test_current_notifications_failed(self, requests_mocker):
|
||||||
@ -131,16 +174,7 @@ class TestNotifications(TestCase):
|
|||||||
# when
|
# when
|
||||||
result = _current_notifications()
|
result = _current_notifications()
|
||||||
# then
|
# then
|
||||||
self.assertEqual(result['notifications'], [])
|
self.assertEqual(list(result['notifications']), [])
|
||||||
|
|
||||||
@patch(MODULE_PATH + '.admin_status.cache')
|
|
||||||
def test_current_notifications_is_none(self, mock_cache):
|
|
||||||
# given
|
|
||||||
mock_cache.get_or_set.return_value = None
|
|
||||||
# when
|
|
||||||
result = _current_notifications()
|
|
||||||
# then
|
|
||||||
self.assertEqual(result['notifications'], [])
|
|
||||||
|
|
||||||
|
|
||||||
class TestCeleryQueueLength(TestCase):
|
class TestCeleryQueueLength(TestCase):
|
@ -74,14 +74,14 @@ def dashboard_characters(request):
|
|||||||
|
|
||||||
def dashboard_admin(request):
|
def dashboard_admin(request):
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return render_to_string('allianceauth/admin-status/include.html', request=request)
|
return render_to_string('admin-status/include.html', request=request)
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def dashboard_esi_check(request):
|
def dashboard_esi_check(request):
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return render_to_string('allianceauth/admin-status/esi_check.html', request=request)
|
return render_to_string('admin-status/esi_check.html', request=request)
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
from allianceauth.admin_status.hooks import AppAnnouncementHook
|
||||||
from allianceauth.hooks import get_hooks
|
from allianceauth.hooks import get_hooks
|
||||||
from allianceauth.menu.hooks import MenuItemHook
|
from allianceauth.menu.hooks import MenuItemHook
|
||||||
|
|
||||||
@ -145,6 +146,16 @@ class MenuItemHook(MenuItemHook):
|
|||||||
def __init_subclass__(cls) -> None:
|
def __init_subclass__(cls) -> None:
|
||||||
return super().__init_subclass__()
|
return super().__init_subclass__()
|
||||||
|
|
||||||
|
class AppAnnouncementHook(AppAnnouncementHook):
|
||||||
|
"""
|
||||||
|
AppAnnouncementHook shim to allianceauth.admin_status.hooks
|
||||||
|
|
||||||
|
:param AppAnnouncementHook: _description_
|
||||||
|
:type AppAnnouncementHook: _type_
|
||||||
|
"""
|
||||||
|
def __init_subclass__(cls) -> None:
|
||||||
|
return super().__init_subclass__()
|
||||||
|
|
||||||
|
|
||||||
class UrlHook:
|
class UrlHook:
|
||||||
"""A hook for registering the URLs of a Django app.
|
"""A hook for registering the URLs of a Django app.
|
||||||
|
53
docs/development/custom/app-announcement-hooks.md
Normal file
53
docs/development/custom/app-announcement-hooks.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Announcement Hooks
|
||||||
|
|
||||||
|
This hook allows the issues opened on your application repository to be displayed on the alliance auth front page to
|
||||||
|
administrators.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.Service.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`.
|
||||||
|
|
||||||
|
### Service
|
||||||
|
|
||||||
|
This variable is an enumeration of the class `AppAnnouncemementHook.Service`
|
||||||
|
|
||||||
|
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.Service
|
||||||
|
: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"`.
|
BIN
docs/development/custom/img/app_announcement_hook_example.png
Executable file
BIN
docs/development/custom/img/app_announcement_hook_example.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
@ -8,6 +8,7 @@ This section describes how to extend **Alliance Auth** with custom apps, service
|
|||||||
integrating-services
|
integrating-services
|
||||||
menu-hooks
|
menu-hooks
|
||||||
url-hooks
|
url-hooks
|
||||||
|
app-announcement-hooks
|
||||||
logging
|
logging
|
||||||
custom-themes
|
custom-themes
|
||||||
aa-framework
|
aa-framework
|
||||||
|
Loading…
x
Reference in New Issue
Block a user