Merge branch 'v5.x' of gitlab.com:allianceauth/allianceauth into djangomumble

This commit is contained in:
Joel Falknau 2025-07-07 13:59:34 +10:00
commit ac408dbc64
No known key found for this signature in database
605 changed files with 16752 additions and 7723 deletions

6
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,6 @@
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# Ruff initial formatting storm
a99315ea55339f0b6010b5c9d8703e51278fcf29

1
.gitignore vendored
View File

@ -71,6 +71,7 @@ celerybeat-schedule
#other #other
.flake8 .flake8
.ruff_cache
.pylintrc .pylintrc
Makefile Makefile
alliance_auth.sqlite3 alliance_auth.sqlite3

View File

@ -25,7 +25,7 @@ before_script:
pre-commit-check: pre-commit-check:
<<: *only-default <<: *only-default
stage: pre-commit stage: pre-commit
image: python:3.11-bookworm image: python:3.12-bookworm
# variables: # variables:
# PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit # PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
# cache: # cache:
@ -51,30 +51,6 @@ secret_detection:
stage: gitlab stage: gitlab
before_script: [] before_script: []
test-3.8-core:
<<: *only-default
image: python:3.8-bookworm
script:
- tox -e py38-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-core:
<<: *only-default
image: python:3.9-bookworm
script:
- tox -e py39-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-core: test-3.10-core:
<<: *only-default <<: *only-default
image: python:3.10-bookworm image: python:3.10-bookworm
@ -111,23 +87,11 @@ test-3.12-core:
coverage_format: cobertura coverage_format: cobertura
path: coverage.xml path: coverage.xml
test-3.8-all: test-3.13-core:
<<: *only-default <<: *only-default
image: python:3.8-bookworm image: python:3.13-rc-bookworm
script: script:
- tox -e py38-all - tox -e py313-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-all:
<<: *only-default
image: python:3.9-bookworm
script:
- tox -e py39-all
artifacts: artifacts:
when: always when: always
reports: reports:
@ -172,9 +136,21 @@ test-3.12-all:
coverage_format: cobertura coverage_format: cobertura
path: coverage.xml path: coverage.xml
test-3.13-all:
<<: *only-default
image: python:3.13-rc-bookworm
script:
- tox -e py313-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
build-test: build-test:
stage: test stage: test
image: python:3.11-bookworm image: python:3.12-bookworm
before_script: before_script:
- python -m pip install --upgrade pip - python -m pip install --upgrade pip
@ -193,13 +169,13 @@ build-test:
test-docs: test-docs:
<<: *only-default <<: *only-default
image: python:3.11-bookworm image: python:3.12-bookworm
script: script:
- tox -e docs - tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.11-bookworm image: python:3.12-bookworm
before_script: before_script:
- python -m pip install --upgrade pip - python -m pip install --upgrade pip
@ -215,10 +191,10 @@ deploy_production:
build-image: build-image:
before_script: [] before_script: []
image: docker:24.0 image: docker:27
stage: docker stage: docker
services: services:
- docker:24.0-dind - docker:27-dind
script: | script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -) CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_SHORT_SHA IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_SHORT_SHA
@ -239,10 +215,10 @@ build-image:
build-image-dev: build-image-dev:
before_script: [] before_script: []
image: docker:24.0 image: docker:27
stage: docker stage: docker
services: services:
- docker:24.0-dind - docker:27-dind
script: | script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -) CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA
@ -260,10 +236,10 @@ build-image-dev:
build-image-mr: build-image-mr:
before_script: [] before_script: []
image: docker:24.0 image: docker:27
stage: docker stage: docker
services: services:
- docker:24.0-dind - docker:27-dind
script: | script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -) CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME-$CI_COMMIT_SHORT_SHA IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME-$CI_COMMIT_SHORT_SHA

View File

@ -3,22 +3,42 @@
# Update this file: # Update this file:
# pre-commit autoupdate # pre-commit autoupdate
# Set the default language versions for the hooks
default_language_version:
python: python3 # Force all Python hooks to use Python 3
node: 22.12.0 # Force all Node hooks to use Node 22.12.0
# Globally exclude files
# https://pre-commit.com/#top_level-exclude
exclude: |
(?x)(
LICENSE|
allianceauth\/static\/allianceauth\/css\/themes\/bootstrap-locals.less|
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json|
static/(.*)/libs/|
telnetlib\.py
)
repos: repos:
# Code Upgrades # Code Upgrades
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.2
hooks:
- id: pyupgrade
args: [--py38-plus]
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: 1.17.0 rev: 1.25.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [--target-version=4.2] args: [--target-version=5.2]
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py310-plus]
# Formatting # Formatting
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v5.0.0
hooks: hooks:
# Identify invalid files # Identify invalid files
- id: check-ast - id: check-ast
@ -44,57 +64,34 @@ repos:
args: [--fix=lf] args: [--fix=lf]
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] args: [--markdown-linebreak-ext=md]
exclude: |
(?x)(
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json
)
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: |
(?x)(
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json
)
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python - repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: 2.7.3 rev: 3.2.1
hooks: hooks:
- id: editorconfig-checker - id: editorconfig-checker
exclude: |
(?x)(
LICENSE|
allianceauth\/static\/allianceauth\/css\/themes\/bootstrap-locals.less|
\.po|
\.mo|
swagger\.json|
\.ice
)
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.41.0 rev: v0.45.0
hooks: hooks:
- id: markdownlint - id: markdownlint
language: node
args: args:
- --disable=MD013 - --disable=MD013
# Infrastructure # Infrastructure
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: 2.1.3 rev: v2.6.0
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
name: pyproject.toml formatter
description: "Format the pyproject.toml file."
args: args:
- --indent=4 - --indent=4
additional_dependencies: additional_dependencies:
- tox==4.15.0 # https://github.com/tox-dev/tox/releases/latest - tox==4.26.0 # https://github.com/tox-dev/tox/releases/latest
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.5.0
hooks:
- id: tox-ini-fmt
- repo: https://github.com/abravalheri/validate-pyproject - repo: https://github.com/abravalheri/validate-pyproject
rev: v0.18 rev: v0.24.1
hooks: hooks:
- id: validate-pyproject - id: validate-pyproject
name: Validate pyproject.toml
description: "Validate the pyproject.toml file."

View File

@ -7,11 +7,11 @@ version: 2
# Set the version of Python and other tools you might need # Set the version of Python and other tools you might need
build: build:
os: ubuntu-22.04 os: ubuntu-24.04
apt_packages: apt_packages:
- redis - redis
tools: tools:
python: "3.11" python: "3.12"
jobs: jobs:
post_system_dependencies: post_system_dependencies:
- redis-server --daemonize yes - redis-server --daemonize yes

View File

