Compare commits

..

16 Commits

Author SHA1 Message Date
T'rahk Rokym
2891b7e439 The image wasn't added correctly 2025-05-19 23:05:54 +02:00
T'rahk Rokym
5cb5aef7e4 Fix doc 2025-05-19 23:04:26 +02:00
T'rahk Rokym
aa21cab967 Increase test coverage 2025-05-19 18:34:54 +02:00
T'rahk Rokym
0e588bf5cd Change announcement card title 2025-05-19 18:13:15 +02:00
T'rahk Rokym
39071f7fc3 fix pre-commit 2025-05-18 23:20:42 +02:00
T'rahk Rokym
97f603c138 Move all the code in an application and stores Announcements in the database so they can be marked as closed 2025-05-18 23:16:09 +02:00
T'rahk Rokym
c9b07c12a0 Last fixes 2025-04-21 16:31:39 +02:00
T'rahk Rokym
fd84f7fe15 Doc fix 2025-04-21 16:12:00 +02:00
T'rahk Rokym
92d8c699eb Ignore closed issues 2025-04-21 16:10:58 +02:00
T'rahk Rokym
9cc3283399 Move logger statements to debug 2025-04-21 15:44:15 +02:00
T'rahk Rokym
401c093b74 Remove template hooks 2025-04-21 15:26:00 +02:00
T'rahk Rokym
b3534f4f44 Basic documentation 2025-04-21 13:16:21 +02:00
T'rahk Rokym
f88249c8fc Enable caching 2025-04-21 12:35:55 +02:00
T'rahk Rokym
ec34d7fd29 Properly translates GitHub attributes to GitLab 2025-04-21 12:35:03 +02:00
T'rahk Rokym
cd9d985732 Upgrades
- Error handling per hook
- Fix github redirect
- Better code for github pagination
2025-04-21 12:30:32 +02:00
T'rahk Rokym
1c1e219037 Basic implementation of app hooks
Still need to remove the examples and add tests
2025-04-21 01:12:40 +02:00
38 changed files with 892 additions and 127 deletions

View File

@@ -24,14 +24,20 @@ exclude: |
)
repos:
# Code Upgrades
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.4
hooks:
# Run the linter, and only the linter
- id: ruff
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.25.0
rev: 1.24.0
hooks:
- id: django-upgrade
args: [--target-version=5.2]
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
args: [--target-version=5.1]
- repo: https://github.com/asottile/pyupgrade # Ruff doesnt get everything.
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py310-plus]
@@ -53,7 +59,7 @@ repos:
- id: detect-private-key
- id: check-case-conflict
# Python checks
# - id: check-docstring-first
#
- id: debug-statements
# - id: requirements-txt-fixer
- id: fix-encoding-pragma
@@ -71,22 +77,21 @@ repos:
hooks:
- id: editorconfig-checker
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0
rev: v0.44.0
hooks:
- id: markdownlint
language: node
args:
- --disable=MD013
# Infrastructure
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.6.0
rev: v2.5.1
hooks:
- id: pyproject-fmt
args:
- --indent=4
additional_dependencies:
- tox==4.26.0 # https://github.com/tox-dev/tox/releases/latest
- tox==4.24.1 # https://github.com/tox-dev/tox/releases/latest
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.5.0
hooks:

View File

@@ -5,7 +5,7 @@ manage online service access.
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
__version__ = '5.0.0a3'
__title__ = 'AllianceAuth'
__version__ = '5.0.0a1'
__title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}'

View File

View 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

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AdminStatusApplication(AppConfig):
name = 'allianceauth.admin_status'
label = 'admin_status'

View 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

View 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(),
)

View 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()),
],
),
]

View 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

View File

