mirror of
https://gitlab.com/allianceauth/allianceauth.git
synced 2026-02-08 16:16:18 +01:00
Application Announcement Hook
This commit is contained in:
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
|
||||
@@ -0,0 +1,13 @@
|
||||
{% load humanize %}
|
||||
{% load admin_status %}
|
||||
|
||||
<div
|
||||
class="progress-bar bg-{{ level }} task-status-progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow="{% decimal_widthratio tasks_count tasks_total 100 %}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {% decimal_widthratio tasks_count tasks_total 100 %}%;"
|
||||
>
|
||||
<span>{% widthratio tasks_count tasks_total 100 %}%</span>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
{% load i18n %}
|
||||
<div id="esi-alert" class="col-12 collapse">
|
||||
<div class="alert alert-warning">
|
||||
<p class="text-center ">{% translate 'Your Server received an ESI error response code of ' %}<b id="esi-code">?</b></p>
|
||||
<hr>
|
||||
<pre id="esi-data" class="text-center text-wrap"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const elemCard = document.getElementById("esi-alert");
|
||||
const elemMessage = document.getElementById("esi-data");
|
||||
const elemCode = document.getElementById("esi-code");
|
||||
|
||||
fetch('{% url "authentication:esi_check" %}')
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.then((responseJson) => {
|
||||
console.log("ESI Check: ", JSON.stringify(responseJson, null, 2));
|
||||
|
||||
const status = responseJson.status;
|
||||
if (status !== 200) {
|
||||
elemCode.textContent = status
|
||||
elemMessage.textContent = responseJson.data.error;
|
||||
new bootstrap.Collapse(elemCard, {
|
||||
toggle: true
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
{% load admin_status %}
|
||||
|
||||
{% status_overview %}
|
||||
150
allianceauth/admin_status/templates/admin-status/overview.html
Normal file
150
allianceauth/admin_status/templates/admin-status/overview.html
Normal file
@@ -0,0 +1,150 @@
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% if notifications %}
|
||||
<div id="aa-dashboard-panel-admin-application-notifications" class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% translate "Announcements" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div>
|
||||
<ul class="list-group">
|
||||
{% for notif in notifications %}
|
||||
{% if not notif.is_hidden %}
|
||||
<li class="list-group-item">
|
||||
<span class="badge bg-info me-2">{{ notif.application_name }}</span>
|
||||
<a href="{{ notif.announcement_url }}" target="_blank">#{{ notif.announcement_number }} {{ notif.announcement_text }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{# TODO maybe add some disclaimer that those are managed by application devs? #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body row">
|
||||
<div id="aa-dashboard-panel-software-version" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
|
||||
{% translate "Software Version" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div>
|
||||
<ul class="list-group list-group-horizontal w-100" role="group" aria-label="{% translate 'Software Version' %}">
|
||||
<li class="list-group-item w-100">
|
||||
<div class="btn h-100 w-100 cursor-default">
|
||||
<h5 class="list-group-item-heading">{% translate "Current" %}</h5>
|
||||
<p class="list-group-item-text">{{ current_version }}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="list-group-item bg-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %} w-100">
|
||||
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_patch_version }}">
|
||||
<h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5>
|
||||
|
||||
<p class="list-group-item-text">
|
||||
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
|
||||
{{ latest_patch_version }}
|
||||
{% if not latest_patch %}<br>{% translate "Update available" %}{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if latest_beta %}
|
||||
<li class="list-group-item bg-info w-100">
|
||||
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_beta_version }}">
|
||||
<h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5>
|
||||
|
||||
<p class="list-group-item-text">
|
||||
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
|
||||
{{ latest_beta_version }}
|
||||
<br>{% translate "Pre-Release available" %}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="aa-dashboard-panel-task-queue" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
|
||||
{% translate "Task Queue" as widget_title %}
|
||||
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{% blocktranslate with total=tasks_total|intcomma latest=earliest_task|timesince|default:"?" %}
|
||||
Status of {{ total }} processed tasks • last {{ latest }}
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="progress"
|
||||
style="height: 21px;"
|
||||
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
|
||||
>
|
||||
{% include "admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
|
||||
{% include "admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
|
||||
{% include "admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<span id="task-counts">?</span> {% translate 'running' %} |
|
||||
<span id="queued-tasks-count">?</span> {% translate 'queued' %}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
const elemRunning = document.getElementById("task-counts");
|
||||
const elemQueued = document.getElementById("queued-tasks-count");
|
||||
|
||||
fetch('{% url "authentication:task_counts" %}')
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.then((responseJson) => {
|
||||
const running = responseJson.tasks_running;
|
||||
if (running == null) {
|
||||
elemRunning.textContent = "N/A";
|
||||
} else {
|
||||
elemRunning.textContent = running.toLocaleString();
|
||||
}
|
||||
|
||||
const queued = responseJson.tasks_queued;
|
||||
if (queued == null) {
|
||||
elemQueued.textContent = "N/A";
|
||||
} else {
|
||||
elemQueued.textContent = queued.toLocaleString();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
elemRunning.textContent = "ERROR";
|
||||
elemQueued.textContent = "ERROR";
|
||||
});
|
||||
</script>
|
||||
0
allianceauth/admin_status/templatetags/__init__.py
Normal file
0
allianceauth/admin_status/templatetags/__init__.py
Normal file
181
allianceauth/admin_status/templatetags/admin_status.py
Normal file
181
allianceauth/admin_status/templatetags/admin_status.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from packaging.version import InvalidVersion, Version as Pep440Version
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from allianceauth import __version__
|
||||
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||
from allianceauth.authentication.task_statistics.counters import (
|
||||
dashboard_results,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
|
||||
# cache timers
|
||||
TAG_CACHE_TIME = 3600 # 1 hours
|
||||
NOTIFICATION_CACHE_TIME = 300 # 5 minutes
|
||||
# timeout for all requests
|
||||
REQUESTS_TIMEOUT = 5 # 5 seconds
|
||||
# max pages to be fetched from gitlab
|
||||
MAX_PAGES = 50
|
||||
|
||||
GITLAB_AUTH_REPOSITORY_TAGS_URL = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def decimal_widthratio(this_value, max_value, max_width) -> str:
|
||||
if max_value == 0:
|
||||
return str(0)
|
||||
|
||||
return str(round(this_value / max_value * max_width, 2))
|
||||
|
||||
|
||||
@register.inclusion_tag('admin-status/overview.html')
|
||||
def status_overview() -> dict:
|
||||
response = {
|
||||
"notifications": [],
|
||||
"current_version": __version__,
|
||||
"tasks_succeeded": 0,
|
||||
"tasks_retried": 0,
|
||||
"tasks_failed": 0,
|
||||
"tasks_total": 0,
|
||||
"tasks_hours": 0,
|
||||
"earliest_task": None,
|
||||
}
|
||||
response.update(_current_notifications())
|
||||
response.update(_current_version_summary())
|
||||
response.update(_celery_stats())
|
||||
return response
|
||||
|
||||
|
||||
def _celery_stats() -> dict:
|
||||
hours = getattr(settings, "ALLIANCEAUTH_DASHBOARD_TASKS_MAX_HOURS", 24)
|
||||
results = dashboard_results(hours=hours)
|
||||
return {
|
||||
"tasks_succeeded": results.succeeded,
|
||||
"tasks_retried": results.retried,
|
||||
"tasks_failed": results.failed,
|
||||
"tasks_total": results.total,
|
||||
"tasks_hours": results.hours,
|
||||
"earliest_task": results.earliest_task,
|
||||
}
|
||||
|
||||
|
||||
def _current_notifications() -> dict:
|
||||
"""returns announcements from AllianceAuth and third party applications"""
|
||||
|
||||
application_notifications = ApplicationAnnouncement.object.sync_and_return()
|
||||
|
||||
response = {
|
||||
'notifications': application_notifications,
|
||||
}
|
||||
return response
|
||||
|
||||
def _current_version_summary() -> dict:
|
||||
"""returns the current version info"""
|
||||
try:
|
||||
tags = cache.get_or_set(
|
||||
'git_release_tags', _fetch_tags_from_gitlab, TAG_CACHE_TIME
|
||||
)
|
||||
except requests.HTTPError:
|
||||
logger.warning('Error while getting gitlab release tags', exc_info=True)
|
||||
return {}
|
||||
|
||||
if not tags:
|
||||
return {}
|
||||
|
||||
(
|
||||
latest_patch_version,
|
||||
latest_beta_version
|
||||
) = _latests_versions(tags)
|
||||
current_version = Pep440Version(__version__)
|
||||
|
||||
has_latest_patch = \
|
||||
current_version >= latest_patch_version if latest_patch_version else False
|
||||
has_current_beta = \
|
||||
current_version <= latest_beta_version \
|
||||
and latest_patch_version <= latest_beta_version \
|
||||
if latest_beta_version else False
|
||||
|
||||
response = {
|
||||
'latest_patch': has_latest_patch,
|
||||
'latest_beta': has_current_beta,
|
||||
'current_version': str(current_version),
|
||||
'latest_patch_version': str(latest_patch_version),
|
||||
'latest_beta_version': str(latest_beta_version)
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
def _fetch_tags_from_gitlab():
|
||||
return _fetch_list_from_gitlab(GITLAB_AUTH_REPOSITORY_TAGS_URL)
|
||||
|
||||
|
||||
def _latests_versions(tags: list) -> tuple:
|
||||
"""returns latests version from given tags list
|
||||
|
||||
Non-compliant tags will be ignored
|
||||
"""
|
||||
versions = []
|
||||
betas = []
|
||||
for tag in tags:
|
||||
try:
|
||||
version = Pep440Version(tag.get('name'))
|
||||
except InvalidVersion:
|
||||
pass
|
||||
else:
|
||||
if version.is_prerelease or version.is_devrelease:
|
||||
betas.append(version)
|
||||
else:
|
||||
versions.append(version)
|
||||
|
||||
latest_patch_version = max(versions)
|
||||
latest_beta_version = max(betas)
|
||||
return (
|
||||
latest_patch_version,
|
||||
latest_beta_version
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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())
|
||||
358
allianceauth/admin_status/tests/test_templatetags.py
Normal file
358
allianceauth/admin_status/tests/test_templatetags.py
Normal file
@@ -0,0 +1,358 @@
|
||||
from math import ceil
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
import requests_mock
|
||||
from packaging.version import Version as Pep440Version
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from allianceauth.admin_status.models import ApplicationAnnouncement
|
||||
from allianceauth.admin_status.templatetags.admin_status import (
|
||||
_current_notifications,
|
||||
_current_version_summary,
|
||||
_fetch_list_from_gitlab,
|
||||
_latests_versions,
|
||||
status_overview,
|
||||
)
|
||||
|
||||
MODULE_PATH = 'allianceauth.admin_status.templatetags'
|
||||
|
||||
|
||||
def create_tags_list(tag_names: list):
|
||||
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'])
|
||||
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 = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'first issue'
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'title': 'second issue'
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'title': 'third issue'
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'title': 'forth issue'
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'title': 'fifth issue'
|
||||
},
|
||||
{
|
||||
'id': 6,
|
||||
'title': 'sixth issue'
|
||||
},
|
||||
]
|
||||
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):
|
||||
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
|
||||
@patch(MODULE_PATH + '.admin_status._current_version_summary')
|
||||
@patch(MODULE_PATH + '.admin_status._current_notifications')
|
||||
def test_status_overview(
|
||||
self, mock_current_notifications, mock_current_version_info
|
||||
):
|
||||
# given
|
||||
notifications = {
|
||||
'notifications': GITHUB_NOTIFICATION_ISSUES[:5]
|
||||
}
|
||||
mock_current_notifications.return_value = notifications
|
||||
version_info = {
|
||||
'latest_major': True,
|
||||
'latest_minor': True,
|
||||
'latest_patch': True,
|
||||
'latest_beta': False,
|
||||
'current_version': TEST_VERSION,
|
||||
'latest_major_version': '2.4.5',
|
||||
'latest_minor_version': '2.4.0',
|
||||
'latest_patch_version': '2.4.5',
|
||||
'latest_beta_version': '2.4.4a1',
|
||||
}
|
||||
mock_current_version_info.return_value = version_info
|
||||
# when
|
||||
result = status_overview()
|
||||
# then
|
||||
self.assertEqual(result["notifications"], GITHUB_NOTIFICATION_ISSUES[:5])
|
||||
self.assertTrue(result["latest_major"])
|
||||
self.assertTrue(result["latest_minor"])
|
||||
self.assertTrue(result["latest_patch"])
|
||||
self.assertFalse(result["latest_beta"])
|
||||
self.assertEqual(result["current_version"], TEST_VERSION)
|
||||
self.assertEqual(result["latest_major_version"], '2.4.5')
|
||||
self.assertEqual(result["latest_minor_version"], '2.4.0')
|
||||
self.assertEqual(result["latest_patch_version"], '2.4.5')
|
||||
self.assertEqual(result["latest_beta_version"], '2.4.4a1')
|
||||
|
||||
|
||||
class TestNotifications(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
cache.clear()
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_fetch_notification_issues_from_gitlab(self, requests_mocker):
|
||||
# given
|
||||
url = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
|
||||
'?labels=announcement'
|
||||
)
|
||||
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
|
||||
# when
|
||||
result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10)
|
||||
# then
|
||||
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
|
||||
|
||||
@patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement')
|
||||
def test_current_notifications_normal(self, mock_application_announcement):
|
||||
# given
|
||||
mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS
|
||||
# when
|
||||
result = _current_notifications()
|
||||
# then
|
||||
for notification in result["notifications"]:
|
||||
self.assertIn(get_app_announcement_as_dict(notification), ANNOUNCEMENT_DICT)
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_current_notifications_failed(self, requests_mocker):
|
||||
# given
|
||||
url = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
|
||||
'?labels=announcement'
|
||||
)
|
||||
requests_mocker.get(url, status_code=404)
|
||||
# when
|
||||
result = _current_notifications()
|
||||
# then
|
||||
self.assertEqual(list(result['notifications']), [])
|
||||
|
||||
|
||||
class TestCeleryQueueLength(TestCase):
|
||||
|
||||
def test_get_celery_queue_length(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestVersionTags(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
cache.clear()
|
||||
|
||||
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
|
||||
@patch(MODULE_PATH + '.admin_status.cache')
|
||||
def test_current_version_info_normal(self, mock_cache):
|
||||
# given
|
||||
mock_cache.get_or_set.return_value = GITHUB_TAGS
|
||||
# when
|
||||
result = _current_version_summary()
|
||||
# then
|
||||
self.assertTrue(result['latest_patch'])
|
||||
self.assertEqual(result['latest_patch_version'], '2.4.5')
|
||||
self.assertEqual(result['latest_beta_version'], '2.4.6a1')
|
||||
|
||||
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
|
||||
@requests_mock.mock()
|
||||
def test_current_version_info_failed(self, requests_mocker):
|
||||
# given
|
||||
url = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
|
||||
'/repository/tags'
|
||||
)
|
||||
requests_mocker.get(url, status_code=500)
|
||||
# when
|
||||
result = _current_version_summary()
|
||||
# then
|
||||
self.assertEqual(result, {})
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_fetch_tags_from_gitlab(self, requests_mocker):
|
||||
# given
|
||||
url = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
|
||||
'/repository/tags'
|
||||
)
|
||||
requests_mocker.get(url, json=GITHUB_TAGS)
|
||||
# when
|
||||
result = _current_version_summary()
|
||||
# then
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
|
||||
@patch(MODULE_PATH + '.admin_status.cache')
|
||||
def test_current_version_info_return_no_data(self, mock_cache):
|
||||
# given
|
||||
mock_cache.get_or_set.return_value = None
|
||||
# when
|
||||
result = _current_version_summary()
|
||||
# then
|
||||
self.assertEqual(result, {})
|
||||
|
||||
|
||||
class TestLatestsVersion(TestCase):
|
||||
|
||||
def test_all_version_types_defined(self):
|
||||
|
||||
tags = create_tags_list(
|
||||
['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
|
||||
)
|
||||
patch, beta = _latests_versions(tags)
|
||||
self.assertEqual(patch, Pep440Version('2.1.1'))
|
||||
self.assertEqual(beta, Pep440Version('2.1.1a1'))
|
||||
|
||||
def test_major_and_minor_not_defined_with_zero(self):
|
||||
|
||||
tags = create_tags_list(
|
||||
['2.1.2', '2.1.1', '2.0.1', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
|
||||
)
|
||||
patch, beta = _latests_versions(tags)
|
||||
self.assertEqual(patch, Pep440Version('2.1.2'))
|
||||
self.assertEqual(beta, Pep440Version('2.1.1a1'))
|
||||
|
||||
def test_can_ignore_invalid_versions(self):
|
||||
|
||||
tags = create_tags_list(
|
||||
['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', 'invalid']
|
||||
)
|
||||
patch, beta = _latests_versions(tags)
|
||||
self.assertEqual(patch, Pep440Version('2.1.1'))
|
||||
self.assertEqual(beta, Pep440Version('2.1.1a1'))
|
||||
|
||||
|
||||
class TestFetchListFromGitlab(TestCase):
|
||||
|
||||
page_size = 2
|
||||
|
||||
def setUp(self):
|
||||
self.url = (
|
||||
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
|
||||
'/repository/tags'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def my_callback(cls, request, context):
|
||||
page = int(request.qs['page'][0])
|
||||
start = (page - 1) * cls.page_size
|
||||
end = start + cls.page_size
|
||||
return GITHUB_TAGS[start:end]
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_can_fetch_one_page_with_header(self, requests_mocker):
|
||||
headers = {
|
||||
'x-total-pages': '1'
|
||||
}
|
||||
requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
|
||||
result = _fetch_list_from_gitlab(self.url)
|
||||
self.assertEqual(result, GITHUB_TAGS)
|
||||
self.assertEqual(requests_mocker.call_count, 1)
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_can_fetch_one_page_wo_header(self, requests_mocker):
|
||||
requests_mocker.get(self.url, json=GITHUB_TAGS)
|
||||
result = _fetch_list_from_gitlab(self.url)
|
||||
self.assertEqual(result, GITHUB_TAGS)
|
||||
self.assertEqual(requests_mocker.call_count, 1)
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_can_fetch_one_page_and_ignore_invalid_header(self, requests_mocker):
|
||||
headers = {
|
||||
'x-total-pages': 'invalid'
|
||||
}
|
||||
requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
|
||||
result = _fetch_list_from_gitlab(self.url)
|
||||
self.assertEqual(result, GITHUB_TAGS)
|
||||
self.assertEqual(requests_mocker.call_count, 1)
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_can_fetch_multiple_pages(self, requests_mocker):
|
||||
total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
|
||||
headers = {
|
||||
'x-total-pages': str(total_pages)
|
||||
}
|
||||
requests_mocker.get(self.url, json=self.my_callback, headers=headers)
|
||||
result = _fetch_list_from_gitlab(self.url)
|
||||
self.assertEqual(result, GITHUB_TAGS)
|
||||
self.assertEqual(requests_mocker.call_count, total_pages)
|
||||
|
||||
@requests_mock.mock()
|
||||
def test_can_fetch_given_number_of_pages_only(self, requests_mocker):
|
||||
total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
|
||||
headers = {
|
||||
'x-total-pages': str(total_pages)
|
||||
}
|
||||
requests_mocker.get(self.url, json=self.my_callback, headers=headers)
|
||||
max_pages = 2
|
||||
result = _fetch_list_from_gitlab(self.url, max_pages=max_pages)
|
||||
self.assertEqual(result, GITHUB_TAGS[:4])
|
||||
self.assertEqual(requests_mocker.call_count, max_pages)
|
||||
|
||||
@requests_mock.mock()
|
||||
@patch(MODULE_PATH + '.admin_status.logger')
|
||||
def test_should_not_raise_any_exception_from_github_request_but_log_as_warning(
|
||||
self, requests_mocker, mock_logger
|
||||
):
|
||||
for my_exception in [
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.HTTPError,
|
||||
requests.exceptions.URLRequired,
|
||||
requests.exceptions.TooManyRedirects,
|
||||
requests.exceptions.ConnectTimeout,
|
||||
requests.exceptions.Timeout,
|
||||
|
||||
]:
|
||||
requests_mocker.get(self.url, exc=my_exception)
|
||||
try:
|
||||
result = _fetch_list_from_gitlab(self.url)
|
||||
except Exception as ex:
|
||||
self.fail(f"Unexpected exception raised: {ex}")
|
||||
self.assertTrue(mock_logger.warning.called)
|
||||
self.assertListEqual(result, [])
|
||||
Reference in New Issue
Block a user