@ -1,15 +1,15 @@
# Alliance Auth # Alliance Auth
[![license](https://img.shields.io/badge/license-GPLv2-green)](https://pypi.org/project/allianceauth/) [![License](https://img.shields.io/badge/license-GPLv2-green)](https://pypi.org/project/allianceauth/)
[![python](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/) [![Python Versions](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/)
[![django](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/) [![Django Versions](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/)
[![version](https://img.shields.io/pypi/v/allianceauth?label=release)](https://pypi.org/project/allianceauth/) [![Stable AA Version](https://img.shields.io/pypi/v/allianceauth?label=release)](https://pypi.org/project/allianceauth/)
[![pipeline status](https://gitlab.com/allianceauth/allianceauth/badges/master/pipeline.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master) [![Pipeline Status](https://gitlab.com/allianceauth/allianceauth/badges/master/pipeline.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master)
[![Documentation Status](https://readthedocs.org/projects/allianceauth/badge/?version=latest)](https://allianceauth.readthedocs.io/?badge=latest) [![Documentation Status](https://readthedocs.org/projects/allianceauth/badge/?version=latest)](https://allianceauth.readthedocs.io/?badge=latest)
[![coverage report](https://gitlab.com/allianceauth/allianceauth/badges/master/coverage.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master) [![Test Coverage Report](https://gitlab.com/allianceauth/allianceauth/badges/master/coverage.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master)
[![Chat on Discord](https://img.shields.io/discord/399006117012832262.svg)](https://discord.gg/fjnHAmk) [![Chat on Discord](https://img.shields.io/discord/399006117012832262.svg)](https://discord.gg/fjnHAmk)
An auth system for EVE Online to help in-game organizations manage online service access. A flexible authentication platform for EVE Online to help in-game organizations manage access to applications and services. AA provides both, a stable core, and a robust framework for community development and custom applications.
## Content ## Content
@ -22,17 +22,17 @@ An auth system for EVE Online to help in-game organizations manage online servic
## Overview ## Overview
Alliance Auth (AA) is a web site that helps Eve Online organizations efficiently manage access to applications and services. Alliance Auth (AA) is a platform that helps Eve Online organizations efficiently manage access to applications and services.
Main features: Main features:
- Automatically grants or revokes user access to external services (e.g. Discord, Mumble) and web apps (e.g. SRP requests) based on the user's current membership to [in-game organizations](https://allianceauth.readthedocs.io/en/latest/features/core/states/) and [groups](https://allianceauth.readthedocs.io/en/latest/features/core/groups/) - Automatically grants or revokes user access to external services (e.g.: Discord, Mumble) based on the user's current membership to [a variety of EVE Online affiliation](https://allianceauth.readthedocs.io/en/latest/features/core/states/) and [groups](https://allianceauth.readthedocs.io/en/latest/features/core/groups/)
- Provides a central web site where users can directly access web apps (e.g. SRP requests, Fleet Schedule) and manage their access to external services and groups. - Provides a central web site where users can directly access web apps (e.g. SRP requests, Fleet Schedule) and manage their access to external services and groups.
- Includes a set of connectors (called ["services"](https://allianceauth.readthedocs.io/en/latest/features/services/)) for integrating access management with many popular external applications / services like Discord, Mumble, Teamspeak 3, SMF and others - Includes a set of connectors (called ["Services"](https://allianceauth.readthedocs.io/en/latest/features/services/)) for integrating access management with many popular external applications / services like Discord, Mumble, Teamspeak 3, SMF and others
- Includes a set of web [apps](https://allianceauth.readthedocs.io/en/latest/features/apps/) which add many useful functions, e.g.: fleet schedule, timer board, SRP request management, fleet activity tracker - Includes a set of web [Apps](https://allianceauth.readthedocs.io/en/latest/features/apps/) which add many useful functions, e.g.: fleet schedule, timer board, SRP request management, fleet activity tracker
- Can be easily extended with additional services and apps. Many are provided by the community and can be found here: [Community Creations](https://gitlab.com/allianceauth/community-creations) - Can be easily extended with additional services and apps. Many are provided by the community and can be found here: [Community Creations](https://gitlab.com/allianceauth/community-creations)
@ -42,9 +42,15 @@ For further details about AA - including an installation guide and a full list o
## Screenshot ## Screenshot
Here is an example of the Alliance Auth web site with some plug-ins apps and services enabled: Here is an example of the Alliance Auth web site with a mixture of Services, Apps and Community Creations enabled:
![screenshot](https://i.imgur.com/2tnX9kD.png) ### Flatly Theme
![Flatly Theme](docs/_static/images/promotion/SampleInstallation-Flatly.png)
### Darkly Theme
![Darkly Theme](docs/_static/images/promotion/SampleInstallation-Darkly.png)
## Support ## Support

View File

@ -5,7 +5,7 @@ manage online service access.
# This will make sure the app is always imported when # This will make sure the app is always imported when
# Django starts so that shared_task will use this app. # Django starts so that shared_task will use this app.
__version__ = '4.4.2' __version__ = '5.0.0a3'
__title__ = 'AllianceAuth' __title__ = 'AllianceAuth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth' __url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}' 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,7 +2,7 @@
{% load admin_status %} {% load admin_status %}
<div <div
class="progress-bar bg-{{ level }} task-status-progress-bar" class="progress-bar text-bg-{{ level }} task-status-progress-bar"
role="progressbar" role="progressbar"
aria-valuenow="{% decimal_widthratio tasks_count tasks_total 100 %}" aria-valuenow="{% decimal_widthratio tasks_count tasks_total 100 %}"
aria-valuemin="0" aria-valuemin="0"

View File

@ -2,21 +2,17 @@
{% load humanize %} {% load humanize %}
{% if notifications %} {% if notifications %}
<div id="aa-dashboard-panel-admin-notifications" class="col-12 mb-3"> <div id="aa-dashboard-panel-admin-application-notifications" class="col-12 mb-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% translate "Alliance Auth Notifications" as widget_title %} {% translate "Announcements" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %} {% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div> <div>
<ul class="list-group"> <ul class="list-group">
{% for notif in notifications %} {% for notif in notifications %}
<li class="list-group-item"> <li class="list-group-item">
{% if notif.state == 'opened' %} <span class="badge text-bg-success me-2">{% translate "Open" %}</span>
<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> <a href="{{ notif.web_url }}" target="_blank">#{{ notif.iid }} {{ notif.title }}</a>
</li> </li>
{% empty %} {% empty %}
@ -28,13 +24,13 @@
<div class="text-end pt-3"> <div class="text-end pt-3">
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none"> <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);"> <span class="badge text-bg-danger">
<i class="fab fa-gitlab" aria-hidden="true"></i> <i class="fab fa-gitlab" aria-hidden="true"></i>
{% translate 'Powered by GitLab' %} {% translate 'Powered by GitLab' %}
</span> </span>
</a> </a>
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none"> <a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
<span class="badge" style="background-color: rgb(110 133 211);"> <span class="badge text-bg-info">
<i class="fab fa-discord" aria-hidden="true"></i> <i class="fab fa-discord" aria-hidden="true"></i>
{% translate 'Support Discord' %} {% translate 'Support Discord' %}
</span> </span>
@ -62,7 +58,7 @@
</div> </div>
</li> </li>
<li class="list-group-item bg-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %} w-100"> <li class="list-group-item text-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 }}"> <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> <h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5>
@ -75,7 +71,7 @@
</li> </li>
{% if latest_beta %} {% if latest_beta %}
<li class="list-group-item bg-info w-100"> <li class="list-group-item text-bg-info w-100">
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_beta_version }}"> <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> <h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5>
@ -107,9 +103,9 @@
style="height: 21px;" style="height: 21px;"
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed" title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
> >
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %} {% include "admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %} {% include "admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
{% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %} {% include "admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
</div> </div>
<p> <p>
@ -118,6 +114,20 @@
</p> </p>
</div> </div>
</div> </div>
<div class="text-end pt-3">
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none">
<span class="badge" style="background-color: rgb(230 83 40);">
<i class="fab fa-gitlab" aria-hidden="true"></i>
{% translate 'Powered by GitLab' %}
</span>
</a>
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
<span class="badge" style="background-color: rgb(110 133 211);">
<i class="fab fa-discord" aria-hidden="true"></i>
{% translate 'Support Discord' %}
</span>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from allianceauth import __version__ from allianceauth import __version__
from allianceauth.admin_status.models import ApplicationAnnouncement
from allianceauth.authentication.task_statistics.counters import ( from allianceauth.authentication.task_statistics.counters import (
dashboard_results, dashboard_results,
) )
@ -25,10 +26,6 @@ MAX_PAGES = 50
GITLAB_AUTH_REPOSITORY_TAGS_URL = ( GITLAB_AUTH_REPOSITORY_TAGS_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags' 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
) )
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
'?labels=announcement&state=opened'
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -41,10 +38,10 @@ def decimal_widthratio(this_value, max_value, max_width) -> str:
return str(round(this_value / max_value * max_width, 2)) return str(round(this_value / max_value * max_width, 2))
@register.inclusion_tag('allianceauth/admin-status/overview.html') @register.inclusion_tag('admin-status/overview.html')
def status_overview() -> dict: def status_overview() -> dict:
response = { response = {
"notifications": list(), "notifications": [],
"current_version": __version__, "current_version": __version__,
"tasks_succeeded": 0, "tasks_succeeded": 0,
"tasks_retried": 0, "tasks_retried": 0,
@ -73,32 +70,15 @@ def _celery_stats() -> dict:
def _current_notifications() -> dict: def _current_notifications() -> dict:
"""returns the newest 5 announcement issues""" """returns announcements from AllianceAuth and third party applications"""
try:
notifications = cache.get_or_set( application_notifications = ApplicationAnnouncement.object.sync_and_return()
'gitlab_notification_issues',
_fetch_notification_issues_from_gitlab,
NOTIFICATION_CACHE_TIME
)
except requests.HTTPError:
logger.warning('Error while getting gitlab notifications', exc_info=True)
top_notifications = []
else:
if notifications:
top_notifications = notifications[:5]
else:
top_notifications = []
response = { response = {
'notifications': top_notifications, 'notifications': application_notifications,
} }
return response return response
def _fetch_notification_issues_from_gitlab() -> list:
return _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, max_pages=10)
def _current_version_summary() -> dict: def _current_version_summary() -> dict:
"""returns the current version info""" """returns the current version info"""
try: try:
@ -144,8 +124,8 @@ def _latests_versions(tags: list) -> tuple:
Non-compliant tags will be ignored Non-compliant tags will be ignored
""" """
versions = list() versions = []
betas = list() betas = []
for tag in tags: for tag in tags:
try: try:
version = Pep440Version(tag.get('name')) version = Pep440Version(tag.get('name'))
@ -167,7 +147,7 @@ def _latests_versions(tags: list) -> tuple:
def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list: def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list:
"""returns a list from the GitLab API. Supports paging""" """returns a list from the GitLab API. Supports paging"""
result = list() result = []
for page in range(1, max_pages + 1): for page in range(1, max_pages + 1):
try: 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,19 +8,61 @@ from packaging.version import Version as Pep440Version
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from allianceauth.templatetags.admin_status import ( from allianceauth.admin_status.models import ApplicationAnnouncement
_current_notifications, _current_version_summary, _fetch_list_from_gitlab, from allianceauth.admin_status.templatetags.admin_status import (
_fetch_notification_issues_from_gitlab, _latests_versions, status_overview, _current_notifications,
_current_version_summary,
_fetch_list_from_gitlab,
_latests_versions,
status_overview,
) )
MODULE_PATH = 'allianceauth.templatetags' MODULE_PATH = 'allianceauth.admin_status.templatetags'
def create_tags_list(tag_names: list): def create_tags_list(tag_names: list):
return [{'name': str(tag_name)} for tag_name in tag_names] return [{'name': str(tag_name)} for tag_name in tag_names]
def get_app_announcement_as_dict(app_announcement: ApplicationAnnouncement) -> dict:
"""Transforms an app announcement object in a dict easy to compare"""
return {
"application_name": app_announcement.application_name,
"announcement_number": app_announcement.announcement_number,
"announcement_text": app_announcement.announcement_text,
"announcement_url": app_announcement.announcement_url,
}
GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1']) GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
STORED_NOTIFICATIONS = [
ApplicationAnnouncement(
application_name="Test GitHub Application",
announcement_number=1,
announcement_text="GitHub issue",
announcement_url="https://github.com/r0kym/test/issues/1",
announcement_hash="hash1",
),
ApplicationAnnouncement(
application_name="Test Gitlab Application",
announcement_number=1,
announcement_text="GitLab issue",
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
announcement_hash="hash2",
),
]
ANNOUNCEMENT_DICT = [
{
"application_name": "Test GitHub Application",
"announcement_number": 1,
"announcement_text": "GitHub issue",
"announcement_url": "https://github.com/r0kym/test/issues/1",
}, {
"application_name": "Test Gitlab Application",
"announcement_number": 1,
"announcement_text": "GitLab issue",
"announcement_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
}
]
GITHUB_NOTIFICATION_ISSUES = [ GITHUB_NOTIFICATION_ISSUES = [
{ {
'id': 1, 'id': 1,
@ -48,6 +90,10 @@ GITHUB_NOTIFICATION_ISSUES = [
}, },
] ]
TEST_VERSION = '2.6.5' TEST_VERSION = '2.6.5'
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
'?labels=announcement&state=opened'
)
class TestStatusOverviewTag(TestCase): class TestStatusOverviewTag(TestCase):
@ -103,18 +149,19 @@ class TestNotifications(TestCase):
) )
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES) requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
# when # when
result = _fetch_notification_issues_from_gitlab() result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10)
# then # then
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES) self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
@patch(MODULE_PATH + '.admin_status.cache') @patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement')
def test_current_notifications_normal(self, mock_cache): def test_current_notifications_normal(self, mock_application_announcement):
# given # given
mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS
# when # when
result = _current_notifications() result = _current_notifications()
# then # then
self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5]) for notification in result["notifications"]:
self.assertIn(get_app_announcement_as_dict(notification), ANNOUNCEMENT_DICT)
@requests_mock.mock() @requests_mock.mock()
def test_current_notifications_failed(self, requests_mocker): def test_current_notifications_failed(self, requests_mocker):
@ -127,16 +174,7 @@ class TestNotifications(TestCase):
# when # when
result = _current_notifications() result = _current_notifications()
# then # then
self.assertEqual(result['notifications'], list()) self.assertEqual(list(result['notifications']), [])
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_is_none(self, mock_cache):
# given
mock_cache.get_or_set.return_value = None
# when
result = _current_notifications()
# then
self.assertEqual(result['notifications'], list())
class TestCeleryQueueLength(TestCase): class TestCeleryQueueLength(TestCase):

View File

@ -1,15 +1,17 @@
from solo.admin import SingletonModelAdmin
from django.contrib import admin from django.contrib import admin
from .models import AnalyticsIdentifier, AnalyticsTokens from .models import AnalyticsIdentifier, AnalyticsTokens
@admin.register(AnalyticsIdentifier) @admin.register(AnalyticsIdentifier)
class AnalyticsIdentifierAdmin(admin.ModelAdmin): class AnalyticsIdentifierAdmin(SingletonModelAdmin):
search_fields = ['identifier', ] search_fields = ['identifier', ]
list_display = ('identifier',) list_display = ['identifier', ]
@admin.register(AnalyticsTokens) @admin.register(AnalyticsTokens)
class AnalyticsTokensAdmin(admin.ModelAdmin): class AnalyticsTokensAdmin(admin.ModelAdmin):
search_fields = ['name', ] search_fields = ['name', ]
list_display = ('name', 'type',) list_display = ['name', 'type', ]

View File

@ -1,6 +1,8 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AnalyticsConfig(AppConfig): class AnalyticsConfig(AppConfig):
name = 'allianceauth.analytics' name = 'allianceauth.analytics'
label = 'analytics' label = 'analytics'
verbose_name = _('Analytics')

View File

@ -1,8 +1,9 @@
# Generated by Django 3.1.4 on 2020-12-30 13:11 # Generated by Django 3.1.4 on 2020-12-30 13:11
from django.db import migrations, models
import uuid import uuid
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -21,7 +21,7 @@ def remove_aa_team_token(apps, schema_editor):
# Have to define some code to remove this identifier # Have to define some code to remove this identifier
# In case of migration rollback? # In case of migration rollback?
Tokens = apps.get_model('analytics', 'AnalyticsTokens') Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.filter(token="UA-186249766-2").delete() Tokens.objects.filter(token="UA-186249766-2").delete()
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,6 +1,5 @@
# Generated by Django 3.1.4 on 2020-12-30 08:53 # Generated by Django 3.1.4 on 2020-12-30 08:53
from uuid import uuid4
from django.db import migrations from django.db import migrations

View File

@ -1,7 +1,7 @@
# Generated by Django 3.1.4 on 2020-12-30 08:53 # Generated by Django 3.1.4 on 2020-12-30 08:53
from django.db import migrations
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations
def add_aa_team_token(apps, schema_editor): def add_aa_team_token(apps, schema_editor):
@ -51,7 +51,7 @@ def remove_aa_team_token(apps, schema_editor):
# Have to define some code to remove this identifier # Have to define some code to remove this identifier
# In case of migration rollback? # In case of migration rollback?
Tokens = apps.get_model('analytics', 'AnalyticsTokens') Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.filter(token="G-6LYSMYK8DE").delete() Tokens.objects.filter(token="G-6LYSMYK8DE").delete()
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.16 on 2024-12-11 02:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('analytics', '0009_remove_analyticstokens_ignore_paths_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='analyticsidentifier',
options={'verbose_name': 'Analytics Identifier'},
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 5.1.6 on 2025-03-04 01:03
# This was built by Deleting Every Migration, Creating one from scratch
# And porting in anything necessary
import uuid
from django.db import migrations, models
def add_aa_team_token(apps, schema_editor):
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens()
token.type = 'GA-V4'
token.token = 'G-6LYSMYK8DE'
token.secret = 'KLlpjLZ-SRGozS5f5wb_kw'
token.name = 'AA Team Public Google Analytics (V4)'
token.save()
class Migration(migrations.Migration):
replaces = [('analytics', '0001_initial'), ('analytics', '0002_add_AA_Team_Token'), ('analytics', '0003_Generate_Identifier'), ('analytics', '0004_auto_20211015_0502'), ('analytics', '0005_alter_analyticspath_ignore_path'), ('analytics', '0006_more_ignore_paths'), ('analytics', '0007_analyticstokens_secret'), ('analytics', '0008_add_AA_GA-4_Team_Token '), ('analytics', '0009_remove_analyticstokens_ignore_paths_and_more'), ('analytics', '0010_alter_analyticsidentifier_options')]
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AnalyticsIdentifier',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('identifier', models.UUIDField(default=uuid.uuid4, editable=False)),
],
options={
'verbose_name': 'Analytics Identifier',
},
),
migrations.CreateModel(
name='AnalyticsTokens',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=254)),
('type', models.CharField(choices=[('GA-U', 'Google Analytics Universal'), ('GA-V4', 'Google Analytics V4')], max_length=254)),
('token', models.CharField(max_length=254)),
('secret', models.CharField(blank=True, max_length=254)),
('send_stats', models.BooleanField(default=False)),
],
),
migrations.RunPython(
add_aa_team_token
),
]

View File

@ -1,23 +1,21 @@
from django.db import models from typing import Literal
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from uuid import uuid4 from uuid import uuid4
from solo.models import SingletonModel
class AnalyticsIdentifier(models.Model): from django.db import models
from django.utils.translation import gettext_lazy as _
identifier = models.UUIDField(
default=uuid4,
editable=False)
def save(self, *args, **kwargs): class AnalyticsIdentifier(SingletonModel):
if not self.pk and AnalyticsIdentifier.objects.exists():
# Force a single object identifier = models.UUIDField(default=uuid4, editable=False)
raise ValidationError('There is can be only one \
AnalyticsIdentifier instance') def __str__(self) -> Literal['Analytics Identifier']:
self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1 return "Analytics Identifier"
return super().save(*args, **kwargs)
class Meta:
verbose_name = "Analytics Identifier"
class AnalyticsTokens(models.Model): class AnalyticsTokens(models.Model):
@ -31,3 +29,6 @@ class AnalyticsTokens(models.Model):
token = models.CharField(max_length=254, blank=False) token = models.CharField(max_length=254, blank=False)
secret = models.CharField(max_length=254, blank=True) secret = models.CharField(max_length=254, blank=True)
send_stats = models.BooleanField(default=False) send_stats = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name

View File

@ -1,16 +1,16 @@
import requests
import logging import logging
from django.conf import settings
from django.apps import apps import requests
from celery import shared_task from celery import shared_task
from .models import AnalyticsTokens, AnalyticsIdentifier
from .utils import ( from django.apps import apps
install_stat_addons, from django.conf import settings
install_stat_tokens,
install_stat_users)
from allianceauth import __version__ from allianceauth import __version__
from .models import AnalyticsIdentifier, AnalyticsTokens
from .utils import existence_baremetal_or_docker, install_stat_addons, install_stat_tokens, install_stat_users
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BASE_URL = "https://www.google-analytics.com" BASE_URL = "https://www.google-analytics.com"
@ -67,8 +67,8 @@ def analytics_event(namespace: str,
value=value).apply_async(priority=9) value=value).apply_async(priority=9)
@shared_task() @shared_task
def analytics_daily_stats(): def analytics_daily_stats() -> None:
"""Celery Task: Do not call directly """Celery Task: Do not call directly
Gathers a series of daily statistics Gathers a series of daily statistics
@ -77,6 +77,7 @@ def analytics_daily_stats():
users = install_stat_users() users = install_stat_users()
tokens = install_stat_tokens() tokens = install_stat_tokens()
addons = install_stat_addons() addons = install_stat_addons()
existence_type = existence_baremetal_or_docker()
logger.debug("Running Daily Analytics Upload") logger.debug("Running Daily Analytics Upload")
analytics_event(namespace='allianceauth.analytics', analytics_event(namespace='allianceauth.analytics',
@ -84,6 +85,11 @@ def analytics_daily_stats():
label='existence', label='existence',
value=1, value=1,
event_type='Stats') event_type='Stats')
analytics_event(namespace='allianceauth.analytics',
task='send_install_stats',
label=existence_type,
value=1,
event_type='Stats')
analytics_event(namespace='allianceauth.analytics', analytics_event(namespace='allianceauth.analytics',
task='send_install_stats', task='send_install_stats',
label='users', label='users',
@ -99,7 +105,6 @@ def analytics_daily_stats():
label='addons', label='addons',
value=addons, value=addons,
event_type='Stats') event_type='Stats')
for appconfig in apps.get_app_configs(): for appconfig in apps.get_app_configs():
if appconfig.label in [ if appconfig.label in [
"django_celery_beat", "django_celery_beat",
@ -135,7 +140,7 @@ def analytics_daily_stats():
event_type='Stats') event_type='Stats')
@shared_task() @shared_task
def send_ga_tracking_celery_event( def send_ga_tracking_celery_event(
measurement_id: str, measurement_id: str,
secret: str, secret: str,
@ -165,7 +170,7 @@ def send_ga_tracking_celery_event(
} }
payload = { payload = {
'client_id': AnalyticsIdentifier.objects.get(id=1).identifier.hex, 'client_id': AnalyticsIdentifier.get_solo().identifier.hex,
"user_properties": { "user_properties": {
"allianceauth_version": { "allianceauth_version": {
"value": __version__ "value": __version__

View File

@ -1,10 +1,8 @@
from allianceauth.analytics.models import AnalyticsIdentifier from uuid import uuid4
from django.core.exceptions import ValidationError
from django.test.testcases import TestCase from django.test.testcases import TestCase
from uuid import UUID, uuid4 from allianceauth.analytics.models import AnalyticsIdentifier
# Identifiers # Identifiers
uuid_1 = "ab33e241fbf042b6aa77c7655a768af7" uuid_1 = "ab33e241fbf042b6aa77c7655a768af7"
@ -14,14 +12,4 @@ uuid_2 = "7aa6bd70701f44729af5e3095ff4b55c"
class TestAnalyticsIdentifier(TestCase): class TestAnalyticsIdentifier(TestCase):
def test_identifier_random(self): def test_identifier_random(self):
self.assertNotEqual(AnalyticsIdentifier.objects.get(), uuid4) self.assertNotEqual(AnalyticsIdentifier.get_solo(), uuid4)
def test_identifier_singular(self):
AnalyticsIdentifier.objects.all().delete()
AnalyticsIdentifier.objects.create(identifier=uuid_1)
# Yeah i have multiple asserts here, they all do the same thing
with self.assertRaises(ValidationError):
AnalyticsIdentifier.objects.create(identifier=uuid_2)
self.assertEqual(AnalyticsIdentifier.objects.count(), 1)
self.assertEqual(AnalyticsIdentifier.objects.get(
pk=1).identifier, UUID(uuid_1))

View File

@ -2,12 +2,9 @@ import requests_mock
from django.test.utils import override_settings from django.test.utils import override_settings
from allianceauth.analytics.tasks import ( from allianceauth.analytics.tasks import analytics_event, send_ga_tracking_celery_event
analytics_event,
send_ga_tracking_celery_event)
from allianceauth.utils.testing import NoSocketsTestCase from allianceauth.utils.testing import NoSocketsTestCase
GOOGLE_ANALYTICS_DEBUG_URL = 'https://www.google-analytics.com/debug/mp/collect' GOOGLE_ANALYTICS_DEBUG_URL = 'https://www.google-analytics.com/debug/mp/collect'

View File

@ -1,10 +1,9 @@
from django.apps import apps from django.apps import apps
from allianceauth.authentication.models import User
from esi.models import Token
from allianceauth.analytics.utils import install_stat_users, install_stat_tokens, install_stat_addons
from django.test.testcases import TestCase from django.test.testcases import TestCase
from allianceauth.analytics.utils import install_stat_addons, install_stat_users
from allianceauth.authentication.models import User
def create_testdata(): def create_testdata():
User.objects.all().delete() User.objects.all().delete()

View File

@ -1,7 +1,11 @@
import os
from django.apps import apps from django.apps import apps
from allianceauth.authentication.models import User
from esi.models import Token from esi.models import Token
from allianceauth.authentication.models import User
def install_stat_users() -> int: def install_stat_users() -> int:
"""Count and Return the number of User accounts """Count and Return the number of User accounts
@ -34,3 +38,16 @@ def install_stat_addons() -> int:
The Number of Installed Apps""" The Number of Installed Apps"""
addons = len(list(apps.get_app_configs())) addons = len(list(apps.get_app_configs()))
return addons return addons
def existence_baremetal_or_docker() -> str:
"""Checks the Installation Type of an install
Returns
-------
str
existence_baremetal or existence_docker"""
docker_tag = os.getenv('AA_DOCKER_TAG')
if docker_tag:
return "existence_docker"
return "existence_baremetal"

View File

@ -1,5 +1,4 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.core.checks import Warning, Error, register
class AllianceAuthConfig(AppConfig): class AllianceAuthConfig(AppConfig):

View File

@ -1,43 +1,21 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group, Permission as BasePermission, User as BaseUser
from django.contrib.auth.models import Permission as BasePermission
from django.contrib.auth.models import User as BaseUser
from django.db.models import Count, Q from django.db.models import Count, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.db.models.signals import ( from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete, pre_save
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save
)
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.text import slugify from django.utils.text import slugify
from allianceauth.authentication.models import ( from allianceauth.authentication.models import CharacterOwnership, OwnershipRecord, State, UserProfile, get_guest_state
CharacterOwnership, from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
OwnershipRecord,
State,
UserProfile,
get_guest_state
)
from allianceauth.eveonline.models import (
EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
EveFactionInfo
)
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character
from allianceauth.hooks import get_hooks from allianceauth.hooks import get_hooks
from allianceauth.services.hooks import ServicesHook from allianceauth.services.hooks import ServicesHook
from .app_settings import ( from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_CHARS, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
AUTHENTICATION_ADMIN_USERS_MAX_CHARS,
AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
)
from .forms import UserChangeForm, UserProfileForm from .forms import UserChangeForm, UserProfileForm
@ -132,10 +110,7 @@ def user_username(obj):
To be used for all user based admin lists To be used for all user based admin lists
""" """
link = reverse( link = reverse(
'admin:{}_{}_change'.format( f'admin:{obj._meta.app_label}_{type(obj).__name__.lower()}_change',
obj._meta.app_label,
type(obj).__name__.lower()
),
args=(obj.pk,) args=(obj.pk,)
) )
user_obj = obj.user if hasattr(obj, 'user') else obj user_obj = obj.user if hasattr(obj, 'user') else obj
@ -548,7 +523,7 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj and obj.pk: if obj and obj.pk:
return 'owner_hash', 'character' return 'owner_hash', 'character'
return tuple() return ()
@admin.register(OwnershipRecord) @admin.register(OwnershipRecord)

View File

@ -25,7 +25,7 @@ def _clean_setting(
if not required_type: if not required_type:
required_type = type(default_value) required_type = type(default_value)
if min_value is None and required_type == int: if min_value is None and required_type is int:
min_value = 0 min_value = 0
if (hasattr(settings, name) if (hasattr(settings, name)

View File

@ -1,10 +1,12 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.core.checks import register, Tags from django.core.checks import register, Tags
from django.utils.translation import gettext_lazy as _
class AuthenticationConfig(AppConfig): class AuthenticationConfig(AppConfig):
name = "allianceauth.authentication" name = "allianceauth.authentication"
label = "authentication" label = "authentication"
verbose_name = _("Authentication")
def ready(self): def ready(self):
from allianceauth.authentication import checks, signals # noqa: F401 from allianceauth.authentication import checks, signals # noqa: F401

View File

@ -1,6 +1,7 @@
from allianceauth.hooks import DashboardItemHook
from allianceauth import hooks from allianceauth import hooks
from .views import dashboard_characters, dashboard_esi_check, dashboard_groups, dashboard_admin from allianceauth.hooks import DashboardItemHook
from .views import dashboard_admin, dashboard_characters, dashboard_esi_check, dashboard_groups
class UserCharactersHook(DashboardItemHook): class UserCharactersHook(DashboardItemHook):

View File

@ -1,10 +1,9 @@
import logging import logging
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import Permission, User
from .models import UserProfile, CharacterOwnership, OwnershipRecord
from .models import CharacterOwnership, OwnershipRecord, UserProfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,5 +1,5 @@
from django.core.checks import Error
from django.conf import settings from django.conf import settings
from django.core.checks import Error
def check_login_scopes_setting(*args, **kwargs): def check_login_scopes_setting(*args, **kwargs):

View File

@ -2,7 +2,6 @@
import itertools import itertools
import logging import logging
from typing import Optional
from amqp.exceptions import ChannelError from amqp.exceptions import ChannelError
from celery import current_app from celery import current_app
@ -12,7 +11,7 @@ from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def active_tasks_count() -> Optional[int]: def active_tasks_count() -> int | None:
"""Return count of currently active tasks """Return count of currently active tasks
or None if celery workers are not online. or None if celery workers are not online.
""" """
@ -20,7 +19,7 @@ def active_tasks_count() -> Optional[int]:
return _tasks_count(inspect.active()) return _tasks_count(inspect.active())
def _tasks_count(data: dict) -> Optional[int]: def _tasks_count(data: dict) -> int | None:
"""Return count of tasks in data from celery inspect API.""" """Return count of tasks in data from celery inspect API."""
try: try:
tasks = itertools.chain(*data.values()) tasks = itertools.chain(*data.values())
@ -29,7 +28,7 @@ def _tasks_count(data: dict) -> Optional[int]:
return len(list(tasks)) return len(list(tasks))
def queued_tasks_count() -> Optional[int]: def queued_tasks_count() -> int | None:
"""Return count of queued tasks. Return None if there was an error.""" """Return count of queued tasks. Return None if there was an error."""
try: try:
with current_app.connection_or_acquire() as conn: with current_app.connection_or_acquire() as conn:

View File

@ -1,14 +1,11 @@
from django.urls import include from collections.abc import Callable, Iterable
from django.contrib.auth.decorators import user_passes_test
from django.core.exceptions import PermissionDenied
from functools import wraps from functools import wraps
from typing import Callable, Iterable, Optional
from django.urls import include
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import include
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -17,7 +14,7 @@ def user_has_main_character(user):
def decorate_url_patterns( def decorate_url_patterns(
urls, decorator: Callable, excluded_views: Optional[Iterable] = None urls, decorator: Callable, excluded_views: Iterable | None = None
): ):
"""Decorate views given in url patterns except when they are explicitly excluded. """Decorate views given in url patterns except when they are explicitly excluded.

View File

@ -60,7 +60,7 @@ class UserChangeForm(BaseUserChangeForm):
{ {
"groups": _( "groups": _(
"You are not allowed to add or remove these " "You are not allowed to add or remove these "
"restricted groups: %s" % restricted_names "restricted groups: {}".format(restricted_names)
) )
} }
) )

View File

@ -1,5 +1,6 @@
from django.urls import include, path, re_path
from allianceauth.authentication import views from allianceauth.authentication import views
from django.urls import include, re_path, path
urlpatterns = [ urlpatterns = [
path('activate/complete/', views.activation_complete, name='registration_activation_complete'), path('activate/complete/', views.activation_complete, name='registration_activation_complete'),

View File

@ -1,4 +1,5 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from allianceauth.authentication.models import UserProfile from allianceauth.authentication.models import UserProfile
@ -11,8 +12,7 @@ class Command(BaseCommand):
if profiles.exists(): if profiles.exists():
for profile in profiles: for profile in profiles:
self.stdout.write(self.style.ERROR( self.stdout.write(self.style.ERROR(
'{} does not have an ownership. Resetting user {} main character.'.format(profile.main_character, f'{profile.main_character} does not have an ownership. Resetting user {profile.user} main character.'))
profile.user)))
profile.main_character = None profile.main_character = None
profile.save() profile.save()
self.stdout.write(self.style.WARNING(f'Reset {profiles.count()} main characters.')) self.stdout.write(self.style.WARNING(f'Reset {profiles.count()} main characters.'))

View File

@ -1,7 +1,7 @@
import logging import logging
from django.db import transaction from django.db import transaction
from django.db.models import Manager, QuerySet, Q from django.db.models import Manager, Q, QuerySet
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter

View File

@ -1,8 +1,8 @@
import logging
from django.conf import settings from django.conf import settings
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,8 +1,8 @@
# Generated by Django 1.10.1 on 2016-09-05 21:38 # Generated by Django 1.10.1 on 2016-09-05 21:38
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -2,6 +2,7 @@
from django.db import migrations from django.db import migrations
def create_permissions(apps, schema_editor): def create_permissions(apps, schema_editor):
User = apps.get_model('auth', 'User') User = apps.get_model('auth', 'User')
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')

View File

@ -2,6 +2,7 @@
from django.db import migrations from django.db import migrations
def delete_permissions(apps, schema_editor): def delete_permissions(apps, schema_editor):
User = apps.get_model('auth', 'User') User = apps.get_model('auth', 'User')
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')

View File

@ -2,6 +2,7 @@
from django.db import migrations from django.db import migrations
def count_completed_fields(model): def count_completed_fields(model):
return len([True for key, value in model.__dict__.items() if bool(value)]) return len([True for key, value in model.__dict__.items() if bool(value)])

View File

@ -1,8 +1,8 @@
# Generated by Django 1.10.1 on 2017-01-07 07:11 # Generated by Django 1.10.1 on 2017-01-07 07:11
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,6 +1,7 @@
# Generated by Django 1.10.5 on 2017-01-12 00:59 # Generated by Django 1.10.5 on 2017-01-12 00:59
from django.db import migrations, models from django.db import migrations
def remove_permissions(apps, schema_editor): def remove_permissions(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType') ContentType = apps.get_model('contenttypes', 'ContentType')

View File

@ -1,9 +1,9 @@
# Generated by Django 1.10.2 on 2016-12-11 23:14 # Generated by Django 1.10.2 on 2016-12-11 23:14
from django.db import migrations
import logging import logging
from django.db import migrations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,11 +1,12 @@
# Generated by Django 1.10.5 on 2017-03-22 23:09 # Generated by Django 1.10.5 on 2017-03-22 23:09
import allianceauth.authentication.models
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.db import migrations, models from django.db import migrations, models
import allianceauth.authentication.models
def create_guest_state(apps, schema_editor): def create_guest_state(apps, schema_editor):
State = apps.get_model('authentication', 'State') State = apps.get_model('authentication', 'State')

View File

@ -1,8 +1,8 @@
# Generated by Django 2.0.4 on 2018-04-14 18:28 # Generated by Django 2.0.4 on 2018-04-14 18:28
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
def create_initial_records(apps, schema_editor): def create_initial_records(apps, schema_editor):

View File

@ -0,0 +1,124 @@
# Generated by Django 5.1.6 on 2025-03-04 02:44
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import allianceauth.authentication.models
def create_states(apps, schema_editor) -> None:
State = apps.get_model('authentication', 'State')
State.objects.update_or_create(name="Guest", defaults={'priority': 0, 'public': True})[0]
State.objects.update_or_create(name="Blue", defaults={'priority': 50, 'public': False})[0]
State.objects.update_or_create(name="Member", defaults={'priority': 100, 'public': False})[0]
def create_states_reverse(apps, schema_editor) -> None:
pass
class Migration(migrations.Migration):
replaces = [('authentication', '0001_initial'), ('authentication', '0002_auto_20160907_1914'), ('authentication', '0003_authservicesinfo_state'), ('authentication', '0004_create_permissions'), ('authentication', '0005_delete_perms'), ('authentication', '0006_auto_20160910_0542'), ('authentication', '0007_remove_authservicesinfo_is_blue'), ('authentication', '0008_set_state'), ('authentication', '0009_auto_20161021_0228'), ('authentication', '0010_only_one_authservicesinfo'), ('authentication', '0011_authservicesinfo_user_onetoonefield'), ('authentication', '0012_remove_add_delete_authservicesinfo_permissions'), ('authentication', '0013_service_modules'), ('authentication', '0014_fleetup_permission'), ('authentication', '0015_user_profiles'), ('authentication', '0016_ownershiprecord'), ('authentication', '0017_remove_fleetup_permission'), ('authentication', '0018_state_member_factions'), ('authentication', '0018_alter_state_name_length'), ('authentication', '0019_merge_20211026_0919'), ('authentication', '0020_userprofile_language_userprofile_night_mode'), ('authentication', '0021_alter_userprofile_language'), ('authentication', '0022_userprofile_theme'), ('authentication', '0023_alter_userprofile_language'), ('authentication', '0024_alter_userprofile_language')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('esi', '0012_fix_token_type_choices'),
('eveonline', '0019_v5squash'),
]
operations = [
migrations.CreateModel(
name='CharacterOwnership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('owner_hash', models.CharField(max_length=28, unique=True)),
('character', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='character_ownership', to='eveonline.evecharacter')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='character_ownerships', to=settings.AUTH_USER_MODEL)),
],
options={
'default_permissions': ('change', 'delete'),
'ordering': ['user', 'character__character_name'],
},
),
migrations.CreateModel(
name='State',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=32, unique=True)),
('priority', models.IntegerField(help_text='Users get assigned the state with the highest priority available to them.', unique=True)),
('public', models.BooleanField(default=False, help_text='Make this state available to any character.')),
('member_alliances', models.ManyToManyField(blank=True, help_text='Alliances to whose members this state is available.', to='eveonline.eveallianceinfo')),
('member_characters', models.ManyToManyField(blank=True, help_text='Characters to which this state is available.', to='eveonline.evecharacter')),
('member_corporations', models.ManyToManyField(blank=True, help_text='Corporations to whose members this state is available.', to='eveonline.evecorporationinfo')),
('permissions', models.ManyToManyField(blank=True, to='auth.permission')),
('member_factions', models.ManyToManyField(blank=True, help_text='Factions to whose members this state is available.', to='eveonline.evefactioninfo')),
],
options={
'ordering': ['-priority'],
},
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('main_character', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='eveonline.evecharacter')),
('state', models.ForeignKey(default=allianceauth.authentication.models.get_guest_state_pk, on_delete=django.db.models.deletion.SET_DEFAULT, to='authentication.state')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
('night_mode', models.BooleanField(blank=True, null=True, verbose_name='Night Mode')),
('theme', models.CharField(blank=True, help_text='Bootstrap 5 Themes from https://bootswatch.com/ or Community Apps', max_length=200, null=True, verbose_name='Theme')),
('language', models.CharField(blank=True, choices=[('en', 'English'), ('cs-cz', 'Czech'), ('de', 'German'), ('es', 'Spanish'), ('it-it', 'Italian'), ('ja', 'Japanese'), ('ko-kr', 'Korean'), ('fr-fr', 'French'), ('ru', 'Russian'), ('nl-nl', 'Dutch'), ('pl-pl', 'Polish'), ('uk', 'Ukrainian'), ('zh-hans', 'Simplified Chinese')], default='', max_length=10, verbose_name='Language')),
],
options={
'default_permissions': ('change',),
},
),
migrations.CreateModel(
name='Permission',
fields=[
],
options={
'proxy': True,
'verbose_name': 'permission',
'verbose_name_plural': 'permissions',
},
bases=('auth.permission',),
managers=[
('objects', django.contrib.auth.models.PermissionManager()),
],
),
migrations.CreateModel(
name='User',
fields=[
],
options={
'proxy': True,
'verbose_name': 'user',
'verbose_name_plural': 'users',
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='OwnershipRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('owner_hash', models.CharField(db_index=True, max_length=28)),
('created', models.DateTimeField(auto_now=True)),
('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ownership_records', to='eveonline.evecharacter')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ownership_records', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
migrations.RunPython(create_states, create_states_reverse),
]

View File

@ -1,11 +1,12 @@
import logging import logging
from typing import ClassVar
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import Permission, User
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo
from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
from allianceauth.notifications import notify from allianceauth.notifications import notify
from django.conf import settings
from .managers import CharacterOwnershipManager, StateManager from .managers import CharacterOwnershipManager, StateManager
@ -15,24 +16,30 @@ logger = logging.getLogger(__name__)
class State(models.Model): class State(models.Model):
name = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=32, unique=True)
permissions = models.ManyToManyField(Permission, blank=True) permissions = models.ManyToManyField(Permission, blank=True)
priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.") priority = models.IntegerField(
unique=True, help_text="Users get assigned the state with the highest priority available to them."
)
member_characters = models.ManyToManyField(EveCharacter, blank=True, member_characters = models.ManyToManyField(
help_text="Characters to which this state is available.") EveCharacter, blank=True, help_text="Characters to which this state is available."
member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True, )
help_text="Corporations to whose members this state is available.") member_corporations = models.ManyToManyField(
member_alliances = models.ManyToManyField(EveAllianceInfo, blank=True, EveCorporationInfo, blank=True, help_text="Corporations to whose members this state is available."
help_text="Alliances to whose members this state is available.") )
member_factions = models.ManyToManyField(EveFactionInfo, blank=True, member_alliances = models.ManyToManyField(
help_text="Factions to whose members this state is available.") EveAllianceInfo, blank=True, help_text="Alliances to whose members this state is available."
)
member_factions = models.ManyToManyField(
EveFactionInfo, blank=True, help_text="Factions to whose members this state is available."
)
public = models.BooleanField(default=False, help_text="Make this state available to any character.") public = models.BooleanField(default=False, help_text="Make this state available to any character.")
objects = StateManager() objects: ClassVar[StateManager] = StateManager()
class Meta: class Meta:
ordering = ['-priority'] ordering = ["-priority"]
def __str__(self): def __str__(self) -> str:
return self.name return self.name
def available_to_character(self, character): def available_to_character(self, character):
@ -48,11 +55,11 @@ class State(models.Model):
super().delete(**kwargs) super().delete(**kwargs)
def get_guest_state(): def get_guest_state() -> State:
try: try:
return State.objects.get(name='Guest') return State.objects.get(name="Guest")
except State.DoesNotExist: except State.DoesNotExist:
return State.objects.create(name='Guest', priority=0, public=True) return State.objects.create(name="Guest", priority=0, public=True)
def get_guest_state_pk(): def get_guest_state_pk():
@ -60,72 +67,58 @@ def get_guest_state_pk():
class UserProfile(models.Model): class UserProfile(models.Model):
class Meta:
default_permissions = ('change',)
class Language(models.TextChoices): class Language(models.TextChoices):
""" """
Choices for UserProfile.language Choices for UserProfile.language
""" """
# Sorted by Language Code alphabetical order + English at top
ENGLISH = 'en', _('English')
CZECH = 'cs-cz', _("Czech") # Not yet at 50% translated
GERMAN = 'de', _('German')
SPANISH = 'es', _('Spanish')
ITALIAN = 'it-it', _('Italian')
JAPANESE = 'ja', _('Japanese')
KOREAN = 'ko-kr', _('Korean')
FRENCH = 'fr-fr', _('French')
RUSSIAN = 'ru', _('Russian')
DUTCH = 'nl-nl', _("Dutch")
POLISH = 'pl-pl', _("Polish")
UKRAINIAN = 'uk', _('Ukrainian')
CHINESE = 'zh-hans', _('Simplified Chinese')
user = models.OneToOneField( # Sorted by Language Code alphabetical order + English at top
User, ENGLISH = "en", _("English")
related_name='profile', CZECH = "cs-cz", _("Czech") # Not yet at 50% translated
on_delete=models.CASCADE) GERMAN = "de", _("German")
main_character = models.OneToOneField( SPANISH = "es", _("Spanish")
EveCharacter, ITALIAN = "it-it", _("Italian")
blank=True, JAPANESE = "ja", _("Japanese")
null=True, KOREAN = "ko-kr", _("Korean")
on_delete=models.SET_NULL) FRENCH = "fr-fr", _("French")
state = models.ForeignKey( RUSSIAN = "ru", _("Russian")
State, DUTCH = "nl-nl", _("Dutch")
on_delete=models.SET_DEFAULT, POLISH = "pl-pl", _("Polish")
default=get_guest_state_pk) UKRAINIAN = "uk", _("Ukrainian")
language = models.CharField( CHINESE = "zh-hans", _("Simplified Chinese")
_("Language"), max_length=10,
choices=Language.choices, user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
blank=True, main_character = models.OneToOneField(EveCharacter, blank=True, null=True, on_delete=models.SET_NULL)
default='') state = models.ForeignKey(State, on_delete=models.SET_DEFAULT, default=get_guest_state_pk)
night_mode = models.BooleanField( language = models.CharField(_("Language"), max_length=10, choices=Language.choices, blank=True, default="")
_("Night Mode"), night_mode = models.BooleanField(_("Night Mode"), blank=True, null=True)
blank=True, theme = models.CharField( # noqa:DJ001 Null has a specific meaning, never set by user
null=True)
theme = models.CharField(
_("Theme"), _("Theme"),
max_length=200, max_length=200,
blank=True, blank=True,
null=True, null=True,
help_text="Bootstrap 5 Themes from https://bootswatch.com/ or Community Apps" help_text="Bootstrap 5 Themes from https://bootswatch.com/ or Community Apps",
) )
def assign_state(self, state=None, commit=True): class Meta:
default_permissions = ("change",)
def __str__(self) -> str:
return str(self.user)
def assign_state(self, state=None, commit=True) -> None:
if not state: if not state:
state = State.objects.get_for_user(self.user) state = State.objects.get_for_user(self.user)
if self.state != state: if self.state != state:
self.state = state self.state = state
if commit: if commit:
logger.info(f'Updating {self.user} state to {self.state}') logger.info(f"Updating {self.user} state to {self.state}")
self.save(update_fields=['state']) self.save(update_fields=["state"])
notify( notify(
self.user, self.user,
_('State changed to: %s' % state), _(f"State changed to: {state}"),
_('Your user\'s state is now: %(state)s') _("Your user's state is now: %(state)s") % ({"state": state}),
% ({'state': state}), "info",
'info'
) )
from allianceauth.authentication.signals import state_changed from allianceauth.authentication.signals import state_changed
@ -133,35 +126,33 @@ class UserProfile(models.Model):
# Clear all attribute caches and reload the model that will get passed to the signals! # Clear all attribute caches and reload the model that will get passed to the signals!
self.refresh_from_db() self.refresh_from_db()
state_changed.send( state_changed.send(sender=self.__class__, user=self.user, state=self.state)
sender=self.__class__, user=self.user, state=self.state
)
def __str__(self):
return str(self.user)
class CharacterOwnership(models.Model): class CharacterOwnership(models.Model):
character = models.OneToOneField(EveCharacter, on_delete=models.CASCADE, related_name='character_ownership')
owner_hash = models.CharField(max_length=28, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="character_ownerships")
objects: ClassVar[CharacterOwnershipManager] = CharacterOwnershipManager()
class Meta: class Meta:
default_permissions = ('change', 'delete') default_permissions = ('change', 'delete')
ordering = ['user', 'character__character_name'] ordering = ['user', 'character__character_name']
character = models.OneToOneField(EveCharacter, on_delete=models.CASCADE, related_name='character_ownership') def __str__(self) -> str:
owner_hash = models.CharField(max_length=28, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='character_ownerships')
objects = CharacterOwnershipManager()
def __str__(self):
return f"{self.user}: {self.character}" return f"{self.user}: {self.character}"
class OwnershipRecord(models.Model): class OwnershipRecord(models.Model):
character = models.ForeignKey(EveCharacter, on_delete=models.CASCADE, related_name='ownership_records') character = models.ForeignKey(EveCharacter, on_delete=models.CASCADE, related_name="ownership_records")
owner_hash = models.CharField(max_length=28, db_index=True) owner_hash = models.CharField(max_length=28, db_index=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ownership_records') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ownership_records")
created = models.DateTimeField(auto_now=True) created = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['-created'] ordering = ["-created"]
def __str__(self): def __str__(self) -> str:
return f"{self.user}: {self.character} on {self.created}" return f"{self.user}: {self.character} on {self.created}"

View File

@ -1,19 +1,16 @@
import logging import logging
from .models import (
CharacterOwnership,
UserProfile,
get_guest_state,
State,
OwnershipRecord)
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete, pre_save
from django.dispatch import receiver, Signal from django.dispatch import Signal, receiver
from esi.models import Token from esi.models import Token
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
from .models import CharacterOwnership, OwnershipRecord, State, UserProfile, get_guest_state
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
state_changed = Signal() state_changed = Signal()
@ -108,8 +105,7 @@ def record_character_ownership(sender, instance, created, *args, **kwargs):
def validate_main_character(sender, instance, *args, **kwargs): def validate_main_character(sender, instance, *args, **kwargs):
try: try:
if instance.user.profile.main_character == instance.character: if instance.user.profile.main_character == instance.character:
logger.info("Ownership of a main character {} has been revoked. Resetting {} main character.".format( logger.info(f"Ownership of a main character {instance.character} has been revoked. Resetting {instance.user} main character.")
instance.character, instance.user))
# clear main character as user no longer owns them # clear main character as user no longer owns them
instance.user.profile.main_character = None instance.user.profile.main_character = None
instance.user.profile.save() instance.user.profile.save()

View File

@ -1,7 +1,7 @@
"""Counters for Task Statistics.""" """Counters for Task Statistics."""
import datetime as dt import datetime as dt
from typing import NamedTuple, Optional from typing import NamedTuple
from .event_series import EventSeries from .event_series import EventSeries
@ -16,7 +16,7 @@ class _TaskCounts(NamedTuple):
retried: int retried: int
failed: int failed: int
total: int total: int
earliest_task: Optional[dt.datetime] earliest_task: dt.datetime | None
hours: int hours: int

View File

@ -2,7 +2,6 @@
import datetime as dt import datetime as dt
import logging import logging
from typing import List, Optional
from pytz import utc from pytz import utc
from redis import Redis from redis import Redis
@ -17,7 +16,7 @@ class EventSeries:
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES" _ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Optional[Redis] = None) -> None: def __init__(self, key_id: str, redis: Redis | None = None) -> None:
self._redis = get_redis_client_or_stub() if not redis else redis self._redis = get_redis_client_or_stub() if not redis else redis
self._key_id = str(key_id) self._key_id = str(key_id)
self.clear() self.clear()
@ -46,7 +45,7 @@ class EventSeries:
my_id = self._redis.incr(self._key_counter) my_id = self._redis.incr(self._key_counter)
self._redis.zadd(self._key_sorted_set, {my_id: event_time.timestamp()}) self._redis.zadd(self._key_sorted_set, {my_id: event_time.timestamp()})
def all(self) -> List[dt.datetime]: def all(self) -> list[dt.datetime]:
"""List of all known events.""" """List of all known events."""
return [ return [
event[1] event[1]
@ -75,7 +74,7 @@ class EventSeries:
maximum = "+inf" if not latest else latest.timestamp() maximum = "+inf" if not latest else latest.timestamp()
return self._redis.zcount(self._key_sorted_set, min=minimum, max=maximum) return self._redis.zcount(self._key_sorted_set, min=minimum, max=maximum)
def first_event(self, earliest: dt.datetime = None) -> Optional[dt.datetime]: def first_event(self, earliest: dt.datetime = None) -> dt.datetime | None:
"""Date/Time of first event. Returns `None` if series has no events. """Date/Time of first event. Returns `None` if series has no events.
Args: Args:

View File

@ -1,7 +1,11 @@
"""Signals for Task Statistics.""" """Signals for Task Statistics."""
from celery.signals import ( from celery.signals import (
task_failure, task_internal_error, task_retry, task_success, worker_ready, task_failure,
task_internal_error,
task_retry,
task_success,
worker_ready,
) )
from django.conf import settings from django.conf import settings

View File

@ -4,7 +4,10 @@ from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
from allianceauth.authentication.task_statistics.counters import ( from allianceauth.authentication.task_statistics.counters import (
dashboard_results, failed_tasks, retried_tasks, succeeded_tasks, dashboard_results,
failed_tasks,
retried_tasks,
succeeded_tasks,
) )

View File

@ -4,7 +4,8 @@ from unittest.mock import patch
from redis import RedisError from redis import RedisError
from allianceauth.authentication.task_statistics.helpers import ( from allianceauth.authentication.task_statistics.helpers import (
_RedisStub, get_redis_client_or_stub, _RedisStub,
get_redis_client_or_stub,
) )
MODULE_PATH = "allianceauth.authentication.task_statistics.helpers" MODULE_PATH = "allianceauth.authentication.task_statistics.helpers"

View File

@ -10,8 +10,8 @@ from allianceauth.authentication.task_statistics.counters import (
succeeded_tasks, succeeded_tasks,
) )
from allianceauth.authentication.task_statistics.signals import ( from allianceauth.authentication.task_statistics.signals import (
reset_counters,
is_enabled, is_enabled,
reset_counters,
) )
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character

View File

@ -1,9 +1,10 @@
import logging import logging
from esi.errors import TokenExpiredError, TokenInvalidError, IncompleteResponseError
from esi.models import Token
from celery import shared_task from celery import shared_task
from esi.errors import IncompleteResponseError, TokenExpiredError, TokenInvalidError
from esi.models import Token
from allianceauth.authentication.models import CharacterOwnership from allianceauth.authentication.models import CharacterOwnership
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,8 +23,7 @@ def check_character_ownership(owner_hash):
continue continue
except (KeyError, IncompleteResponseError): except (KeyError, IncompleteResponseError):
# We can't validate the hash hasn't changed but also can't assume it has. Abort for now. # We can't validate the hash hasn't changed but also can't assume it has. Abort for now.
logger.warning("Failed to validate owner hash of {} due to problems contacting SSO servers.".format( logger.warning(f"Failed to validate owner hash of {tokens[0].character_name} due to problems contacting SSO servers.")
tokens[0].character_name))
break break
if not t.character_owner_hash == old_hash: if not t.character_owner_hash == old_hash:
@ -33,7 +33,7 @@ def check_character_ownership(owner_hash):
break break
if not Token.objects.filter(character_owner_hash=owner_hash).exists(): if not Token.objects.filter(character_owner_hash=owner_hash).exists():
logger.info('No tokens found with owner hash %s. Revoking ownership.' % owner_hash) logger.info(f'No tokens found with owner hash {owner_hash}. Revoking ownership.')
CharacterOwnership.objects.filter(owner_hash=owner_hash).delete() CharacterOwnership.objects.filter(owner_hash=owner_hash).delete()

View File

@ -1,10 +0,0 @@
{% extends 'allianceauth/base.html' %}
{% block page_title %}Dashboard{% endblock page_title %}
{% block content %}
<div>
<h1>Dashboard Dummy</h1>
</div>
{% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "allianceauth/base-bs5.html" %} {% extends "allianceauth/base-bs5.html" %}
{% load aa_i18n %}
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
@ -13,7 +14,7 @@
{% block content %} {% block content %}
<div> <div>
<p class="mb-3"> <p class="mb-3">
{% translate "This page is a best attempt, but backups or database logs can still contain your tokens. Always revoke tokens on https://community.eveonline.com/support/third-party-applications/ where possible."|urlize %} {% translate "This page is a best attempt, but backups or database logs can still contain your tokens. Always revoke tokens on https://developers.eveonline.com/authorized-apps where possible."|urlize %}
</p> </p>
<table class="table w-100" id="table_tokens"> <table class="table w-100" id="table_tokens">
@ -30,7 +31,7 @@
<tr> <tr>
<td style="white-space:initial;"> <td style="white-space:initial;">
{% for s in t.scopes.all %} {% for s in t.scopes.all %}
<span class="badge bg-secondary">{{ s.name }}</span> <span class="badge text-bg-secondary">{{ s.name }}</span>
{% endfor %} {% endfor %}
</td> </td>
@ -50,20 +51,23 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include "bundles/datatables-js-bs5.html" %} {% include "bundles/datatables-js-bs5.html" %}
{% get_datatables_language_static LANGUAGE_CODE as DT_LANG_PATH %}
<script> <script>
$(document).ready(() => { $(document).ready(() => {
let grp = 2; let grp = 2;
const table = $('#table_tokens').DataTable({ $('#table_tokens').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
'columnDefs': [{orderable: false, targets: [0, 1]}, { 'columnDefs': [{orderable: false, targets: [0, 1]}, {
'visible': false, 'visible': false,
'targets': grp 'targets': grp
}], }],
'order': [[grp, 'asc']], 'order': [[grp, 'asc']],
'drawCallback': function (settings) { 'drawCallback': function (settings) {
var api = this.api(); const api = this.api();
var rows = api.rows({page: 'current'}).nodes(); const rows = api.rows({page: 'current'}).nodes();
var last = null; let last = null;
api.column(grp, {page: 'current'}) api.column(grp, {page: 'current'})
.data() .data()
.each((group, i) => { .each((group, i) => {

View File

@ -1,24 +1,24 @@
{% load theme_tags %} {% load theme_tags %}
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" {% theme_html_tags %}>
<head> <head>
<!-- Required meta tags -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content=""> <!-- End Required meta tags -->
<meta name="author" content="">
<!-- TODO Bundle all the site specific stuff up into its own template for easy override -->
<meta property="og:title" content="{{ SITE_NAME }}">
<meta property="og:image" content="{{ SITE_URL }}{% static 'allianceauth/icons/apple-touch-icon.png' %}">
<meta property="og:description" content="Alliance Auth - An auth system for EVE Online to help in-game organizations manage online service access.">
<!-- Meta tags -->
{% include 'allianceauth/opengraph.html' %}
{% include 'allianceauth/icons.html' %} {% include 'allianceauth/icons.html' %}
<!-- Meta tags -->
<title>{% block title %}{% block page_title %}{% endblock page_title %} - {{ SITE_NAME }}{% endblock title %}</title> <title>{% block title %}{% block page_title %}{% endblock page_title %} - {{ SITE_NAME }}{% endblock title %}</title>
{% theme_css %} {% theme_css %}
{% include 'bundles/fontawesome.html' %} {% include 'bundles/fontawesome.html' %}
{% include 'bundles/auth-framework-css.html' %}
{% block extra_include %} {% block extra_include %}
{% endblock %} {% endblock %}

View File

@ -1,7 +1,6 @@
{% load i18n %} {% load i18n %}
<div class="dropdown"> <form class="dropdown-item" action="{% url 'set_language' %}" method="post">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %} {% csrf_token %}
<select class="form-select" onchange="this.form.submit()" class="form-control" id="lang-select" name="language"> <select class="form-select" onchange="this.form.submit()" class="form-control" id="lang-select" name="language">
@ -14,4 +13,3 @@
{% endfor %} {% endfor %}
</select> </select>
</form> </form>
</div>

View File

@ -29,7 +29,7 @@
</p> </p>
<p class="text-center"> <p class="text-center">
<a class="text-reset" href="https://community.eveonline.com/support/third-party-applications/" target="_blank" rel="noopener noreferrer"> <a class="text-reset" href="https://developers.eveonline.com/authorized-apps" target="_blank" rel="noopener noreferrer">
{% translate "Manage ESI Applications" %} {% translate "Manage ESI Applications" %}
</a> </a>
</p> </p>

View File

@ -1,12 +1,7 @@
from django.db.models.signals import (
m2m_changed,
post_save,
pre_delete,
pre_save
)
from django.urls import reverse
from unittest import mock from unittest import mock
from django.urls import reverse
MODULE_PATH = 'allianceauth.authentication' MODULE_PATH = 'allianceauth.authentication'
@ -17,9 +12,7 @@ def patch(target, *args, **kwargs):
def get_admin_change_view_url(obj: object) -> str: def get_admin_change_view_url(obj: object) -> str:
"""returns URL to admin change view for given object""" """returns URL to admin change view for given object"""
return reverse( return reverse(
'admin:{}_{}_change'.format( f'admin:{obj._meta.app_label}_{type(obj).__name__.lower()}_change',
obj._meta.app_label, type(obj).__name__.lower()
),
args=(obj.pk,) args=(obj.pk,)
) )

View File

@ -5,7 +5,8 @@ from amqp.exceptions import ChannelError
from django.test import TestCase from django.test import TestCase
from allianceauth.authentication.core.celery_workers import ( from allianceauth.authentication.core.celery_workers import (
active_tasks_count, queued_tasks_count, active_tasks_count,
queued_tasks_count,
) )
MODULE_PATH = "allianceauth.authentication.core.celery_workers" MODULE_PATH = "allianceauth.authentication.core.celery_workers"

View File

@ -1,42 +1,37 @@
from bs4 import BeautifulSoup from unittest.mock import MagicMock, patch
from urllib.parse import quote from urllib.parse import quote
from unittest.mock import patch, MagicMock
from bs4 import BeautifulSoup
from django_webtest import WebTest from django_webtest import WebTest
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.test import TestCase, RequestFactory, Client from django.test import Client, RequestFactory, TestCase
from allianceauth.authentication.models import ( from allianceauth.authentication.models import CharacterOwnership, OwnershipRecord, State
CharacterOwnership, State, OwnershipRecord from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
)
from allianceauth.eveonline.models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo
)
from allianceauth.services.hooks import ServicesHook from allianceauth.services.hooks import ServicesHook
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from ..admin import ( from ..admin import (
BaseUserAdmin, BaseUserAdmin,
CharacterOwnershipAdmin, CharacterOwnershipAdmin,
StateAdmin,
MainCorporationsFilter,
MainAllianceFilter, MainAllianceFilter,
MainCorporationsFilter,
MainFactionFilter, MainFactionFilter,
OwnershipRecordAdmin, OwnershipRecordAdmin,
StateAdmin,
User, User,
UserAdmin, UserAdmin,
make_service_hooks_sync_nickname_action,
make_service_hooks_update_groups_action,
update_main_character_model,
user_main_organization, user_main_organization,
user_profile_pic, user_profile_pic,
user_username, user_username,
update_main_character_model,
make_service_hooks_update_groups_action,
make_service_hooks_sync_nickname_action
) )
from . import get_admin_change_view_url, get_admin_search_url from . import get_admin_change_view_url, get_admin_search_url
MODULE_PATH = 'allianceauth.authentication.admin' MODULE_PATH = 'allianceauth.authentication.admin'
@ -327,15 +322,15 @@ class TestUserAdmin(TestCaseWithTestData):
def test_user_username_u1(self): def test_user_username_u1(self):
expected = ( expected = (
'<strong><a href="/admin/authentication/user/{}/change/">' f'<strong><a href="/admin/authentication/user/{self.user_1.pk}/change/">'
'Bruce_Wayne</a></strong><br>Bruce Wayne'.format(self.user_1.pk) 'Bruce_Wayne</a></strong><br>Bruce Wayne'
) )
self.assertEqual(user_username(self.user_1), expected) self.assertEqual(user_username(self.user_1), expected)
def test_user_username_u3(self): def test_user_username_u3(self):
expected = ( expected = (
'<strong><a href="/admin/authentication/user/{}/change/">' f'<strong><a href="/admin/authentication/user/{self.user_3.pk}/change/">'
'Lex_Luthor</a></strong>'.format(self.user_3.pk) 'Lex_Luthor</a></strong>'
) )
self.assertEqual(user_username(self.user_3), expected) self.assertEqual(user_username(self.user_3), expected)

View File

@ -1,4 +1,5 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.test import TestCase from django.test import TestCase
from .. import app_settings from .. import app_settings
@ -83,7 +84,7 @@ class TestSetAppSetting(TestCase):
self.assertEqual(result, 50) self.assertEqual(result, 50)
@patch(MODULE_PATH + '.app_settings.settings') @patch(MODULE_PATH + '.app_settings.settings')
def test_default_for_invalid_type_int(self, mock_settings): def test_default_for_outofrange_int(self, mock_settings):
mock_settings.TEST_SETTING_DUMMY = 1000 mock_settings.TEST_SETTING_DUMMY = 1000
result = app_settings._clean_setting( result = app_settings._clean_setting(
'TEST_SETTING_DUMMY', 'TEST_SETTING_DUMMY',
@ -96,7 +97,7 @@ class TestSetAppSetting(TestCase):
def test_default_is_none_needs_required_type(self, mock_settings): def test_default_is_none_needs_required_type(self, mock_settings):
mock_settings.TEST_SETTING_DUMMY = 'invalid type' mock_settings.TEST_SETTING_DUMMY = 'invalid type'
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
result = app_settings._clean_setting( app_settings._clean_setting(
'TEST_SETTING_DUMMY', 'TEST_SETTING_DUMMY',
default_value=None default_value=None
) )

View File

@ -1,13 +1,13 @@
from django.contrib.auth.models import User, Group from django.contrib.auth.models import Group, User
from django.test import TestCase from django.test import TestCase
from esi.models import Token
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from esi.models import Token
from ..backends import StateBackend from ..backends import StateBackend
from ..models import CharacterOwnership, UserProfile, OwnershipRecord from ..models import CharacterOwnership, OwnershipRecord, UserProfile
MODULE_PATH = 'allianceauth.authentication' MODULE_PATH = 'allianceauth.authentication'

View File

@ -6,12 +6,11 @@ from django.contrib.auth.models import AnonymousUser
from django.http.response import HttpResponse from django.http.response import HttpResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse, URLPattern from django.urls import URLPattern, reverse
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from ..decorators import decorate_url_patterns, main_character_required from ..decorators import decorate_url_patterns, main_character_required
from ..models import CharacterOwnership from ..models import CharacterOwnership
@ -47,7 +46,7 @@ class DecoratorTestCase(TestCase):
@mock.patch(MODULE_PATH + '.decorators.messages') @mock.patch(MODULE_PATH + '.decorators.messages')
def test_login_redirect(self, m): def test_login_redirect(self, m):
setattr(self.request, 'user', AnonymousUser()) self.request.user = AnonymousUser()
response = self.dummy_view(self.request) response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None) url = getattr(response, 'url', None)
@ -55,7 +54,7 @@ class DecoratorTestCase(TestCase):
@mock.patch(MODULE_PATH + '.decorators.messages') @mock.patch(MODULE_PATH + '.decorators.messages')
def test_main_character_redirect(self, m): def test_main_character_redirect(self, m):
setattr(self.request, 'user', self.no_main_user) self.request.user = self.no_main_user
response = self.dummy_view(self.request) response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
url = getattr(response, 'url', None) url = getattr(response, 'url', None)
@ -63,7 +62,7 @@ class DecoratorTestCase(TestCase):
@mock.patch(MODULE_PATH + '.decorators.messages') @mock.patch(MODULE_PATH + '.decorators.messages')
def test_successful_request(self, m): def test_successful_request(self, m):
setattr(self.request, 'user', self.main_user) self.request.user = self.main_user
response = self.dummy_view(self.request) response = self.dummy_view(self.request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -1,10 +1,10 @@
from unittest import mock
from allianceauth.authentication.middleware import UserSettingsMiddleware
from unittest.mock import Mock from unittest.mock import Mock
from django.http import HttpResponse
from django.http import HttpResponse
from django.test.testcases import TestCase from django.test.testcases import TestCase
from allianceauth.authentication.middleware import UserSettingsMiddleware
class TestUserSettingsMiddlewareSaveLang(TestCase): class TestUserSettingsMiddlewareSaveLang(TestCase):
@ -39,7 +39,7 @@ class TestUserSettingsMiddlewareSaveLang(TestCase):
of a non-existent (anonymous) user of a non-existent (anonymous) user
""" """
self.request.user.is_anonymous = True self.request.user.is_anonymous = True
response = self.middleware.process_response( self.middleware.process_response(
self.request, self.request,
self.response self.response
) )
@ -52,7 +52,7 @@ class TestUserSettingsMiddlewareSaveLang(TestCase):
does the middleware change a language not set in the DB does the middleware change a language not set in the DB
""" """
self.request.user.profile.language = None self.request.user.profile.language = None
response = self.middleware.process_response( self.middleware.process_response(
self.request, self.request,
self.response self.response
) )
@ -64,7 +64,7 @@ class TestUserSettingsMiddlewareSaveLang(TestCase):
""" """
Tests the middleware will change a language setting Tests the middleware will change a language setting
""" """
response = self.middleware.process_response( self.middleware.process_response(
self.request, self.request,
self.response self.response
) )
@ -158,7 +158,7 @@ class TestUserSettingsMiddlewareLoginFlow(TestCase):
tests the middleware will set night_mode if not set tests the middleware will set night_mode if not set
""" """
self.request.session = {} self.request.session = {}
response = self.middleware.process_response( self.middleware.process_response(
self.request, self.request,
self.response self.response
) )
@ -168,7 +168,7 @@ class TestUserSettingsMiddlewareLoginFlow(TestCase):
""" """
tests the middleware will set night_mode if set. tests the middleware will set night_mode if set.
""" """
response = self.middleware.process_response( self.middleware.process_response(
self.request, self.request,
self.response self.response
) )

View File

@ -3,12 +3,12 @@ from unittest import mock
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\
EveAllianceInfo, EveFactionInfo
from allianceauth.tests.auth_utils import AuthUtils
from esi.errors import IncompleteResponseError from esi.errors import IncompleteResponseError
from esi.models import Token from esi.models import Token
from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
from allianceauth.tests.auth_utils import AuthUtils
from ..models import CharacterOwnership, State, get_guest_state from ..models import CharacterOwnership, State, get_guest_state
from ..tasks import check_character_ownership from ..tasks import check_character_ownership

View File

@ -1,19 +1,10 @@
from django.db.models.signals import post_save
from django.test.testcases import TestCase
from allianceauth.authentication.models import User, UserProfile from allianceauth.authentication.models import User, UserProfile
from allianceauth.eveonline.models import ( from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo
EveCharacter,
EveCorporationInfo,
EveAllianceInfo
)
from django.db.models.signals import (
pre_save,
post_save,
pre_delete,
m2m_changed
)
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from django.test.testcases import TestCase
from unittest.mock import Mock
from . import patch from . import patch

View File

@ -1,12 +1,13 @@
import json import json
import requests_mock
from unittest.mock import patch from unittest.mock import patch
import requests_mock
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from allianceauth.authentication.views import task_counts, esi_check
from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.authentication.constants import ESI_ERROR_MESSAGE_OVERRIDES from allianceauth.authentication.constants import ESI_ERROR_MESSAGE_OVERRIDES
from allianceauth.authentication.views import esi_check, task_counts
from allianceauth.tests.auth_utils import AuthUtils
MODULE_PATH = "allianceauth.authentication.views" MODULE_PATH = "allianceauth.authentication.views"

View File

@ -38,7 +38,6 @@ urlpatterns = [
name='token_refresh' name='token_refresh'
), ),
path('dashboard/', views.dashboard, name='dashboard'), path('dashboard/', views.dashboard, name='dashboard'),
path('dashboard_bs3/', views.dashboard_bs3, name='dashboard_bs3'),
path('task-counts/', views.task_counts, name='task_counts'), path('task-counts/', views.task_counts, name='task_counts'),
path('esi-check/', views.esi_check, name='esi_check'), path('esi-check/', views.esi_check, name='esi_check'),
] ]

View File

@ -1,11 +1,6 @@
import logging import logging
import requests import requests
from django_registration.backends.activation.views import (
REGISTRATION_SALT, ActivationView as BaseActivationView,
RegistrationView as BaseRegistrationView,
)
from django_registration.signals import user_registered
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@ -18,6 +13,12 @@ from django.shortcuts import redirect, render
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_registration.backends.activation.views import (
REGISTRATION_SALT,
ActivationView as BaseActivationView,
RegistrationView as BaseRegistrationView,
)
from django_registration.signals import user_registered
from esi.decorators import token_required from esi.decorators import token_required
from esi.models import Token from esi.models import Token
@ -32,7 +33,7 @@ from .models import CharacterOwnership
if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS: if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS:
_has_auto_groups = True _has_auto_groups = True
from allianceauth.eveonline.autogroups.models import * # noqa: F401, F403 from allianceauth.eveonline.autogroups.models import * # noqa: F403
else: else:
_has_auto_groups = False _has_auto_groups = False
@ -73,21 +74,21 @@ def dashboard_characters(request):
def dashboard_admin(request): def dashboard_admin(request):
if request.user.is_superuser: if request.user.is_superuser:
return render_to_string('allianceauth/admin-status/include.html', request=request) return render_to_string('admin-status/include.html', request=request)
else: else:
return "" return ""
def dashboard_esi_check(request): def dashboard_esi_check(request):
if request.user.is_superuser: if request.user.is_superuser:
return render_to_string('allianceauth/admin-status/esi_check.html', request=request) return render_to_string('admin-status/esi_check.html', request=request)
else: else:
return "" return ""
@login_required @login_required
def dashboard(request): def dashboard(request):
_dash_items = list() _dash_items = []
hooks = get_hooks('dashboard_hook') hooks = get_hooks('dashboard_hook')
items = [fn() for fn in hooks] items = [fn() for fn in hooks]
items.sort(key=lambda i: i.order) items.sort(key=lambda i: i.order)
@ -164,9 +165,7 @@ def main_character_change(request, token):
request.user.profile.save(update_fields=['main_character']) request.user.profile.save(update_fields=['main_character'])
messages.success(request, _('Changed main character to %s') % co.character) messages.success(request, _('Changed main character to %s') % co.character)
logger.info( logger.info(
'Changed user {user} main character to {char}'.format( f'Changed user {request.user} main character to {co.character}'
user=request.user, char=co.character
)
) )
return redirect("authentication:dashboard") return redirect("authentication:dashboard")
@ -176,10 +175,9 @@ def add_character(request, token):
if CharacterOwnership.objects.filter(character__character_id=token.character_id).filter( if CharacterOwnership.objects.filter(character__character_id=token.character_id).filter(
owner_hash=token.character_owner_hash).filter(user=request.user).exists(): owner_hash=token.character_owner_hash).filter(user=request.user).exists():
messages.success(request, _( messages.success(request, _(
'Added %(name)s to your account.' % ({'name': token.character_name}))) f'Added {token.character_name} to your account.'))
else: else:
messages.error(request, _('Failed to add %(name)s to your account: they already have an account.' % ( messages.error(request, _(f'Failed to add {token.character_name} to your account: they already have an account.'))
{'name': token.character_name})))
return redirect('authentication:dashboard') return redirect('authentication:dashboard')
@ -294,7 +292,7 @@ class RegistrationView(BaseRegistrationView):
return redirect(settings.LOGIN_URL) return redirect(settings.LOGIN_URL)
if not getattr(settings, 'REGISTRATION_VERIFY_EMAIL', True): if not getattr(settings, 'REGISTRATION_VERIFY_EMAIL', True):
# Keep the request so the user can be automagically logged in. # Keep the request so the user can be automagically logged in.
setattr(self, 'request', request) self.request = request
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def register(self, form): def register(self, form):
@ -394,12 +392,3 @@ def esi_check(request) -> JsonResponse:
"data": check_for_override_esi_error_message(_r) "data": check_for_override_esi_error_message(_r)
} }
return JsonResponse(data) return JsonResponse(data)
@login_required
def dashboard_bs3(request):
"""Render dashboard view with BS3 theme.
This is an internal view used for testing BS3 backward compatibility in AA4 only.
"""
return render(request, 'authentication/dashboard_bs3.html')

View File

@ -2,6 +2,7 @@
import os import os
import shutil import shutil
from optparse import OptionParser from optparse import OptionParser
from django.core.management import call_command from django.core.management import call_command
from django.core.management.commands.startproject import Command as BaseStartProject from django.core.management.commands.startproject import Command as BaseStartProject
@ -43,7 +44,7 @@ def create_project(parser, options, args):
# Call the command with extra context # Call the command with extra context
call_command(StartProject(), *args, **command_options) call_command(StartProject(), *args, **command_options)
print(f"Success! {args[0]} has been created.") # noqa print(f"Success! {args[0]} has been created.")
def update_settings(parser, options, args): def update_settings(parser, options, args):
@ -62,7 +63,7 @@ def update_settings(parser, options, args):
# next check if given path is to the project, so the app is within it # next check if given path is to the project, so the app is within it
settings_path = os.path.join(project_path, project_name, 'settings/base.py') settings_path = os.path.join(project_path, project_name, 'settings/base.py')
if not os.path.exists(settings_path): if not os.path.exists(settings_path):
parser.error("Unable to locate the Alliance Auth project at %s" % project_path) parser.error(f"Unable to locate the Alliance Auth project at {project_path}")
# first find the path to the Alliance Auth template settings # first find the path to the Alliance Auth template settings
import allianceauth import allianceauth

View File

@ -1,13 +1,19 @@
from typing import List """
from django import db Django system checks for Alliance Auth
from django.core.checks import CheckMessage, Error, register, Warning """
from allianceauth.utils.cache import get_redis_client
from django.utils import timezone from datetime import datetime, timezone
from packaging.version import InvalidVersion, Version as Pep440Version
from celery import current_app
from django.conf import settings
from sqlite3.dbapi2 import sqlite_version_info from sqlite3.dbapi2 import sqlite_version_info
from celery import current_app
from packaging.version import InvalidVersion, Version as Pep440Version
from django import db
from django.conf import settings
from django.core.checks import CheckMessage, Error, Warning, register
from allianceauth.utils.cache import get_redis_client
""" """
A = System Packages A = System Packages
B = Configuration B = Configuration
@ -15,143 +21,416 @@ B = Configuration
@register() @register()
def django_settings(app_configs, **kwargs) -> List[CheckMessage]: def django_settings(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
if hasattr(settings, "SITE_URL"): Check that Django settings are correctly configured
if settings.SITE_URL[-1] == "/":
errors.append(Warning("'SITE_URL' Has a trailing slash. This may lead to incorrect links being generated by Auth.", hint="", id="allianceauth.checks.B005"))
else:
errors.append(Error("No 'SITE_URL' found is settings. This may lead to incorrect links being generated by Auth or Errors in 3rd party modules.", hint="", id="allianceauth.checks.B006"))
if hasattr(settings, "CSRF_TRUSTED_ORIGINS") and hasattr(settings, "SITE_URL"): :param app_configs:
if settings.SITE_URL not in settings.CSRF_TRUSTED_ORIGINS: :type app_configs:
errors.append(Warning("'SITE_URL' not found in 'CSRF_TRUSTED_ORIGINS'. Auth may not load pages correctly until this is rectified.", hint="", id="allianceauth.checks.B007")) :param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = []
# Check for SITE_URL
if hasattr(settings, "SITE_URL"):
# Check if SITE_URL is empty
if settings.SITE_URL == "":
errors.append(
Error(
msg="'SITE_URL' is empty.",
hint="Make sure to set 'SITE_URL' to the URL of your Auth instance. (Without trailing slash)",
id="allianceauth.checks.B011",
)
)
# Check if SITE_URL has a trailing slash
elif settings.SITE_URL[-1] == "/":
errors.append(
Warning(
msg="'SITE_URL' has a trailing slash. This may lead to incorrect links being generated by Auth.",
hint="",
id="allianceauth.checks.B005",
)
)
# SITE_URL not found
else: else:
errors.append(Error("No 'CSRF_TRUSTED_ORIGINS' found is settings, Auth may not load pages correctly until this is rectified", hint="", id="allianceauth.checks.B008")) errors.append(
Error(
msg="No 'SITE_URL' found is settings. This may lead to incorrect links being generated by Auth or Errors in 3rd party modules.",
hint="",
id="allianceauth.checks.B006",
)
)
# Check for CSRF_TRUSTED_ORIGINS
if hasattr(settings, "CSRF_TRUSTED_ORIGINS") and hasattr(settings, "SITE_URL"):
# Check if SITE_URL is not in CSRF_TRUSTED_ORIGINS
if settings.SITE_URL not in settings.CSRF_TRUSTED_ORIGINS:
errors.append(
Warning(
msg="'SITE_URL' not found in 'CSRF_TRUSTED_ORIGINS'. Auth may not load pages correctly until this is rectified.",
hint="",
id="allianceauth.checks.B007",
)
)
# CSRF_TRUSTED_ORIGINS not found
else:
errors.append(
Error(
msg="No 'CSRF_TRUSTED_ORIGINS' found is settings, Auth may not load pages correctly until this is rectified",
hint="",
id="allianceauth.checks.B008",
)
)
# Check for ESI_USER_CONTACT_EMAIL
if hasattr(settings, "ESI_USER_CONTACT_EMAIL"):
# Check if ESI_USER_CONTACT_EMAIL is empty
if settings.ESI_USER_CONTACT_EMAIL == "":
errors.append(
Error(
msg="'ESI_USER_CONTACT_EMAIL' is empty. A valid email is required as maintainer contact for CCP.",
hint="",
id="allianceauth.checks.B009",
)
)
# ESI_USER_CONTACT_EMAIL not found
else:
errors.append(
Error(
msg="No 'ESI_USER_CONTACT_EMAIL' found is settings. A valid email is required as maintainer contact for CCP.",
hint="",
id="allianceauth.checks.B010",
)
)
return errors return errors
@register() @register()
def system_package_redis(app_configs, **kwargs) -> List[CheckMessage]: def system_package_redis(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
Check that Redis is a supported version
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
allianceauth_redis_install_link = "https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools"
errors: list[CheckMessage] = []
try: try:
redis_version = Pep440Version(get_redis_client().info()['redis_version']) redis_version = Pep440Version(get_redis_client().info()["redis_version"])
except InvalidVersion: except InvalidVersion:
errors.append(Warning("Unable to confirm Redis Version")) errors.append(Warning("Unable to confirm Redis Version"))
return errors return errors
if redis_version.major == 7 and redis_version.minor == 2 and timezone.now() > timezone.datetime(year=2025, month=8, day=31, tzinfo=timezone.utc): if (
errors.append(Error(f"Redis {redis_version.public} in Security Support only, Updating Suggested", hint="https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools", id="allianceauth.checks.A001")) redis_version.major == 7
and redis_version.minor == 2
and datetime.now(timezone.utc)
> datetime(year=2025, month=8, day=31, tzinfo=timezone.utc)
):
errors.append(
Error(
msg=f"Redis {redis_version.public} in Security Support only, Updating Suggested",
hint=allianceauth_redis_install_link,
id="allianceauth.checks.A001",
)
)
elif redis_version.major == 7 and redis_version.minor == 0: elif redis_version.major == 7 and redis_version.minor == 0:
errors.append(Warning(f"Redis {redis_version.public} in Security Support only, Updating Suggested", hint="https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools", id="allianceauth.checks.A002")) errors.append(
Warning(
msg=f"Redis {redis_version.public} in Security Support only, Updating Suggested",
hint=allianceauth_redis_install_link,
id="allianceauth.checks.A002",
)
)
elif redis_version.major == 6 and redis_version.minor == 2: elif redis_version.major == 6 and redis_version.minor == 2:
errors.append(Warning(f"Redis {redis_version.public} in Security Support only, Updating Suggested", hint="https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools", id="allianceauth.checks.A018")) errors.append(
Warning(
msg=f"Redis {redis_version.public} in Security Support only, Updating Suggested",
hint=allianceauth_redis_install_link,
id="allianceauth.checks.A018",
)
)
elif redis_version.major in [6, 5]: elif redis_version.major in [6, 5]:
errors.append(Error(f"Redis {redis_version.public} EOL", hint="https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools", id="allianceauth.checks.A003")) errors.append(
Error(
msg=f"Redis {redis_version.public} EOL",
hint=allianceauth_redis_install_link,
id="allianceauth.checks.A003",
)
)
return errors return errors
@register() @register()
def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]: def system_package_mysql(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
Check that MySQL is a supported version
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
mysql_quick_guide_link = "https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/"
errors: list[CheckMessage] = []
for connection in db.connections.all(): for connection in db.connections.all():
if connection.vendor == "mysql": if connection.vendor == "mysql":
try: try:
mysql_version = Pep440Version(".".join(str(i) for i in connection.mysql_version)) mysql_version = Pep440Version(
".".join(str(i) for i in connection.mysql_version)
)
except InvalidVersion: except InvalidVersion:
errors.append(Warning("Unable to confirm MySQL Version")) errors.append(Warning("Unable to confirm MySQL Version"))
return errors return errors
# MySQL 8 # MySQL 8
if mysql_version.major == 8 and mysql_version.minor == 4 and timezone.now() > timezone.datetime(year=2032, month=4, day=30, tzinfo=timezone.utc): if mysql_version.major == 8:
errors.append(Error(f"MySQL {mysql_version.public} EOL", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A004")) if mysql_version.minor == 4 and datetime.now(timezone.utc) > datetime(
elif mysql_version.major == 8 and mysql_version.minor == 3: year=2032, month=4, day=30, tzinfo=timezone.utc
errors.append(Warning(f"MySQL {mysql_version.public} Non LTS", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A005")) ):
elif mysql_version.major == 8 and mysql_version.minor == 2: errors.append(
errors.append(Warning(f"MySQL {mysql_version.public} Non LTS", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A006")) Error(
elif mysql_version.major == 8 and mysql_version.minor == 1: msg=f"MySQL {mysql_version.public} EOL",
errors.append(Error(f"MySQL {mysql_version.public} EOL", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A007")) hint=mysql_quick_guide_link,
elif mysql_version.major == 8 and mysql_version.minor == 0 and timezone.now() > timezone.datetime(year=2026, month=4, day=30, tzinfo=timezone.utc): id="allianceauth.checks.A004",
errors.append(Error(f"MySQL {mysql_version.public} EOL", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A008")) )
elif mysql_version.major < 8: # This will also catch Mariadb 5.x )
errors.append(Error(f"MySQL or MariaDB {mysql_version.public} EOL", hint="https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/", id="allianceauth.checks.A009")) # Demote versions down here once EOL
elif mysql_version.minor in [1, 2, 3]:
errors.append(
Warning(
msg=f"MySQL {mysql_version.public} Non LTS",
hint=mysql_quick_guide_link,
id="allianceauth.checks.A005",
)
)
elif mysql_version.minor == 0 and datetime.now(timezone.utc) > datetime(
year=2026, month=4, day=30, tzinfo=timezone.utc
):
errors.append(
Error(
msg=f"MySQL {mysql_version.public} EOL",
hint=mysql_quick_guide_link,
id="allianceauth.checks.A008",
)
)
# MySQL below 8
# This will also catch Mariadb 5.x
elif mysql_version.major < 8:
errors.append(
Error(
msg=f"MySQL or MariaDB {mysql_version.public} EOL",
hint=mysql_quick_guide_link,
id="allianceauth.checks.A009",
)
)
return errors return errors
@register() @register()
def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]: def system_package_mariadb(app_configs, **kwargs) -> list[CheckMessage]: # noqa: C901
errors: List[CheckMessage] = [] """
Check that MariaDB is a supported version
"""
mariadb_download_link = "https://mariadb.org/download/?t=repo-config"
errors: list[CheckMessage] = []
for connection in db.connections.all(): for connection in db.connections.all():
if connection.vendor == "mysql": # Still to find a way to determine MySQL vs MariaDB # TODO: Find a way to determine MySQL vs. MariaDB
if connection.vendor == "mysql":
try: try:
mariadb_version = Pep440Version(".".join(str(i) for i in connection.mysql_version)) mariadb_version = Pep440Version(
".".join(str(i) for i in connection.mysql_version)
)
except InvalidVersion: except InvalidVersion:
errors.append(Warning("Unable to confirm MariaDB Version")) errors.append(Warning("Unable to confirm MariaDB Version"))
return errors return errors
# MariaDB 11 # MariaDB 11
if mariadb_version.major == 11 and mariadb_version.minor == 4 and timezone.now() > timezone.datetime(year=2029, month=5, day=19, tzinfo=timezone.utc): if mariadb_version.major == 11:
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A010")) if mariadb_version.minor == 4 and datetime.now(timezone.utc) > datetime(
elif mariadb_version.major == 11 and mariadb_version.minor == 2: year=2029, month=5, day=19, tzinfo=timezone.utc
errors.append(Warning(f"MariaDB {mariadb_version.public} Non LTS", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A018")) ):
if timezone.now() > timezone.datetime(year=2024, month=11, day=21, tzinfo=timezone.utc): errors.append(
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A011")) Error(
elif mariadb_version.major == 11 and mariadb_version.minor == 1: msg=f"MariaDB {mariadb_version.public} EOL",
errors.append(Warning(f"MariaDB {mariadb_version.public} Non LTS", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A019")) hint=mariadb_download_link,
if timezone.now() > timezone.datetime(year=2024, month=8, day=21, tzinfo=timezone.utc): id="allianceauth.checks.A010",
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A012")) )
elif mariadb_version.major == 11 and mariadb_version.minor in [0, 3]: # Demote versions down here once EOL )
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config.", id="allianceauth.checks.A013")) # Demote versions down here once EOL
elif mariadb_version.minor in [0, 1, 2, 3, 5, 6]:
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A013",
)
)
# MariaDB 10 # MariaDB 10
elif mariadb_version.major == 10 and mariadb_version.minor == 11 and timezone.now() > timezone.datetime(year=2028, month=2, day=10, tzinfo=timezone.utc): elif mariadb_version.major == 10:
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config.", id="allianceauth.checks.A014")) if mariadb_version.minor == 11 and datetime.now(timezone.utc) > datetime(
elif mariadb_version.major == 10 and mariadb_version.minor == 6 and timezone.now() > timezone.datetime(year=2026, month=7, day=6, tzinfo=timezone.utc): year=2028, month=2, day=10, tzinfo=timezone.utc
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A0015")) ):
elif mariadb_version.major == 10 and mariadb_version.minor == 5 and timezone.now() > timezone.datetime(year=2025, month=6, day=24, tzinfo=timezone.utc): errors.append(
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A016")) Error(
elif mariadb_version.major == 10 and mariadb_version.minor in [0, 1, 2, 3, 4, 7, 9, 10]: # Demote versions down here once EOL msg=f"MariaDB {mariadb_version.public} EOL",
errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A017")) hint=mariadb_download_link,
id="allianceauth.checks.A014",
)
)
elif mariadb_version.minor == 6 and datetime.now(timezone.utc) > datetime(
year=2026, month=7, day=6, tzinfo=timezone.utc
):
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A0015",
)
)
elif mariadb_version.minor == 5 and datetime.now(timezone.utc) > datetime(
year=2025, month=6, day=24, tzinfo=timezone.utc
):
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A016",
)
)
# Demote versions down here once EOL
elif mariadb_version.minor in [0, 1, 2, 3, 4, 7, 9, 10]:
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A017",
)
)
return errors return errors
@register() @register()
def system_package_sqlite(app_configs, **kwargs) -> List[CheckMessage]: def system_package_sqlite(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
Check that SQLite is a supported version
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = []
for connection in db.connections.all(): for connection in db.connections.all():
if connection.vendor == "sqlite": if connection.vendor == "sqlite":
try: try:
sqlite_version = Pep440Version(".".join(str(i) for i in sqlite_version_info)) sqlite_version = Pep440Version(
".".join(str(i) for i in sqlite_version_info)
)
except InvalidVersion: except InvalidVersion:
errors.append(Warning("Unable to confirm SQLite Version")) errors.append(Warning("Unable to confirm SQLite Version"))
return errors return errors
if sqlite_version.major == 3 and sqlite_version.minor < 27: if sqlite_version.major == 3 and sqlite_version.minor < 27:
errors.append(Error(f"SQLite {sqlite_version.public} Unsupported by Django", hint="https://pkgs.org/download/sqlite3", id="allianceauth.checks.A020")) errors.append(
Error(
msg=f"SQLite {sqlite_version.public} Unsupported by Django",
hint="https://pkgs.org/download/sqlite3",
id="allianceauth.checks.A020",
)
)
return errors return errors
@register() @register()
def sql_settings(app_configs, **kwargs) -> List[CheckMessage]: def sql_settings(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
Check that SQL settings are correctly configured
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = []
for connection in db.connections.all(): for connection in db.connections.all():
if connection.vendor == "mysql": if connection.vendor == "mysql":
try: try:
if connection.settings_dict["OPTIONS"]["charset"] != "utf8mb4": if connection.settings_dict["OPTIONS"]["charset"] != "utf8mb4":
errors.append(Error(f"SQL Charset is not set to utf8mb4 DB:{connection.alias}", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8", id="allianceauth.checks.B001")) errors.append(
Error(
msg=f"SQL Charset is not set to utf8mb4 DB: {connection.alias}",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8",
id="allianceauth.checks.B001",
)
)
except KeyError: except KeyError:
errors.append(Error(f"SQL Charset is not set to utf8mb4 DB:{connection.alias}", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8", id="allianceauth.checks.B001")) errors.append(
Error(
msg=f"SQL Charset is not set to utf8mb4 DB: {connection.alias}",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8",
id="allianceauth.checks.B001",
)
)
# This hasn't actually been set on AA yet # This hasn't actually been set on AA yet
# try: # try:
# if connection.settings_dict["OPTIONS"]["collation"] != "utf8mb4_unicode_ci": # if (
# errors.append(Error(f"SQL Collation is not set to utf8mb4_unicode_ci DB:{connection.alias}", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8", id="allianceauth.checks.B001")) # connection.settings_dict["OPTIONS"]["collation"]
# != "utf8mb4_unicode_ci"
# ):
# errors.append(
# Error(
# msg=f"SQL Collation is not set to utf8mb4_unicode_ci DB:{connection.alias}",
# hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8",
# id="allianceauth.checks.B001",
# )
# )
# except KeyError: # except KeyError:
# errors.append(Error(f"SQL Collation is not set to utf8mb4_unicode_ci DB:{connection.alias}", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8", id="allianceauth.checks.B001")) # errors.append(
# Error(
# msg=f"SQL Collation is not set to utf8mb4_unicode_ci DB:{connection.alias}",
# hint="https://gitlab.com/allianceauth/allianceauth/-/commit/89be2456fb2d741b86417e889da9b6129525bec8",
# id="allianceauth.checks.B001",
# )
# )
# if connection.vendor == "sqlite": # if connection.vendor == "sqlite":
@ -159,20 +438,58 @@ def sql_settings(app_configs, **kwargs) -> List[CheckMessage]:
@register() @register()
def celery_settings(app_configs, **kwargs) -> List[CheckMessage]: def celery_settings(app_configs, **kwargs) -> list[CheckMessage]:
errors: List[CheckMessage] = [] """
Check that Celery settings are correctly configured
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = []
try: try:
if current_app.conf.broker_transport_options != {'priority_steps': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 'queue_order_strategy': 'priority'}: if current_app.conf.broker_transport_options != {
errors.append(Error("Celery Priorities are not set correctly", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc", id="allianceauth.checks.B003")) "priority_steps": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
"queue_order_strategy": "priority",
}:
errors.append(
Error(
msg="Celery Priorities are not set correctly",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc",
id="allianceauth.checks.B003",
)
)
except KeyError: except KeyError:
errors.append(Error("Celery Priorities are not set", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc", id="allianceauth.checks.B003")) errors.append(
Error(
msg="Celery Priorities are not set",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc",
id="allianceauth.checks.B003",
)
)
try: try:
if current_app.conf.broker_connection_retry_on_startup != True: if not current_app.conf.broker_connection_retry_on_startup:
errors.append(Error("Celery broker_connection_retry_on_startup not set correctly", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280", id="allianceauth.checks.B004")) errors.append(
Error(
msg="Celery broker_connection_retry_on_startup not set correctly",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280",
id="allianceauth.checks.B004",
)
)
except KeyError: except KeyError:
errors.append(Error("Celery broker_connection_retry_on_startup not set", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280", id="allianceauth.checks.B004")) errors.append(
Error(
msg="Celery broker_connection_retry_on_startup not set",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280",
id="allianceauth.checks.B004",
)
)
return errors return errors

View File

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from .views import NightModeRedirectView from .views import NightModeRedirectView

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import CorpStats, CorpMember from .models import CorpMember, CorpStats
admin.site.register(CorpStats) admin.site.register(CorpStats)
admin.site.register(CorpMember) admin.site.register(CorpMember)

View File

@ -1,6 +1,8 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CorpUtilsConfig(AppConfig): class CorpUtilsConfig(AppConfig):
name = 'allianceauth.corputils' name = 'allianceauth.corputils'
label = 'corputils' label = 'corputils'
verbose_name = _('Corporation Stats')

View File

@ -1,8 +1,9 @@
from allianceauth.menu.hooks import MenuItemHook
from allianceauth.services.hooks import UrlHook
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from allianceauth import hooks from allianceauth import hooks
from allianceauth.corputils import urls from allianceauth.corputils import urls
from allianceauth.menu.hooks import MenuItemHook
from allianceauth.services.hooks import UrlHook
class CorpStats(MenuItemHook): class CorpStats(MenuItemHook):

View File

@ -1,6 +1,7 @@
from django.db import models
import logging import logging
from django.db import models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -8,7 +9,7 @@ class CorpStatsQuerySet(models.QuerySet):
def visible_to(self, user): def visible_to(self, user):
# superusers get all visible # superusers get all visible
if user.is_superuser: if user.is_superuser:
logger.debug('Returning all corpstats for superuser %s.' % user) logger.debug(f'Returning all corpstats for superuser {user}.')
return self return self
try: try:
@ -36,7 +37,7 @@ class CorpStatsQuerySet(models.QuerySet):
query |= q query |= q
return self.filter(query) return self.filter(query)
except AssertionError: except AssertionError:
logger.debug('User %s has no main character. No corpstats visible.' % user) logger.debug(f'User {user} has no main character. No corpstats visible.')
return self.none() return self.none()

View File

@ -1,7 +1,7 @@
# Generated by Django 1.10.1 on 2016-12-14 21:36 # Generated by Django 1.10.1 on 2016-12-14 21:36
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -66,11 +66,11 @@ def forward(apps, schema_editor):
g.permissions.add(perm_dict['corpstats']['alliance_apis'].pk) g.permissions.add(perm_dict['corpstats']['alliance_apis'].pk)
g.permissions.add(perm_dict['corpstats']['view_alliance_corpstats'].pk) g.permissions.add(perm_dict['corpstats']['view_alliance_corpstats'].pk)
for name, perm in perm_dict['user'].items(): for _name, perm in perm_dict['user'].items():
perm.delete() perm.delete()
def reverse(apps, schema_editor): def reverse(apps, schema_editor): # noqa: C901
perm_dict = user_permissions_dict(apps) perm_dict = user_permissions_dict(apps)
corp_users = users_with_permission(apps, perm_dict['corpstats']['view_corp_corpstats']) corp_users = users_with_permission(apps, perm_dict['corpstats']['view_corp_corpstats'])

View File

@ -1,9 +1,10 @@
# Generated by Django 1.10.5 on 2017-03-26 20:13 # Generated by Django 1.10.5 on 2017-03-26 20:13
from django.db import migrations, models
import django.db.models.deletion
import json import json
import django.db.models.deletion
from django.db import migrations, models
def convert_json_to_members(apps, schema_editor): def convert_json_to_members(apps, schema_editor):
CorpStats = apps.get_model('corputils', 'CorpStats') CorpStats = apps.get_model('corputils', 'CorpStats')

View File

@ -0,0 +1,52 @@
# Generated by Django 5.1.6 on 2025-03-04 01:29
# This was built by Deleting Every Migration, Creating one from scratch
# And porting in anything necessary
# Some functions were skipped as they only make sense _if you are migrating in place_
# i.e. permissions migration
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [('corputils', '0001_initial'), ('corputils', '0002_migrate_permissions'), ('corputils', '0003_granular_permissions'), ('corputils', '0004_member_models'), ('corputils', '0005_cleanup_permissions')]
initial = True
dependencies = [
('esi', '0012_fix_token_type_choices'),
('eveonline', '0019_v5squash'),
]
operations = [
migrations.CreateModel(
name='CorpStats',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_update', models.DateTimeField(auto_now=True)),
('corp', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='eveonline.evecorporationinfo')),
('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='esi.token')),
],
options={
'verbose_name': 'corp stats',
'verbose_name_plural': 'corp stats',
'permissions': (('view_corp_corpstats', 'Can view corp stats of their corporation.'), ('view_alliance_corpstats', 'Can view corp stats of members of their alliance.'), ('view_state_corpstats', 'Can view corp stats of members of their auth state.')),
},
),
migrations.CreateModel(
name='CorpMember',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('character_id', models.PositiveIntegerField()),
('character_name', models.CharField(max_length=37)),
('corpstats', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='corputils.corpstats')),
],
options={
'ordering': ['character_name'],
'unique_together': {('corpstats', 'character_id')},
},
),
]

Some files were not shown because too many files have changed in this diff Show More