@@ -2,44 +2,24 @@
{% load humanize %}
{% 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-body">
{% translate "Alliance Auth Notifications" as widget_title %}
{% translate "Announcements" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<ul class="list-group">
{% for notif in notifications %}
<li class="list-group-item">
{% if notif.state == 'opened' %}
<span class="badge bg-success me-2">{% translate "Open" %}</span>
{% else %}
<span class="badge bg-danger me-2">{% translate "Closed" %}</span>
{% 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>
{% 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>
<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>
{# TODO maybe add some disclaimer that those are managed by application devs? #}
</div>
</div>
</div>
@@ -107,9 +87,9 @@
style="height: 21px;"
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 "allianceauth/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="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>
@@ -118,6 +98,20 @@
</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>

View File

@@ -8,6 +8,7 @@ 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,
)
@@ -25,10 +26,6 @@ MAX_PAGES = 50
GITLAB_AUTH_REPOSITORY_TAGS_URL = (
'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__)
@@ -41,7 +38,7 @@ def decimal_widthratio(this_value, max_value, max_width) -> str:
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:
response = {
"notifications": [],
@@ -73,32 +70,15 @@ def _celery_stats() -> dict:
def _current_notifications() -> dict:
"""returns the newest 5 announcement issues"""
try:
notifications = cache.get_or_set(
'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 = []
"""returns announcements from AllianceAuth and third party applications"""
application_notifications = ApplicationAnnouncement.object.sync_and_return()
response = {
'notifications': top_notifications,
'notifications': application_notifications,
}
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:
"""returns the current version info"""
try:

View 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)

View 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())

View File

@@ -8,23 +8,61 @@ from packaging.version import Version as Pep440Version
from django.core.cache import cache
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_version_summary,
_fetch_list_from_gitlab,
_fetch_notification_issues_from_gitlab,
_latests_versions,
status_overview,
)
MODULE_PATH = 'allianceauth.templatetags'
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,
@@ -52,6 +90,10 @@ GITHUB_NOTIFICATION_ISSUES = [
},
]
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):
@@ -107,18 +149,19 @@ class TestNotifications(TestCase):
)
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
# when
result = _fetch_notification_issues_from_gitlab()
result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10)
# then
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_normal(self, mock_cache):
@patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement')
def test_current_notifications_normal(self, mock_application_announcement):
# given
mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS
# when
result = _current_notifications()
# 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()
def test_current_notifications_failed(self, requests_mocker):
@@ -131,16 +174,7 @@ class TestNotifications(TestCase):
# when
result = _current_notifications()
# then
self.assertEqual(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'], [])
self.assertEqual(list(result['notifications']), [])
class TestCeleryQueueLength(TestCase):

View File

@@ -74,14 +74,14 @@ def dashboard_characters(request):
def dashboard_admin(request):
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:
return ""
def dashboard_esi_check(request):
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:
return ""

View File

@@ -1,7 +1,6 @@
import logging
import os
from bravado.client import SwaggerClient
from bravado.exception import HTTPError, HTTPNotFound, HTTPUnprocessableEntity
from jsonschema.exceptions import RefResolutionError
@@ -9,7 +8,7 @@ from django.conf import settings
from esi.clients import esi_client_factory
from allianceauth import __version__, __title__, __url__
from allianceauth import __version__
from allianceauth.utils.django import StartupCommand
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(
@@ -176,11 +175,7 @@ class EveProvider:
class EveSwaggerProvider(EveProvider):
def __init__(self, token=None, adapter=None) -> None:
self._token = token
self.adapter = adapter or self
self._faction_list = None # what are the odds this will change? could cache forever!
def __init__(self, token=None, adapter=None):
if settings.DEBUG or StartupCommand().is_management_command:
self._client = None
logger.info('ESI client will be loaded on-demand')
@@ -188,10 +183,9 @@ class EveSwaggerProvider(EveProvider):
logger.info('Loading ESI client')
try:
self._client = esi_client_factory(
token=self._token,
ua_appname=__title__,
ua_version=__version__,
ua_url=__url__,
token=token,
spec_file=SWAGGER_SPEC_PATH,
app_info_text=f"allianceauth v{__version__}"
)
except (HTTPError, RefResolutionError):
logger.exception(
@@ -200,14 +194,15 @@ class EveSwaggerProvider(EveProvider):
)
self._client = None
self._token = token
self.adapter = adapter or self
self._faction_list = None # what are the odds this will change? could cache forever!
@property
def client(self) -> SwaggerClient:
def client(self):
if self._client is None:
self._client = esi_client_factory(
token=self._token,
ua_appname=__title__,
ua_version=__version__,
ua_url=__url__,
token=self._token, spec_file=SWAGGER_SPEC_PATH, app_info_text=("allianceauth v" + __version__)
)
return self._client

File diff suppressed because one or more lines are too long

View File

@@ -676,6 +676,16 @@ class TestEveSwaggerProvider(TestCase):
self.assertTrue(mock_esi_client_factory.called)
self.assertIsNotNone(my_provider._client)
@patch(MODULE_PATH + '.SWAGGER_SPEC_PATH', SWAGGER_OLD_SPEC_PATH)
@patch(MODULE_PATH + '.settings.DEBUG', False)
@patch('socket.socket')
def test_create_client_on_normal_startup_w_old_swagger_spec(
self, mock_socket
):
mock_socket.side_effect = Exception('Network blocked for testing')
my_provider = EveSwaggerProvider()
self.assertIsNone(my_provider._client)
@patch(MODULE_PATH + '.settings.DEBUG', True)
@patch(MODULE_PATH + '.esi_client_factory')
def test_dont_create_client_on_debug_startup(self, mock_esi_client_factory):
@@ -712,6 +722,6 @@ class TestEveSwaggerProvider(TestCase):
my_provider = EveSwaggerProvider()
my_client = my_provider.client
operation = my_client.Universe.get_universe_factions()
self.assertIn(
'AllianceAuth/1.0.0 (dummy@example.net; +https://gitlab.com/allianceauth/allianceauth)', operation.future.request.headers['User-Agent']
self.assertEqual(
operation.future.request.headers['User-Agent'], 'allianceauth v1.0.0 dummy@example.net'
)

View File

@@ -57,9 +57,9 @@ CELERYBEAT_SCHEDULE = {
'task': 'esi.tasks.cleanup_callbackredirect',
'schedule': crontab(minute='0', hour='*/4'),
},
'esi_cleanup_token_subset': { # 1/48th * 1hr = 48Hr/2Day Refresh Cycles.
'task': 'esi.tasks.cleanup_token_subset',
'schedule': crontab(minute="0", hour="*/1"),
'esi_cleanup_token': {
'task': 'esi.tasks.cleanup_token',
'schedule': crontab(minute='0', hour='0'),
},
'run_model_update': {
'task': 'allianceauth.eveonline.tasks.run_model_update',

View File

@@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.urls import include, re_path
from django.utils.functional import cached_property
from allianceauth.admin_status.hooks import AppAnnouncementHook
from allianceauth.hooks import get_hooks
from allianceauth.menu.hooks import MenuItemHook
@@ -145,6 +146,16 @@ class MenuItemHook(MenuItemHook):
def __init_subclass__(cls) -> None:
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:
"""A hook for registering the URLs of a Django app.

View File

@@ -2,7 +2,7 @@ import os
from esi.clients import EsiClientProvider
from allianceauth import __version__, __title__, __url__
from allianceauth import __version__
SWAGGER_SPEC = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'swagger.json')
@@ -12,8 +12,8 @@ get_killmails_killmail_id_killmail_hash
get_universe_types_type_id
"""
esi = EsiClientProvider(
ua_appname=__title__,
ua_version=__version__,
ua_url=__url__,
spec_file=SWAGGER_SPEC,
app_info_text=("allianceauth v" + __version__)
)

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
PROTOCOL=https://
AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN%
DOMAIN=%DOMAIN%
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v5.0.0a3
AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v5.0.0a1
# Nginx Proxy Manager
PROXY_HTTP_PORT=80

View File

@@ -1,5 +1,5 @@
FROM python:3.12-slim
ARG AUTH_VERSION=v5.0.0a3
ARG AUTH_VERSION=v5.0.0a1
ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION}
ENV AUTH_USER=allianceauth
ENV AUTH_GROUP=allianceauth

View 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.
![app_announcement_hook_example](img/app_announcement_hook_example.png)
To register an AppAnnouncementHook class, you would do the following:
```python
from allianceauth import hooks
from allianceauth.services.hooks import AppAnnouncementHook
@hooks.register('app_announcement_hook')
def announcement_hook():
return AppAnnouncementHook("Your app name", "USERNAME/REPOSITORY_NAME", AppAnnouncementHook.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"`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -8,6 +8,7 @@ This section describes how to extend **Alliance Auth** with custom apps, service
integrating-services
menu-hooks
url-hooks
app-announcement-hooks
logging
custom-themes
aa-framework

View File

@@ -266,14 +266,14 @@ Every Alliance Auth installation will come with a couple of special celery relat
Celery-once is a celery extension "that allows you to prevent multiple execution and queuing of celery tasks". What that means is that you can ensure that only one instance of a celery task runs at any given time. This can be useful, for example, if you do not want multiple instances of your task to talk to the same external service at the same time.
We use a custom backend for celery_once in Alliance Auth defined in [allianceauth.services.tasks](https://gitlab.com/allianceauth/allianceauth/-/blob/master/allianceauth/services/tasks.py#L14)
We use a custom backend for celery_once in Alliance Auth defined [here](https://gitlab.com/allianceauth/allianceauth/-/blob/master/allianceauth/services/tasks.py#L14)
You can import it for use like so:
```python
from allianceauth.services.tasks import QueueOnce
```
An example of Alliance Auth's use within the `@sharedtask` decorator, can be seen in [allianceauth.services.modules.discord.tasks](https://gitlab.com/allianceauth/allianceauth/-/blob/master/allianceauth/services/modules/discord/tasks.py#L62) in the discord module
An example of Alliance Auth's use within the `@sharedtask` decorator, can be seen [here](https://gitlab.com/allianceauth/allianceauth/-/blob/master/allianceauth/services/modules/discord/tasks.py#L62) in the discord module
You can use it like so:
```python

View File

@@ -17,7 +17,7 @@ If at any point `docker compose` does not work, but `docker-compose` does, you h
1. run `bash <(curl -s https://gitlab.com/allianceauth/allianceauth/-/raw/master/docker/scripts/download.sh)`. This will download all the files you need to install Alliance Auth and place them in a directory named `aa-docker`. Feel free to rename/move this folder.
1. run `./scripts/prepare-env.sh` to set up your environment
1. (optional) Change `PROTOCOL` to `http://` if not using SSL in `.env`
1. run `docker compose --env-file=.env up -d` (NOTE: if this command hangs, follow the instructions on [How to Setup Entropy](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged))
1. run `docker compose --env-file=.env up -d` (NOTE: if this command hangs, follow the instructions [here](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged))
1. run `docker compose exec allianceauth_gunicorn bash` to open up a terminal inside an auth container
1. run `auth migrate`
1. run `auth collectstatic`

View File

@@ -69,7 +69,7 @@ Whatever you decide to use, remember it because we'll need it when configuring y
##### Number of workers
By default, Gunicorn will spawn only one worker. The number you set this to will depend on your own server environment, how many visitors you have etc. Gunicorn suggests `(2 x $num_cores) + 1` for the number of workers. So, for example, if you have 2 cores, you want 2 x 2 + 1 = 5 workers. See [How Mnay Workers](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for the official discussion on this topic.
By default, Gunicorn will spawn only one worker. The number you set this to will depend on your own server environment, how many visitors you have etc. Gunicorn suggests `(2 x $num_cores) + 1` for the number of workers. So, for example, if you have 2 cores, you want 2 x 2 + 1 = 5 workers. See [here](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for the official discussion on this topic.
Change it by adding `--workers=5` to the command.

View File

@@ -6,7 +6,7 @@
The default installation will have 3 workers configured for Gunicorn. This will be fine on most systems, but if your system as more than one core than you might want to increase the number of workers to get better response times. Note that more workers will also need more RAM though.
The number you set this to will depend on your own server environment, how many visitors you have etc. Gunicorn suggests `(2 x $num_cores) + 1` for the number of workers. So for example, if you have 2 cores, you want 2 x 2 + 1 = 5 workers. See [How Many Workers](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for the official discussion on this topic.
The number you set this to will depend on your own server environment, how many visitors you have etc. Gunicorn suggests `(2 x $num_cores) + 1` for the number of workers. So for example, if you have 2 cores, you want 2 x 2 + 1 = 5 workers. See [here](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for the official discussion on this topic.
::::{tabs}
:::{group-tab} Ubuntu 2204, 2404

View File

@@ -20,7 +20,7 @@ classifiers = [
"Environment :: Web Environment",
"Framework :: Celery",
"Framework :: Django",
"Framework :: Django :: 5.2",
"Framework :: Django :: 5.1",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
"Operating System :: POSIX :: Linux",
@@ -42,13 +42,13 @@ dependencies = [
"beautifulsoup4",
"celery>=5.5,<6",
"celery-once>=3.0.1",
"django>=5.2,<6",
"django>=5.1,<5.2",
"django-bootstrap-form",
"django-bootstrap5>=23.3",
"django-celery-beat>=2.8",
"django-esi>=7.0.0b1",
"django-celery-beat>=2.7",
"django-esi>=5",
"django-redis>=5.4",
"django-registration>=5.2,<6",
"django-registration>=5.1,<6",
"django-solo",
"django-sortedm2m",
"django-sri",
@@ -87,6 +87,50 @@ scripts.allianceauth = "allianceauth.bin.allianceauth:main"
[tool.flit.module]
name = "allianceauth"
[tool.ruff]
line-length = 119
format.line-ending = "lf"
lint.select = [
"B", # flake8-bugbear
"C", # pylint convention
# "D", # pydocstyle, Want to turn these on, but our docstrings are lightly used
"D300", # use triple double-quotes in docstrings PEP 257
"DJ", # flake8-django
"DOC", # pylintdoc
"E", # pycodestyle error
"F", # pyflakes (flake8 base)
"G010", # logging-warn, Warn on using logging.warn
"I", # isort
"PGH005", # pygrep-hooks, python-check-mock-method
"RUF100", # basically yesqa
"UP", # pyupgrade, will target requires-python
"W", # pycodestyle Warning
]
lint.ignore = [
"E501", # Line too long, WIP across repo.
]
lint.per-file-ignores = { "*local.py" = [ "F405", "F403" ] }
lint.isort.combine-as-imports = true # profile=django
lint.isort.section-order = [
"future",
"standard-library",
"third-party",
"DJANGO",
"ESI",
"first-party",
"local-folder",
]
lint.isort.sections."DJANGO" = [
"django",
"django_redis",
"django_registration",
]
lint.isort.sections."ESI" = [
"esi",
]
[tool.isort]
profile = "django"
sections = [