Compare commits

..

1 Commits

Author SHA1 Message Date
Joel Falknau
4d98136800 BS5 Fork of x-editable 2024-09-16 14:46:47 +10:00
389 changed files with 7160 additions and 19757 deletions

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-trixie 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:
@@ -53,7 +53,7 @@ secret_detection:
test-3.10-core: test-3.10-core:
<<: *only-default <<: *only-default
image: python:3.10-trixie image: python:3.10-bookworm
script: script:
- tox -e py310-core - tox -e py310-core
artifacts: artifacts:
@@ -65,7 +65,7 @@ test-3.10-core:
test-3.11-core: test-3.11-core:
<<: *only-default <<: *only-default
image: python:3.11-trixie image: python:3.11-bookworm
script: script:
- tox -e py311-core - tox -e py311-core
artifacts: artifacts:
@@ -77,7 +77,7 @@ test-3.11-core:
test-3.12-core: test-3.12-core:
<<: *only-default <<: *only-default
image: python:3.12-trixie image: python:3.12-bookworm
script: script:
- tox -e py312-core - tox -e py312-core
artifacts: artifacts:
@@ -89,7 +89,7 @@ test-3.12-core:
test-3.13-core: test-3.13-core:
<<: *only-default <<: *only-default
image: python:3.13-trixie image: python:3.13-rc-bookworm
script: script:
- tox -e py313-core - tox -e py313-core
artifacts: artifacts:
@@ -98,22 +98,11 @@ test-3.13-core:
coverage_report: coverage_report:
coverage_format: cobertura coverage_format: cobertura
path: coverage.xml path: coverage.xml
allow_failure: true
test-3.14-core:
<<: *only-default
image: python:3.14-trixie
script:
- tox -e py314-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-all: test-3.10-all:
<<: *only-default <<: *only-default
image: python:3.10-trixie image: python:3.10-bookworm
script: script:
- tox -e py310-all - tox -e py310-all
artifacts: artifacts:
@@ -125,7 +114,7 @@ test-3.10-all:
test-3.11-all: test-3.11-all:
<<: *only-default <<: *only-default
image: python:3.11-trixie image: python:3.11-bookworm
script: script:
- tox -e py311-all - tox -e py311-all
artifacts: artifacts:
@@ -138,7 +127,7 @@ test-3.11-all:
test-3.12-all: test-3.12-all:
<<: *only-default <<: *only-default
image: python:3.12-trixie image: python:3.12-bookworm
script: script:
- tox -e py312-all - tox -e py312-all
artifacts: artifacts:
@@ -150,7 +139,7 @@ test-3.12-all:
test-3.13-all: test-3.13-all:
<<: *only-default <<: *only-default
image: python:3.13-trixie image: python:3.13-rc-bookworm
script: script:
- tox -e py313-all - tox -e py313-all
artifacts: artifacts:
@@ -159,22 +148,11 @@ test-3.13-all:
coverage_report: coverage_report:
coverage_format: cobertura coverage_format: cobertura
path: coverage.xml path: coverage.xml
allow_failure: true
test-3.14-all:
<<: *only-default
image: python:3.14-trixie
script:
- tox -e py314-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
build-test: build-test:
stage: test stage: test
image: python:3.12-trixie image: python:3.12-bookworm
before_script: before_script:
- python -m pip install --upgrade pip - python -m pip install --upgrade pip
@@ -193,13 +171,13 @@ build-test:
test-docs: test-docs:
<<: *only-default <<: *only-default
image: python:3.12-trixie image: python:3.12-bookworm
script: script:
- tox -e docs - tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.12-trixie image: python:3.12-bookworm
before_script: before_script:
- python -m pip install --upgrade pip - python -m pip install --upgrade pip

View File

@@ -3,42 +3,22 @@
# 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 - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
# Run the linter, and only the linter
- id: ruff
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: 1.29.0 rev: 1.21.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [--target-version=5.2] args: [--target-version=4.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: v6.0.0 rev: v4.6.0
hooks: hooks:
# Identify invalid files # Identify invalid files
- id: check-ast - id: check-ast
@@ -53,39 +33,67 @@ repos:
- id: detect-private-key - id: detect-private-key
- id: check-case-conflict - id: check-case-conflict
# Python checks # Python checks
# - id: check-docstring-first # - id: check-docstring-first
- id: debug-statements - id: debug-statements
# - id: requirements-txt-fixer # - id: requirements-txt-fixer
- id: fix-encoding-pragma
args: [--remove]
- id: fix-byte-order-marker - id: fix-byte-order-marker
# General quality checks # General quality checks
- id: mixed-line-ending - id: mixed-line-ending
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: 3.4.0 rev: 3.0.3
hooks: hooks:
- id: editorconfig-checker - id: editorconfig-checker
exclude: |
(?x)(
LICENSE|
allianceauth\/static\/allianceauth\/css\/themes\/bootstrap-locals.less|
\.po|
\.mo|
swagger\.json
)
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.45.0 rev: v0.41.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: v2.11.0 rev: 2.2.3
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.32.0 # https://github.com/tox-dev/tox/releases/latest - tox==4.18.1 # https://github.com/tox-dev/tox/releases/latest
- repo: https://github.com/abravalheri/validate-pyproject - repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1 rev: v0.19
hooks: hooks:
- id: validate-pyproject - id: validate-pyproject
name: Validate pyproject.toml
description: "Validate the pyproject.toml file."

View File

@@ -1 +0,0 @@
* @allianceauth

View File

@@ -1,20 +1,20 @@
# 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 Versions](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/) [![python](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/)
[![Django Versions](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/) [![django](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/)
[![Stable AA Version](https://img.shields.io/pypi/v/allianceauth?label=release)](https://pypi.org/project/allianceauth/) [![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)](http://allianceauth.readthedocs.io/?badge=latest)
[![Test Coverage Report](https://gitlab.com/allianceauth/allianceauth/badges/master/coverage.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master) [![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)
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. An auth system for EVE Online to help in-game organizations manage online service access.
## Content ## Content
- [Overview](#overview) - [Overview](#overview)
- [Documentation](https://allianceauth.rtfd.io) - [Documentation](http://allianceauth.rtfd.io)
- [Support](#support) - [Support](#support)
- [Release Notes](https://gitlab.com/allianceauth/allianceauth/-/releases) - [Release Notes](https://gitlab.com/allianceauth/allianceauth/-/releases)
- [Developer Team](#development-team) - [Developer Team](#development-team)
@@ -22,35 +22,29 @@ A flexible authentication platform for EVE Online to help in-game organizations
## Overview ## Overview
Alliance Auth (AA) is a platform that helps Eve Online organizations efficiently manage access to applications and services. Alliance Auth (AA) is a web site 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) 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/) - 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/)
- 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)
- English :flag_gb:, Chinese :flag_cn:, German :flag_de:, Spanish :flag_es:, Korean :flag_kr:, Russian :flag_ru:, Italian :flag_it:, French :flag_fr:, Japanese :flag_jp: and Ukrainian :flag_ua: Localization - English :flag_gb:, Chinese :flag_cn:, German :flag_de:, Spanish :flag_es:, Korean :flag_kr:, Russian :flag_ru:, Italian :flag_it:, French :flag_fr:, Japanese :flag_jp: and Ukrainian :flag_ua: Localization
For further details about AA - including an installation guide and a full list of included services and plugin apps - please see the [official documentation](https://allianceauth.rtfd.io). For further details about AA - including an installation guide and a full list of included services and plugin apps - please see the [official documentation](http://allianceauth.rtfd.io).
## Screenshot ## Screenshot
Here is an example of the Alliance Auth web site with a mixture of Services, Apps and Community Creations enabled: Here is an example of the Alliance Auth web site with some plug-ins apps and services enabled:
### Flatly Theme ![screenshot](https://i.imgur.com/2tnX9kD.png)
![Flatly Theme](docs/_static/images/promotion/SampleInstallation-Flatly.png)
### Darkly Theme
![Darkly Theme](docs/_static/images/promotion/SampleInstallation-Darkly.png)
## Support ## Support
@@ -89,6 +83,6 @@ Alliance Auth is maintained and developed by the community and we welcome every
To see what needs to be worked on please review our issue list or chat with our active developers on Discord. To see what needs to be worked on please review our issue list or chat with our active developers on Discord.
Also, please make sure you have signed the [License Agreement](https://developers.eveonline.com/license-agreement) by logging in at [https://developers.eveonline.com](https://developers.eveonline.com) before submitting any pull requests. Also, please make sure you have signed the [License Agreement](https://developers.eveonline.com/resource/license-agreement) by logging in at [https://developers.eveonline.com](https://developers.eveonline.com) before submitting any pull requests.
In addition to the core AA system we also very much welcome contributions to our growing list of 3rd party services and plugin apps. Please see [AA Community Creations](https://gitlab.com/allianceauth/community-creations) for details. In addition to the core AA system we also very much welcome contributions to our growing list of 3rd party services and plugin apps. Please see [AA Community Creations](https://gitlab.com/allianceauth/community-creations) for details.

View File

@@ -5,8 +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__ = '5.0.0a3' __version__ = '4.3.1'
__title__ = 'Alliance Auth' __title__ = 'Alliance Auth'
__title_useragent__ = '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

@@ -1,19 +0,0 @@
"""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

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

View File

@@ -1,207 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -1,33 +0,0 @@
# 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

@@ -1,45 +0,0 @@
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

@@ -1,32 +0,0 @@
{% load i18n %}
<div id="esi-alert" class="col-12 collapse">
<div class="alert alert-warning">
<p class="text-center ">{% translate 'Your Server received an ESI error response code of ' %}<b id="esi-code">?</b></p>
<hr>
<pre id="esi-data" class="text-center text-wrap"></pre>
</div>
<script>
$(document).ready(() => {
const elements = {
card: document.getElementById('esi-alert'),
message: document.getElementById('esi-data'),
code: document.getElementById('esi-code')
};
fetchGet({url: '{% url "authentication:esi_check" %}'})
.then(({status, data}) => {
console.log('ESI Check:', JSON.stringify({status, data}, null, 2));
if (status !== 200) {
elements.code.textContent = status;
elements.message.textContent = data.error;
new bootstrap.Collapse(elements.card, {toggle: true});
}
})
.catch((error) => console.error('Error fetching ESI check:', error));
});
</script>
</div>

View File

@@ -1,194 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,17 +1,15 @@
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(SingletonModelAdmin): class AnalyticsIdentifierAdmin(admin.ModelAdmin):
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,8 +1,6 @@
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,17 +0,0 @@
# 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

@@ -1,56 +0,0 @@
# 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,21 +1,26 @@
from typing import Literal
from uuid import uuid4 from uuid import uuid4
from solo.models import SingletonModel from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class AnalyticsIdentifier(SingletonModel): class AnalyticsIdentifier(models.Model):
identifier = models.UUIDField(default=uuid4, editable=False) identifier = models.UUIDField(
default=uuid4,
editable=False)
def __str__(self) -> Literal['Analytics Identifier']: def __str__(self) -> str:
return "Analytics Identifier" return f"{self.identifier}"
class Meta: def save(self, *args, **kwargs):
verbose_name = "Analytics Identifier" if not self.pk and AnalyticsIdentifier.objects.exists():
# Force a single object
raise ValidationError('There is can be only one \
AnalyticsIdentifier instance')
self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1
return super().save(*args, **kwargs)
class AnalyticsTokens(models.Model): class AnalyticsTokens(models.Model):

View File

@@ -9,7 +9,7 @@ from django.conf import settings
from allianceauth import __version__ from allianceauth import __version__
from .models import AnalyticsIdentifier, AnalyticsTokens from .models import AnalyticsIdentifier, AnalyticsTokens
from .utils import existence_baremetal_or_docker, install_stat_addons, install_stat_tokens, install_stat_users from .utils import install_stat_addons, install_stat_tokens, install_stat_users
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -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() -> None: def analytics_daily_stats():
"""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,7 +77,6 @@ def analytics_daily_stats() -> None:
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',
@@ -85,11 +84,6 @@ def analytics_daily_stats() -> None:
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',
@@ -105,6 +99,7 @@ def analytics_daily_stats() -> None:
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",
@@ -140,7 +135,7 @@ def analytics_daily_stats() -> None:
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,
@@ -170,7 +165,7 @@ def send_ga_tracking_celery_event(
} }
payload = { payload = {
'client_id': AnalyticsIdentifier.get_solo().identifier.hex, 'client_id': AnalyticsIdentifier.objects.get(id=1).identifier.hex,
"user_properties": { "user_properties": {
"allianceauth_version": { "allianceauth_version": {
"value": __version__ "value": __version__

View File

@@ -1,5 +1,6 @@
from uuid import uuid4 from uuid import UUID, uuid4
from django.core.exceptions import ValidationError
from django.test.testcases import TestCase from django.test.testcases import TestCase
from allianceauth.analytics.models import AnalyticsIdentifier from allianceauth.analytics.models import AnalyticsIdentifier
@@ -12,4 +13,14 @@ uuid_2 = "7aa6bd70701f44729af5e3095ff4b55c"
class TestAnalyticsIdentifier(TestCase): class TestAnalyticsIdentifier(TestCase):
def test_identifier_random(self): def test_identifier_random(self):
self.assertNotEqual(AnalyticsIdentifier.get_solo(), uuid4) self.assertNotEqual(AnalyticsIdentifier.objects.get(), 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

@@ -1,5 +1,3 @@
import os
from django.apps import apps from django.apps import apps
from esi.models import Token from esi.models import Token
@@ -38,16 +36,3 @@ 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,12 +1,10 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.core.checks import register, Tags from django.core.checks import Tags, register
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

@@ -52,10 +52,4 @@ class UserSettingsMiddleware(MiddlewareMixin):
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
# Minimize Menu
try:
request.session["MINIMIZE_SIDEBAR"] = request.user.profile.minimize_sidebar
except Exception as e:
pass # We don't care that an anonymous user has no profile (not logged in)
return response return response

View File

@@ -1,22 +0,0 @@
# Generated by Django 4.2.25 on 2025-10-14 22:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentication", "0024_alter_userprofile_language"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="minimize_sidebar",
field=models.BooleanField(
default=False,
help_text="Keep the sidebar menu minimized",
verbose_name="Minimize Sidebar Menu",
),
),
]

View File

@@ -1,124 +0,0 @@
# 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,5 +1,4 @@
import logging import logging
from typing import ClassVar
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Permission, User
from django.db import models, transaction from django.db import models, transaction
@@ -16,30 +15,24 @@ 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( priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.")
unique=True, help_text="Users get assigned the state with the highest priority available to them."
)
member_characters = models.ManyToManyField( member_characters = models.ManyToManyField(EveCharacter, blank=True,
EveCharacter, blank=True, help_text="Characters to which this state is available." help_text="Characters to which this state is available.")
) member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True,
member_corporations = models.ManyToManyField( help_text="Corporations to whose members this state is available.")
EveCorporationInfo, blank=True, help_text="Corporations to whose members this state is available." member_alliances = models.ManyToManyField(EveAllianceInfo, blank=True,
) help_text="Alliances to whose members this state is available.")
member_alliances = models.ManyToManyField( member_factions = models.ManyToManyField(EveFactionInfo, blank=True,
EveAllianceInfo, blank=True, help_text="Alliances to whose members this state is available." help_text="Factions 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: ClassVar[StateManager] = StateManager() objects = StateManager()
class Meta: class Meta:
ordering = ["-priority"] ordering = ['-priority']
def __str__(self) -> str: def __str__(self):
return self.name return self.name
def available_to_character(self, character): def available_to_character(self, character):
@@ -55,11 +48,11 @@ class State(models.Model):
super().delete(**kwargs) super().delete(**kwargs)
def get_guest_state() -> State: def get_guest_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():
@@ -67,6 +60,8 @@ def get_guest_state_pk():
class UserProfile(models.Model): class UserProfile(models.Model):
class Language(models.TextChoices): class Language(models.TextChoices):
""" """
Choices for UserProfile.language Choices for UserProfile.language
@@ -100,8 +95,7 @@ class UserProfile(models.Model):
on_delete=models.SET_DEFAULT, on_delete=models.SET_DEFAULT,
default=get_guest_state_pk) default=get_guest_state_pk)
language = models.CharField( language = models.CharField(
_("Language"), _("Language"), max_length=10,
max_length=10,
choices=Language.choices, choices=Language.choices,
blank=True, blank=True,
default='') default='')
@@ -113,35 +107,29 @@ class UserProfile(models.Model):
_("Theme"), _("Theme"),
max_length=200, max_length=200,
blank=True, blank=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",
) )
minimize_sidebar = models.BooleanField(
_("Minimize Sidebar Menu"),
default=False,
help_text=_("Keep the sidebar menu minimized")
)
class Meta: class Meta:
default_permissions = ("change",) default_permissions = ('change',)
def __str__(self) -> str: def __str__(self) -> str:
return str(self.user) return str(self.user)
def assign_state(self, state=None, commit=True) -> None: def assign_state(self, state=None, commit=True):
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,
_(f"State changed to: {state}"), _('State changed to: {}'.format(state)),
_("Your user's state is now: %(state)s") % ({"state": state}), _('Your user\'s state is now: %(state)s')
"info", % ({'state': state}),
'info'
) )
from allianceauth.authentication.signals import state_changed from allianceauth.authentication.signals import state_changed
@@ -149,33 +137,34 @@ 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(sender=self.__class__, user=self.user, state=self.state) state_changed.send(
sender=self.__class__, user=self.user, state=self.state
)
class CharacterOwnership(models.Model): class CharacterOwnership(models.Model):
character = models.OneToOneField(EveCharacter, on_delete=models.CASCADE, related_name='character_ownership') character = models.OneToOneField(EveCharacter, on_delete=models.CASCADE, related_name='character_ownership')
owner_hash = models.CharField(max_length=28, unique=True) owner_hash = models.CharField(max_length=28, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="character_ownerships") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='character_ownerships')
objects: ClassVar[CharacterOwnershipManager] = CharacterOwnershipManager()
objects = CharacterOwnershipManager()
class Meta: class Meta:
default_permissions = ('change', 'delete') default_permissions = ('change', 'delete')
ordering = ['user', 'character__character_name'] ordering = ['user', 'character__character_name']
def __str__(self):
def __str__(self) -> str:
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) -> str: def __str__(self):
return f"{self.user}: {self.character} on {self.created}" return f"{self.user}: {self.character} on {self.created}"

View File

@@ -27,7 +27,7 @@ def dashboard_results(hours: int) -> _TaskCounts:
my_earliest = events.first_event(earliest=earliest) my_earliest = events.first_event(earliest=earliest)
return [my_earliest] if my_earliest else [] return [my_earliest] if my_earliest else []
earliest = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours) earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
earliest_events = [] earliest_events = []
succeeded_count = succeeded_tasks.count(earliest=earliest) succeeded_count = succeeded_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(succeeded_tasks, earliest) earliest_events += earliest_if_exists(succeeded_tasks, earliest)

View File

@@ -41,7 +41,7 @@ class EventSeries:
- event_time: timestamp of event. Will use current time if not specified. - event_time: timestamp of event. Will use current time if not specified.
""" """
if not event_time: if not event_time:
event_time = dt.datetime.now(dt.timezone.utc) event_time = dt.datetime.utcnow()
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()})

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base-bs5.html" %} {% extends "allianceauth/base-bs5.html" %}
{% load aa_i18n %}
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
@@ -14,7 +13,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://developers.eveonline.com/authorized-apps 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://community.eveonline.com/support/third-party-applications/ where possible."|urlize %}
</p> </p>
<table class="table w-100" id="table_tokens"> <table class="table w-100" id="table_tokens">
@@ -31,7 +30,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 text-bg-secondary">{{ s.name }}</span> <span class="badge bg-secondary">{{ s.name }}</span>
{% endfor %} {% endfor %}
</td> </td>
@@ -51,23 +50,20 @@
{% 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;
$('#table_tokens').DataTable({ const table = $('#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) {
const api = this.api(); var api = this.api();
const rows = api.rows({page: 'current'}).nodes(); var rows = api.rows({page: 'current'}).nodes();
let last = null; var 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" {% theme_html_tags %}> <html lang="en">
<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">
<!-- End Required meta tags --> <meta name="description" content="">
<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,15 +1,17 @@
{% load i18n %} {% load i18n %}
<form class="dropdown-item" action="{% url 'set_language' %}" method="post"> <div class="dropdown">
{% csrf_token %} <form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<select class="form-select" onchange="this.form.submit()" id="lang-select" name="language"> <select class="form-select" onchange="this.form.submit()" class="form-control" id="lang-select" name="language">
{% get_available_languages as LANGUAGES %} {% get_language_info_list for LANGUAGES as languages %}
{% for lang_code, lang_name in LANGUAGES %} {% for language in languages %}
<option lang="{{ lang_code }}" value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}> <option lang="{{ language.code }}" value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected="selected"{% endif %}>
{{ lang_code|language_name_local|capfirst }} ({{ lang_code }}) {{ language.name_local|capfirst }} ({{ language.code }})
</option> </option>
{% 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://developers.eveonline.com/authorized-apps" target="_blank" rel="noopener noreferrer"> <a class="text-reset" href="https://community.eveonline.com/support/third-party-applications/" target="_blank" rel="noopener noreferrer">
{% translate "Manage ESI Applications" %} {% translate "Manage ESI Applications" %}
</a> </a>
</p> </p>

View File

@@ -88,7 +88,6 @@ class TestUserSettingsMiddlewareLoginFlow(TestCase):
self.request.LANGUAGE_CODE = 'en' self.request.LANGUAGE_CODE = 'en'
self.request.user.profile.language = 'de' self.request.user.profile.language = 'de'
self.request.user.profile.night_mode = True self.request.user.profile.night_mode = True
self.request.user.profile.minimize_sidebar = False
self.request.user.is_anonymous = False self.request.user.is_anonymous = False
self.response = Mock() self.response = Mock()
self.response.content = 'hello world' self.response.content = 'hello world'
@@ -174,26 +173,3 @@ class TestUserSettingsMiddlewareLoginFlow(TestCase):
self.response self.response
) )
self.assertEqual(self.request.session["NIGHT_MODE"], True) self.assertEqual(self.request.session["NIGHT_MODE"], True)
def test_middleware_set_mimimize_sidebar(self):
"""
tests the middleware will always set minimize_sidebar to False (default)
"""
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.session["MINIMIZE_SIDEBAR"], False)
def test_middleware_minimize_sidebar_when_set(self):
"""
tests the middleware will set mimimize_sidebar to True from DB
"""
self.request.user.profile.minimize_sidebar = True
response = self.middleware.process_response(
self.request,
self.response
)
self.assertEqual(self.request.session["MINIMIZE_SIDEBAR"], True)

View File

@@ -1,3 +1,4 @@
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.test.testcases import TestCase from django.test.testcases import TestCase

View File

@@ -8,61 +8,23 @@ 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.admin_status.models import ApplicationAnnouncement from allianceauth.templatetags.admin_status import (
from allianceauth.admin_status.templatetags.admin_status import (
_current_notifications, _current_notifications,
_current_version_summary, _current_version_summary,
_fetch_list_from_gitlab, _fetch_list_from_gitlab,
_fetch_notification_issues_from_gitlab,
_latests_versions, _latests_versions,
status_overview, status_overview,
) )
MODULE_PATH = 'allianceauth.admin_status.templatetags' MODULE_PATH = 'allianceauth.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,
@@ -90,10 +52,6 @@ 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):
@@ -149,19 +107,18 @@ class TestNotifications(TestCase):
) )
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES) requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
# when # when
result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10) result = _fetch_notification_issues_from_gitlab()
# then # then
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES) self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
@patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement') @patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_normal(self, mock_application_announcement): def test_current_notifications_normal(self, mock_cache):
# given # given
mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
# when # when
result = _current_notifications() result = _current_notifications()
# then # then
for notification in result["notifications"]: self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5])
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):
@@ -174,7 +131,16 @@ class TestNotifications(TestCase):
# when # when
result = _current_notifications() result = _current_notifications()
# then # then
self.assertEqual(list(result['notifications']), []) self.assertEqual(result['notifications'], [])
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_is_none(self, mock_cache):
# given
mock_cache.get_or_set.return_value = None
# when
result = _current_notifications()
# then
self.assertEqual(result['notifications'], [])
class TestCeleryQueueLength(TestCase): class TestCeleryQueueLength(TestCase):

View File

@@ -10,7 +10,6 @@ from allianceauth.authentication.views import esi_check, task_counts
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
MODULE_PATH = "allianceauth.authentication.views" MODULE_PATH = "allianceauth.authentication.views"
TEMPLATETAGS_PATH = "allianceauth.templatetags.admin_status"
def jsonresponse_to_dict(response) -> dict: def jsonresponse_to_dict(response) -> dict:
@@ -19,7 +18,6 @@ def jsonresponse_to_dict(response) -> dict:
@patch(MODULE_PATH + ".queued_tasks_count") @patch(MODULE_PATH + ".queued_tasks_count")
@patch(MODULE_PATH + ".active_tasks_count") @patch(MODULE_PATH + ".active_tasks_count")
@patch(MODULE_PATH + "._celery_stats")
class TestRunningTasksCount(TestCase): class TestRunningTasksCount(TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
@@ -29,64 +27,36 @@ class TestRunningTasksCount(TestCase):
cls.user.is_superuser = True cls.user.is_superuser = True
cls.user.save() cls.user.save()
def test_should_return_data(self, mock_celery_stats, mock_tasks_queued, mock_tasks_running): def test_should_return_data(
self, mock_active_tasks_count, mock_queued_tasks_count
):
# given # given
mock_tasks_running.return_value = 2 mock_active_tasks_count.return_value = 2
mock_tasks_queued.return_value = 3 mock_queued_tasks_count.return_value = 3
mock_celery_stats.return_value = {
"tasks_succeeded": 5,
"tasks_retried": 1,
"tasks_failed": 4,
"tasks_total": 11,
"tasks_hours": 24,
"earliest_task": "2025-08-14T22:47:54.853Z",
}
request = self.factory.get("/") request = self.factory.get("/")
request.user = self.user request.user = self.user
# when # when
response = task_counts(request) response = task_counts(request)
# then # then
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( self.assertDictEqual(
jsonresponse_to_dict(response), jsonresponse_to_dict(response), {
{ "tasks_running": 2, "tasks_queued": 3}
"tasks_succeeded": 5,
"tasks_retried": 1,
"tasks_failed": 4,
"tasks_total": 11,
"tasks_hours": 24,
"earliest_task": "2025-08-14T22:47:54.853Z",
"tasks_running": 3,
"tasks_queued": 2,
}
) )
def test_su_only(self, mock_celery_stats, mock_tasks_queued, mock_tasks_running): def test_su_only(
self, mock_active_tasks_count, mock_queued_tasks_count
):
self.user.is_superuser = False self.user.is_superuser = False
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
# given # given
mock_tasks_running.return_value = 2 mock_active_tasks_count.return_value = 2
mock_tasks_queued.return_value = 3 mock_queued_tasks_count.return_value = 3
mock_celery_stats.return_value = {
"tasks_succeeded": 5,
"tasks_retried": 1,
"tasks_failed": 4,
"tasks_total": 11,
"tasks_hours": 24,
"earliest_task": "2025-08-14T22:47:54.853Z",
}
request = self.factory.get("/") request = self.factory.get("/")
request.user = self.user request.user = self.user
# when # when
response = task_counts(request) response = task_counts(request)
# then # then
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)

View File

@@ -38,6 +38,7 @@ 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

@@ -28,7 +28,6 @@ from allianceauth.hooks import get_hooks
from .constants import ESI_ERROR_MESSAGE_OVERRIDES from .constants import ESI_ERROR_MESSAGE_OVERRIDES
from .core.celery_workers import active_tasks_count, queued_tasks_count from .core.celery_workers import active_tasks_count, queued_tasks_count
from allianceauth.admin_status.templatetags.admin_status import _celery_stats
from .forms import RegistrationForm from .forms import RegistrationForm
from .models import CharacterOwnership from .models import CharacterOwnership
@@ -75,14 +74,14 @@ def dashboard_characters(request):
def dashboard_admin(request): def dashboard_admin(request):
if request.user.is_superuser: if request.user.is_superuser:
return render_to_string('admin-status/include.html', request=request) return render_to_string('allianceauth/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('admin-status/esi_check.html', request=request) return render_to_string('allianceauth/admin-status/esi_check.html', request=request)
else: else:
return "" return ""
@@ -176,9 +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, _(
f'Added {token.character_name} to your account.')) 'Added {name} to your account.'.format(name=token.character_name)))
else: else:
messages.error(request, _(f'Failed to add {token.character_name} to your account: they already have an account.')) messages.error(request, _('Failed to add {name} to your account: they already have an account.'.format(name=token.character_name)))
return redirect('authentication:dashboard') return redirect('authentication:dashboard')
@@ -369,10 +368,10 @@ def registration_closed(request):
@user_passes_test(lambda u: u.is_superuser) @user_passes_test(lambda u: u.is_superuser)
def task_counts(request) -> JsonResponse: def task_counts(request) -> JsonResponse:
"""Return task counts as JSON for an AJAX call.""" """Return task counts as JSON for an AJAX call."""
data = _celery_stats() data = {
data.update( "tasks_running": active_tasks_count(),
{"tasks_running": active_tasks_count(), "tasks_queued": queued_tasks_count()} "tasks_queued": queued_tasks_count()
) }
return JsonResponse(data) return JsonResponse(data)
@@ -393,3 +392,12 @@ 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

@@ -14,7 +14,6 @@ class StartProject(BaseStartProject):
parser.add_argument('--celery', help='The path to the celery executable.') parser.add_argument('--celery', help='The path to the celery executable.')
parser.add_argument('--gunicorn', help='The path to the gunicorn executable.') parser.add_argument('--gunicorn', help='The path to the gunicorn executable.')
parser.add_argument('--memmon', help='The path to the memmon executable.') parser.add_argument('--memmon', help='The path to the memmon executable.')
parser.add_argument('--venv_directory', help='The path to the virtual environment directory.')
def create_project(parser, options, args): def create_project(parser, options, args):
@@ -29,7 +28,7 @@ def create_project(parser, options, args):
allianceauth_path = os.path.dirname(allianceauth.__file__) allianceauth_path = os.path.dirname(allianceauth.__file__)
template_path = os.path.join(allianceauth_path, 'project_template') template_path = os.path.join(allianceauth_path, 'project_template')
# Determine locations of commands to render supervisor configuration # Determine locations of commands to render supervisor cond
command_options = { command_options = {
'template': template_path, 'template': template_path,
'python': shutil.which('python'), 'python': shutil.which('python'),
@@ -37,7 +36,6 @@ def create_project(parser, options, args):
'celery': shutil.which('celery'), 'celery': shutil.which('celery'),
'memmon': shutil.which('memmon'), 'memmon': shutil.which('memmon'),
'extensions': ['py', 'conf', 'json'], 'extensions': ['py', 'conf', 'json'],
'venv_directory': os.getenv('VIRTUAL_ENV'),
} }
# Strip 'start' out of the arguments, leaving project name (and optionally destination dir) # Strip 'start' out of the arguments, leaving project name (and optionally destination dir)

View File

@@ -1,8 +1,4 @@
""" import datetime
Django system checks for Alliance Auth
"""
from datetime import datetime, timezone
from sqlite3.dbapi2 import sqlite_version_info from sqlite3.dbapi2 import sqlite_version_info
from celery import current_app from celery import current_app
@@ -11,6 +7,7 @@ from packaging.version import InvalidVersion, Version as Pep440Version
from django import db from django import db
from django.conf import settings from django.conf import settings
from django.core.checks import CheckMessage, Error, Warning, register from django.core.checks import CheckMessage, Error, Warning, register
from django.utils import timezone
from allianceauth.utils.cache import get_redis_client from allianceauth.utils.cache import get_redis_client
@@ -22,415 +19,141 @@ B = Configuration
@register() @register()
def django_settings(app_configs, **kwargs) -> list[CheckMessage]: def django_settings(app_configs, **kwargs) -> list[CheckMessage]:
"""
Check that Django settings are correctly configured
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = [] errors: list[CheckMessage] = []
# Check for SITE_URL
if hasattr(settings, "SITE_URL"): if hasattr(settings, "SITE_URL"):
# Check if SITE_URL is empty if settings.SITE_URL[-1] == "/":
if settings.SITE_URL == "": errors.append(Warning("'SITE_URL' Has a trailing slash. This may lead to incorrect links being generated by Auth.", hint="", id="allianceauth.checks.B005"))
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( 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"))
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"): 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: if settings.SITE_URL not in settings.CSRF_TRUSTED_ORIGINS:
errors.append( 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"))
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: else:
errors.append( 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"))
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]:
"""
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] = [] 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 ( if redis_version.major == 7 and redis_version.minor == 2 and timezone.now() > timezone.datetime(year=2025, month=8, day=31, tzinfo=datetime.timezone.utc):
redis_version.major == 7 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"))
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( 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"))
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( 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"))
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( 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"))
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]:
"""
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] = [] 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( mysql_version = Pep440Version(".".join(str(i) for i in connection.mysql_version))
".".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: if mysql_version.major == 8 and mysql_version.minor == 4 and timezone.now() > timezone.datetime(year=2032, month=4, day=30, tzinfo=datetime.timezone.utc):
if mysql_version.minor == 4 and datetime.now(timezone.utc) > datetime( 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"))
year=2032, month=4, day=30, tzinfo=timezone.utc elif mysql_version.major == 8 and mysql_version.minor == 3:
): 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"))
errors.append( elif mysql_version.major == 8 and mysql_version.minor == 2:
Error( 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"))
msg=f"MySQL {mysql_version.public} EOL", elif mysql_version.major == 8 and mysql_version.minor == 1:
hint=mysql_quick_guide_link, 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"))
id="allianceauth.checks.A004", elif mysql_version.major == 8 and mysql_version.minor == 0 and timezone.now() > timezone.datetime(year=2026, month=4, day=30, tzinfo=datetime.timezone.utc):
) 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
# Demote versions down here once EOL 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"))
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]: # noqa: C901 def system_package_mariadb(app_configs, **kwargs) -> list[CheckMessage]:
"""
Check that MariaDB is a supported version
"""
mariadb_download_link = "https://mariadb.org/download/?t=repo-config"
errors: list[CheckMessage] = [] errors: list[CheckMessage] = []
for connection in db.connections.all(): for connection in db.connections.all():
# TODO: Find a way to determine MySQL vs. MariaDB if connection.vendor == "mysql": # Still to find a way to determine MySQL vs MariaDB
if connection.vendor == "mysql":
try: try:
mariadb_version = Pep440Version( mariadb_version = Pep440Version(".".join(str(i) for i in connection.mysql_version))
".".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: if mariadb_version.major == 11 and mariadb_version.minor == 4 and timezone.now() > timezone.datetime(year=2029, month=5, day=19, tzinfo=datetime.timezone.utc):
if mariadb_version.minor == 4 and datetime.now(timezone.utc) > datetime( errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A010"))
year=2029, month=5, day=19, tzinfo=timezone.utc elif mariadb_version.major == 11 and mariadb_version.minor == 2:
): errors.append(Warning(f"MariaDB {mariadb_version.public} Non LTS", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A018"))
errors.append( if timezone.now() > timezone.datetime(year=2024, month=11, day=21, tzinfo=datetime.timezone.utc):
Error( errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A011"))
msg=f"MariaDB {mariadb_version.public} EOL", elif mariadb_version.major == 11 and mariadb_version.minor == 1:
hint=mariadb_download_link, errors.append(Warning(f"MariaDB {mariadb_version.public} Non LTS", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A019"))
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: elif mariadb_version.major == 10 and mariadb_version.minor == 11 and timezone.now() > timezone.datetime(year=2028, month=2, day=10, tzinfo=datetime.timezone.utc):
if mariadb_version.minor == 11 and datetime.now(timezone.utc) > datetime( errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config.", id="allianceauth.checks.A014"))
year=2028, month=2, day=10, tzinfo=timezone.utc elif mariadb_version.major == 10 and mariadb_version.minor == 6 and timezone.now() > timezone.datetime(year=2026, month=7, day=6, tzinfo=datetime.timezone.utc):
): errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A0015"))
errors.append( elif mariadb_version.major == 10 and mariadb_version.minor == 5 and timezone.now() > timezone.datetime(year=2025, month=6, day=24, tzinfo=datetime.timezone.utc):
Error( errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A016"))
msg=f"MariaDB {mariadb_version.public} EOL", elif mariadb_version.major == 10 and mariadb_version.minor in [0, 1, 2, 3, 4, 7, 9, 10]: # Demote versions down here once EOL
hint=mariadb_download_link, errors.append(Error(f"MariaDB {mariadb_version.public} EOL", hint="https://mariadb.org/download/?t=repo-config", id="allianceauth.checks.A017"))
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]:
"""
Check that SQLite is a supported version
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = [] 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( sqlite_version = Pep440Version(".".join(str(i) for i in sqlite_version_info))
".".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( errors.append(Error(f"SQLite {sqlite_version.public} Unsupported by Django", hint="https://pkgs.org/download/sqlite3", id="allianceauth.checks.A020"))
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]:
"""
Check that SQL settings are correctly configured
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = [] 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( 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"))
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( 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"))
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 ( # if connection.settings_dict["OPTIONS"]["collation"] != "utf8mb4_unicode_ci":
# connection.settings_dict["OPTIONS"]["collation"] # 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"))
# != "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( # 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"))
# 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":
@@ -439,57 +162,19 @@ 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]:
"""
Check that Celery settings are correctly configured
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
errors: list[CheckMessage] = [] errors: list[CheckMessage] = []
try: try:
if current_app.conf.broker_transport_options != { if current_app.conf.broker_transport_options != {'priority_steps': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 'queue_order_strategy': 'priority'}:
"priority_steps": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], errors.append(Error("Celery Priorities are not set correctly", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc", id="allianceauth.checks.B003"))
"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( errors.append(Error("Celery Priorities are not set", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc", id="allianceauth.checks.B003"))
Error(
msg="Celery Priorities are not set",
hint="https://gitlab.com/allianceauth/allianceauth/-/commit/8861ec0a61790eca0261f1adc1cc04ca5f243cbc",
id="allianceauth.checks.B003",
)
)
try: try:
if not current_app.conf.broker_connection_retry_on_startup: if current_app.conf.broker_connection_retry_on_startup is not True:
errors.append( errors.append(Error("Celery broker_connection_retry_on_startup not set correctly", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280", id="allianceauth.checks.B004"))
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( errors.append(Error("Celery broker_connection_retry_on_startup not set", hint="https://gitlab.com/allianceauth/allianceauth/-/commit/380c41400b535447839e5552df2410af35a75280", id="allianceauth.checks.B004"))
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,8 +1,6 @@
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

@@ -70,7 +70,7 @@ def forward(apps, schema_editor):
perm.delete() perm.delete()
def reverse(apps, schema_editor): # noqa: C901 def reverse(apps, schema_editor):
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,52 +0,0 @@
# 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')},
},
),
]

View File

@@ -1,6 +1,5 @@
import logging import logging
import os import os
from typing import ClassVar
from bravado.exception import HTTPForbidden from bravado.exception import HTTPForbidden
@@ -34,8 +33,7 @@ class CorpStats(models.Model):
corp = models.OneToOneField(EveCorporationInfo, on_delete=models.CASCADE) corp = models.OneToOneField(EveCorporationInfo, on_delete=models.CASCADE)
last_update = models.DateTimeField(auto_now=True) last_update = models.DateTimeField(auto_now=True)
objects: ClassVar[CorpStatsManager] = CorpStatsManager() objects = CorpStatsManager()
class Meta: class Meta:
permissions = ( permissions = (
('view_corp_corpstats', 'Can view corp stats of their corporation.'), ('view_corp_corpstats', 'Can view corp stats of their corporation.'),
@@ -45,10 +43,12 @@ class CorpStats(models.Model):
verbose_name = "corp stats" verbose_name = "corp stats"
verbose_name_plural = "corp stats" verbose_name_plural = "corp stats"
def __str__(self) -> str:
def __str__(self):
return f"{self.__class__.__name__} for {self.corp}" return f"{self.__class__.__name__} for {self.corp}"
def update(self) -> None: def update(self):
try: try:
c = self.token.get_esi_client(spec_file=SWAGGER_SPEC_PATH) c = self.token.get_esi_client(spec_file=SWAGGER_SPEC_PATH)
assert c.Character.get_characters_character_id(character_id=self.token.character_id).result()['corporation_id'] == int(self.corp.corporation_id) assert c.Character.get_characters_character_id(character_id=self.token.character_id).result()['corporation_id'] == int(self.corp.corporation_id)
@@ -101,11 +101,11 @@ class CorpStats(models.Model):
return self.members.count() return self.members.count()
@property @property
def user_count(self) -> int: def user_count(self):
return len({m.main_character for m in self.members.all() if m.main_character}) return len({m.main_character for m in self.members.all() if m.main_character})
@property @property
def registered_member_count(self) -> int: def registered_member_count(self):
return len(self.registered_members) return len(self.registered_members)
@property @property
@@ -121,7 +121,7 @@ class CorpStats(models.Model):
return self.members.filter(pk__in=[m.pk for m in self.members.all() if not m.registered]) return self.members.filter(pk__in=[m.pk for m in self.members.all() if not m.registered])
@property @property
def main_count(self) -> int: def main_count(self):
return len(self.mains) return len(self.mains)
@property @property
@@ -134,10 +134,10 @@ class CorpStats(models.Model):
def can_update(self, user): def can_update(self, user):
return self.token.user == user or self.visible_to(user) return self.token.user == user or self.visible_to(user)
def corp_logo(self, size=128) -> str: def corp_logo(self, size=128):
return self.corp.logo_url(size) return self.corp.logo_url(size)
def alliance_logo(self, size=128) -> str: def alliance_logo(self, size=128):
if self.corp.alliance: if self.corp.alliance:
return self.corp.alliance.logo_url(size) return self.corp.alliance.logo_url(size)
else: else:
@@ -154,11 +154,11 @@ class CorpMember(models.Model):
unique_together = ('corpstats', 'character_id') unique_together = ('corpstats', 'character_id')
ordering = ['character_name'] ordering = ['character_name']
def __str__(self) -> str: def __str__(self):
return self.character_name return self.character_name
@property @property
def character(self) -> EveCharacter | None: def character(self):
try: try:
return EveCharacter.objects.get(character_id=self.character_id) return EveCharacter.objects.get(character_id=self.character_id)
except EveCharacter.DoesNotExist: except EveCharacter.DoesNotExist:
@@ -179,20 +179,20 @@ class CorpMember(models.Model):
return [] return []
@property @property
def registered(self) -> bool: def registered(self):
return CharacterOwnership.objects.filter(character__character_id=self.character_id).exists() return CharacterOwnership.objects.filter(character__character_id=self.character_id).exists()
def portrait_url(self, size=32) -> str: def portrait_url(self, size=32):
return EveCharacter.generic_portrait_url(self.character_id, size) return EveCharacter.generic_portrait_url(self.character_id, size)
@property @property
def portrait_url_32(self) -> str: def portrait_url_32(self):
return self.portrait_url(32) return self.portrait_url(32)
@property @property
def portrait_url_64(self) -> str: def portrait_url_64(self):
return self.portrait_url(64) return self.portrait_url(64)
@property @property
def portrait_url_128(self) -> str: def portrait_url_128(self):
return self.portrait_url(128) return self.portrait_url(128)

View File

@@ -11,7 +11,7 @@
{% endblock header_nav_brand %} {% endblock header_nav_brand %}
{% block header_nav_collapse_left %} {% block header_nav_collapse_left %}
<li class="nav-item dropdown mb-2 mb-lg-0"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"> <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">
{% translate "Corporations" %} {% translate "Corporations" %}
</a> </a>
@@ -26,7 +26,11 @@
{% endfor %} {% endfor %}
{% if perms.corputils.add_corpstats %} {% if perms.corputils.add_corpstats %}
<li class="mt-3"> {% if available.count >= 1 %}
<li>&nbsp;</li>
{% endif %}
<li>
<a class="dropdown-item" href="{% url 'corputils:add' %}"> <a class="dropdown-item" href="{% url 'corputils:add' %}">
{% translate "Add corporation" %} {% translate "Add corporation" %}
</a> </a>

View File

@@ -1,6 +1,5 @@
{% extends 'corputils/base.html' %} {% extends 'corputils/base.html' %}
{% load aa_i18n %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
@@ -138,7 +137,7 @@
<td style="width: 30%;">{{ alt.corporation_name }}</td> <td style="width: 30%;">{{ alt.corporation_name }}</td>
<td style="width: 30%;">{{ alt.alliance_name|default_if_none:"" }}</td> <td style="width: 30%;">{{ alt.alliance_name|default_if_none:"" }}</td>
<td style="width: 5%;"> <td style="width: 5%;">
<a href="https://zkillboard.com/character/{{ alt.character_id }}/" class="badge text-bg-danger" target="_blank"> <a href="https://zkillboard.com/character/{{ alt.character_id }}/" class="badge bg-danger" target="_blank">
{% translate "Killboard" %} {% translate "Killboard" %}
</a> </a>
</td> </td>
@@ -175,7 +174,7 @@
<td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member }}"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member }}"></td>
<td>{{ member }}</td> <td>{{ member }}</td>
<td> <td>
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge text-bg-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge bg-danger" target="_blank">{% translate "Killboard" %}</a>
</td> </td>
<td>{{ member.character_ownership.user.profile.main_character.character_name }}</td> <td>{{ member.character_ownership.user.profile.main_character.character_name }}</td>
<td>{{ member.character_ownership.user.profile.main_character.corporation_name }}</td> <td>{{ member.character_ownership.user.profile.main_character.corporation_name }}</td>
@@ -188,7 +187,7 @@
<td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td>{{ member.character_name }}</td> <td>{{ member.character_name }}</td>
<td> <td>
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge text-bg-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge bg-danger" target="_blank">{% translate "Killboard" %}</a>
</td> </td>
<td></td> <td></td>
<td></td> <td></td>
@@ -219,7 +218,7 @@
<td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td>{{ member.character_name }}</td> <td>{{ member.character_name }}</td>
<td> <td>
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge text-bg-danger" target="_blank"> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="badge bg-danger" target="_blank">
{% translate "Killboard" %} {% translate "Killboard" %}
</a> </a>
</td> </td>
@@ -239,12 +238,9 @@
{% 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(() => {
$('#table-mains').DataTable({ $('#table-mains').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
"columnDefs": [ "columnDefs": [
{ "sortable": false, "targets": [1] }, { "sortable": false, "targets": [1] },
], ],
@@ -253,7 +249,6 @@
}); });
$('#table-members').DataTable({ $('#table-members').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
"columnDefs": [ "columnDefs": [
{ "searchable": false, "targets": [0, 2] }, { "searchable": false, "targets": [0, 2] },
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },
@@ -264,7 +259,6 @@
}); });
$('#table-unregistered').DataTable({ $('#table-unregistered').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
"columnDefs": [ "columnDefs": [
{ "searchable": false, "targets": [0, 2] }, { "searchable": false, "targets": [0, 2] },
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },

View File

@@ -1,6 +1,5 @@
{% extends "corputils/base.html" %} {% extends "corputils/base.html" %}
{% load aa_i18n %}
{% load i18n %} {% load i18n %}
{% block member_data %} {% block member_data %}
@@ -28,7 +27,7 @@
<td><img src="{{ result.1.portrait_url }}" class="img-circle" alt="{{ result.1.character_name }}"></td> <td><img src="{{ result.1.portrait_url }}" class="img-circle" alt="{{ result.1.character_name }}"></td>
<td>{{ result.1.character_name }}</td> <td>{{ result.1.character_name }}</td>
<td >{{ result.0.corp.corporation_name }}</td> <td >{{ result.0.corp.corporation_name }}</td>
<td><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="badge text-bg-danger" target="_blank">{% translate "Killboard" %}</a></td> <td><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="badge bg-danger" target="_blank">{% translate "Killboard" %}</a></td>
<td>{{ result.1.main_character.character_name }}</td> <td>{{ result.1.main_character.character_name }}</td>
<td>{{ result.1.main_character.corporation_name }}</td> <td>{{ result.1.main_character.corporation_name }}</td>
<td>{{ result.1.main_character.alliance_name }}</td> <td>{{ result.1.main_character.alliance_name }}</td>
@@ -43,12 +42,9 @@
{% 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(() => {
$('#table-search').DataTable({ $('#table-search').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
"stateSave": true, "stateSave": true,
"stateDuration": 0 "stateDuration": 0
}); });

View File

@@ -62,7 +62,7 @@ def corpstats_add(request, token):
@login_required @login_required
@user_passes_test(access_corpstats_test) @user_passes_test(access_corpstats_test)
def corpstats_view(request, corp_id=None): # noqa: C901 def corpstats_view(request, corp_id=None):
corpstats = None corpstats = None
# get requested model # get requested model

View File

@@ -1,3 +0,0 @@
"""
Alliance Auth Crontab Utilities
"""

View File

@@ -1,16 +0,0 @@
"""
Crontab App Config
"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CrontabConfig(AppConfig):
"""
Crontab App Config
"""
name = "allianceauth.crontab"
label = "crontab"
verbose_name = _("Crontab")

View File

@@ -1,30 +0,0 @@
# Generated by Django 4.2.16 on 2025-01-20 06:16
from django.db import migrations, models
import allianceauth.crontab.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='CronOffset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('minute', models.FloatField(default=allianceauth.crontab.models.random_default, verbose_name='Minute Offset')),
('hour', models.FloatField(default=allianceauth.crontab.models.random_default, verbose_name='Hour Offset')),
('day_of_month', models.FloatField(default=allianceauth.crontab.models.random_default, verbose_name='Day of Month Offset')),
('month_of_year', models.FloatField(default=allianceauth.crontab.models.random_default, verbose_name='Month of Year Offset')),
('day_of_week', models.FloatField(default=allianceauth.crontab.models.random_default, verbose_name='Day of Week Offset')),
],
options={
'verbose_name': 'Cron Offsets',
},
),
]

View File

@@ -1,24 +0,0 @@
from random import random
from solo.models import SingletonModel
from django.db import models
from django.utils.translation import gettext_lazy as _
def random_default() -> float:
return random()
class CronOffset(SingletonModel):
minute = models.FloatField(_("Minute Offset"), default=random_default)
hour = models.FloatField(_("Hour Offset"), default=random_default)
day_of_month = models.FloatField(_("Day of Month Offset"), default=random_default)
month_of_year = models.FloatField(_("Month of Year Offset"), default=random_default)
day_of_week = models.FloatField(_("Day of Week Offset"), default=random_default)
def __str__(self) -> str:
return "Cron Offsets"
class Meta:
verbose_name = "Cron Offsets"

View File

@@ -1,68 +0,0 @@
from celery import schedules
from celery.utils.log import get_logger
from django_celery_beat.models import CrontabSchedule
from django_celery_beat.schedulers import DatabaseScheduler
from django.core.exceptions import ObjectDoesNotExist
from django.db.utils import OperationalError, ProgrammingError
from allianceauth.crontab.models import CronOffset
from allianceauth.crontab.utils import offset_cron
logger = get_logger(__name__)
class OffsetDatabaseScheduler(DatabaseScheduler):
"""
Customization of Django Celery Beat, Database Scheduler
Takes the Celery Schedule from local.py and applies our AA Framework Cron Offset, if apply_offset is true
Otherwise it passes it through as normal
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def update_from_dict(self, mapping):
s = {}
try:
cron_offset = CronOffset.get_solo() # noqa: F841
except (OperationalError, ProgrammingError, ObjectDoesNotExist) as exc:
# This is just incase we haven't migrated yet or something
logger.warning(
"OffsetDatabaseScheduler: Could not fetch CronOffset (%r). "
"Defering to DatabaseScheduler",
exc
)
return super().update_from_dict(mapping)
for name, entry_fields in mapping.items():
try:
apply_offset = entry_fields.pop("apply_offset", False) # Ensure this pops before django tries to save to ORM
entry = self.Entry.from_entry(name, app=self.app, **entry_fields)
if apply_offset:
entry_fields.update({"apply_offset": apply_offset}) # Reapply this as its gets pulled from config inconsistently.
schedule_obj = entry.schedule
if isinstance(schedule_obj, schedules.crontab):
offset_cs = CrontabSchedule.from_schedule(offset_cron(schedule_obj))
offset_cs, created = CrontabSchedule.objects.get_or_create(
minute=offset_cs.minute,
hour=offset_cs.hour,
day_of_month=offset_cs.day_of_month,
month_of_year=offset_cs.month_of_year,
day_of_week=offset_cs.day_of_week,
timezone=offset_cs.timezone,
)
entry.schedule = offset_cron(schedule_obj) # This gets passed into Celery Beats Memory, important to keep it in sync with the model/DB
entry.model.crontab = offset_cs
entry.model.save()
logger.debug(f"Offset applied for '{name}' due to 'apply_offset' = True.")
if entry.model.enabled:
s[name] = entry
except Exception as e:
logger.exception("Error updating schedule for %s: %r", name, e)
self.schedule.update(s)

View File

@@ -1,64 +0,0 @@
from unittest.mock import patch
from django.test import TestCase
from allianceauth.crontab.models import CronOffset
class CronOffsetModelTest(TestCase):
def test_cron_offset_is_singleton(self):
"""
Test that CronOffset is indeed a singleton and that
multiple calls to get_solo() return the same instance.
"""
offset1 = CronOffset.get_solo()
offset2 = CronOffset.get_solo()
# They should be the exact same object in memory
self.assertEqual(offset1.pk, offset2.pk)
def test_default_values_random(self):
"""
Test that the default values are set via random_default() when
no explicit value is provided. We'll patch 'random.random' to
produce predictable output.
"""
with patch('allianceauth.crontab.models.random', return_value=0.1234):
# Force creation of a new CronOffset by clearing the existing one
CronOffset.objects.all().delete()
offset = CronOffset.get_solo() # This triggers creation
# All fields should be 0.1234, because we patched random()
self.assertAlmostEqual(offset.minute, 0.1234)
self.assertAlmostEqual(offset.hour, 0.1234)
self.assertAlmostEqual(offset.day_of_month, 0.1234)
self.assertAlmostEqual(offset.month_of_year, 0.1234)
self.assertAlmostEqual(offset.day_of_week, 0.1234)
def test_update_offset_values(self):
"""
Test that we can update the offsets and retrieve them.
"""
offset = CronOffset.get_solo()
offset.minute = 0.5
offset.hour = 0.25
offset.day_of_month = 0.75
offset.month_of_year = 0.99
offset.day_of_week = 0.33
offset.save()
# Retrieve again to ensure changes persist
saved_offset = CronOffset.get_solo()
self.assertEqual(saved_offset.minute, 0.5)
self.assertEqual(saved_offset.hour, 0.25)
self.assertEqual(saved_offset.day_of_month, 0.75)
self.assertEqual(saved_offset.month_of_year, 0.99)
self.assertEqual(saved_offset.day_of_week, 0.33)
def test_str_representation(self):
"""
Verify the __str__ method returns 'Cron Offsets'.
"""
offset = CronOffset.get_solo()
self.assertEqual(str(offset), "Cron Offsets")

View File

@@ -1,82 +0,0 @@
# myapp/tests/test_tasks.py
import logging
from unittest.mock import patch
from celery.schedules import crontab
from django.db import ProgrammingError
from django.test import TestCase
from allianceauth.crontab.models import CronOffset
from allianceauth.crontab.utils import offset_cron
logger = logging.getLogger(__name__)
class TestOffsetCron(TestCase):
def test_offset_cron_normal(self):
"""
Test that offset_cron modifies the minute/hour fields
based on the CronOffset values when everything is normal.
"""
# We'll create a mock CronOffset instance
mock_offset = CronOffset(minute=0.5, hour=0.5)
# Our initial crontab schedule
original_schedule = crontab(
minute=[0, 5, 55],
hour=[0, 3, 23],
day_of_month='*',
month_of_year='*',
day_of_week='*'
)
# Patch CronOffset.get_solo to return our mock offset
with patch('allianceauth.crontab.models.CronOffset.get_solo', return_value=mock_offset):
new_schedule = offset_cron(original_schedule)
# Check the new minute/hour
# minute 0 -> 0 + round(60 * 0.5) = 30 % 60 = 30
# minute 5 -> 5 + 30 = 35 % 60 = 35
# minute 55 -> 55 + 30 = 85 % 60 = 25 --> sorted => 25,30,35
self.assertEqual(new_schedule._orig_minute, '25,30,35')
# hour 0 -> 0 + round(24 * 0.5) = 12 % 24 = 12
# hour 3 -> 3 + 12 = 15 % 24 = 15
# hour 23 -> 23 + 12 = 35 % 24 = 11 --> sorted => 11,12,15
self.assertEqual(new_schedule._orig_hour, '11,12,15')
# Check that other fields are unchanged
self.assertEqual(new_schedule._orig_day_of_month, '*')
self.assertEqual(new_schedule._orig_month_of_year, '*')
self.assertEqual(new_schedule._orig_day_of_week, '*')
def test_offset_cron_programming_error(self):
"""
Test that if a ProgrammingError is raised (e.g. before migrations),
offset_cron just returns the original schedule.
"""
original_schedule = crontab(minute=[0, 15, 30], hour=[1, 2, 3])
# Force get_solo to raise ProgrammingError
with patch('allianceauth.crontab.models.CronOffset.get_solo', side_effect=ProgrammingError()):
new_schedule = offset_cron(original_schedule)
# Should return the original schedule unchanged
self.assertEqual(new_schedule, original_schedule)
def test_offset_cron_unexpected_exception(self):
"""
Test that if any other exception is raised, offset_cron
also returns the original schedule, and logs the error.
"""
original_schedule = crontab(minute='0', hour='0')
# Force get_solo to raise a generic Exception
with patch('allianceauth.crontab.models.CronOffset.get_solo', side_effect=Exception("Something bad")):
new_schedule = offset_cron(original_schedule)
# Should return the original schedule unchanged
self.assertEqual(new_schedule, original_schedule)

View File

@@ -1,52 +0,0 @@
import logging
from celery.schedules import crontab
from django.db import ProgrammingError
from allianceauth.crontab.models import CronOffset
logger = logging.getLogger(__name__)
def offset_cron(schedule: crontab) -> crontab:
"""Take a crontab and apply a series of precalculated offsets to spread out tasks execution on remote resources
Args:
schedule (crontab): celery.schedules.crontab()
Returns:
crontab: A crontab with offsetted Minute and Hour fields
"""
try:
cron_offset = CronOffset.get_solo()
# Stops this shit from happening 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23
# It is only cosmetic, but still annoying
if schedule._orig_minute == '*':
new_minute = '*'
else:
new_minute = [(m + (round(60 * cron_offset.minute))) % 60 for m in schedule.minute]
if schedule._orig_hour == '*':
new_hour = '*'
else:
new_hour = [(m + (round(24 * cron_offset.hour))) % 24 for m in schedule.hour]
return crontab(
minute=",".join(str(m) for m in sorted(new_minute)),
hour=",".join(str(h) for h in sorted(new_hour)),
day_of_month=schedule._orig_day_of_month,
month_of_year=schedule._orig_month_of_year,
day_of_week=schedule._orig_day_of_week)
except ProgrammingError as e:
# If this is called before migrations are run hand back the default schedule
# These offsets are stored in a Singleton Model,
logger.error(e)
return schedule
except Exception as e:
# We absolutely cant fail to hand back a schedule
logger.error(e)
return schedule

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-05 00:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_css', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='customcss',
name='css',
field=models.TextField(blank=True, default='', help_text='This CSS will be added to the site after the default CSS.', verbose_name='Your custom CSS'),
),
]

View File

@@ -22,7 +22,6 @@ class CustomCSS(SingletonModel):
css = models.TextField( css = models.TextField(
blank=True, blank=True,
verbose_name=_("Your custom CSS"), verbose_name=_("Your custom CSS"),
default="",
help_text=_("This CSS will be added to the site after the default CSS."), help_text=_("This CSS will be added to the site after the default CSS."),
) )
timestamp = models.DateTimeField(auto_now=True) timestamp = models.DateTimeField(auto_now=True)
@@ -46,7 +45,7 @@ class CustomCSS(SingletonModel):
return str(_("Custom CSS")) return str(_("Custom CSS"))
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs):
""" """
Save method for CustomCSS Save method for CustomCSS
@@ -62,7 +61,9 @@ class CustomCSS(SingletonModel):
if self.css and len(self.css.replace(" ", "")) > 0: if self.css and len(self.css.replace(" ", "")) > 0:
# Write the custom CSS to a file # Write the custom CSS to a file
custom_css_file = open(f"{settings.STATIC_ROOT}allianceauth/custom-styles.css", "w+") custom_css_file = open(
f"{settings.STATIC_ROOT}allianceauth/custom-styles.css", "w+"
)
custom_css_file.write(self.compress_css()) custom_css_file.write(self.compress_css())
custom_css_file.close() custom_css_file.close()
else: else:
@@ -104,7 +105,9 @@ class CustomCSS(SingletonModel):
) )
# Fragment values can loose zeros # Fragment values can loose zeros
css = re.sub(pattern=r":\s*0(\.\d+([cm]m|e[mx]|in|p[ctx]))\s*;", repl=r":\1;", string=css) css = re.sub(
pattern=r":\s*0(\.\d+([cm]m|e[mx]|in|p[ctx]))\s*;", repl=r":\1;", string=css
)
for rule in re.findall(pattern=r"([^{]+){([^}]*)}", string=css): for rule in re.findall(pattern=r"([^{]+){([^}]*)}", string=css):
# We don't need spaces around operators # We don't need spaces around operators

View File

@@ -8,6 +8,7 @@ from pathlib import Path
# Django # Django
from django.conf import settings from django.conf import settings
from django.template.defaulttags import register from django.template.defaulttags import register
from django.templatetags.static import static
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from allianceauth.custom_css.models import CustomCSS from allianceauth.custom_css.models import CustomCSS
@@ -19,7 +20,7 @@ def custom_css_static(path: str) -> str:
Versioned static URL Versioned static URL
This is to make sure to break the browser cache on CSS updates. This is to make sure to break the browser cache on CSS updates.
Example: /static/allianceauth/custom-styles.css?v=1752004819.555084 Example: /static/allianceauth/custom-styles.css?v=1234567890
:param path: :param path:
:type path: :type path:
@@ -41,6 +42,7 @@ def custom_css_static(path: str) -> str:
custom_css_version = ( custom_css_version = (
str(custom_css_changed).replace(" ", "").replace(":", "").replace("-", "") str(custom_css_changed).replace(" ", "").replace(":", "").replace("-", "")
) # remove spaces, colons, and dashes ) # remove spaces, colons, and dashes
versioned_url = f"{settings.STATIC_URL}{path}?v={custom_css_version}" static_url = static(path)
versioned_url = static_url + "?v=" + custom_css_version
return mark_safe(f'<link rel="stylesheet" href="{versioned_url}">') return mark_safe(f'<link rel="stylesheet" href="{versioned_url}">')

View File

@@ -49,8 +49,8 @@ class EveFactionForm(EveEntityForm):
def clean_id(self): def clean_id(self):
try: try:
assert self.Meta.model.provider.get_faction(self.cleaned_data['id']) assert self.Meta.model.provider.get_faction(self.cleaned_data['id'])
except (AssertionError, ObjectNotFound) as e: except (AssertionError, ObjectNotFound):
raise EveEntityNotFoundError('faction', self.cleaned_data['id']) from e raise EveEntityNotFoundError('faction', self.cleaned_data['id'])
if self.Meta.model.objects.filter(faction_id=self.cleaned_data['id']).exists(): if self.Meta.model.objects.filter(faction_id=self.cleaned_data['id']).exists():
raise EveEntityExistsError('faction', self.cleaned_data['id']) raise EveEntityExistsError('faction', self.cleaned_data['id'])
return self.cleaned_data['id'] return self.cleaned_data['id']
@@ -70,8 +70,8 @@ class EveCharacterForm(EveEntityForm):
def clean_id(self): def clean_id(self):
try: try:
assert self.Meta.model.provider.get_character(self.cleaned_data['id']) assert self.Meta.model.provider.get_character(self.cleaned_data['id'])
except (AssertionError, ObjectNotFound) as e: except (AssertionError, ObjectNotFound):
raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id']) from e raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id'])
if self.Meta.model.objects.filter(character_id=self.cleaned_data['id']).exists(): if self.Meta.model.objects.filter(character_id=self.cleaned_data['id']).exists():
raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id']) raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id'])
return self.cleaned_data['id'] return self.cleaned_data['id']
@@ -90,8 +90,8 @@ class EveCorporationForm(EveEntityForm):
def clean_id(self): def clean_id(self):
try: try:
assert self.Meta.model.provider.get_corporation(self.cleaned_data['id']) assert self.Meta.model.provider.get_corporation(self.cleaned_data['id'])
except (AssertionError, ObjectNotFound) as e: except (AssertionError, ObjectNotFound):
raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id']) from e raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id'])
if self.Meta.model.objects.filter(corporation_id=self.cleaned_data['id']).exists(): if self.Meta.model.objects.filter(corporation_id=self.cleaned_data['id']).exists():
raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id']) raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id'])
return self.cleaned_data['id'] return self.cleaned_data['id']
@@ -110,8 +110,8 @@ class EveAllianceForm(EveEntityForm):
def clean_id(self): def clean_id(self):
try: try:
assert self.Meta.model.provider.get_alliance(self.cleaned_data['id']) assert self.Meta.model.provider.get_alliance(self.cleaned_data['id'])
except (AssertionError, ObjectNotFound) as e: except (AssertionError, ObjectNotFound):
raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id']) from e raise EveEntityNotFoundError(self.entity_type_name, self.cleaned_data['id'])
if self.Meta.model.objects.filter(alliance_id=self.cleaned_data['id']).exists(): if self.Meta.model.objects.filter(alliance_id=self.cleaned_data['id']).exists():
raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id']) raise EveEntityExistsError(self.entity_type_name, self.cleaned_data['id'])
return self.cleaned_data['id'] return self.cleaned_data['id']

View File

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

View File

@@ -1,11 +1,9 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class EveAutogroupsConfig(AppConfig): class EveAutogroupsConfig(AppConfig):
name = 'allianceauth.eveonline.autogroups' name = 'allianceauth.eveonline.autogroups'
label = 'eve_autogroups' label = 'eve_autogroups'
verbose_name = _('EVE Online Autogroups')
def ready(self): def ready(self):
pass pass

View File

@@ -1,7 +1,6 @@
# Generated by Django 5.1.6 on 2025-03-05 02:08 # Generated by Django 1.11.6 on 2017-12-23 04:30
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -10,9 +9,9 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('authentication', '0015_user_profiles'),
('authentication', '0025_v5squash'), ('auth', '0008_alter_user_username_max_length'),
('eveonline', '0019_v5squash'), ('eveonline', '0009_on_delete'),
] ]
operations = [ operations = [
@@ -28,16 +27,27 @@ class Migration(migrations.Migration):
('alliance_name_source', models.CharField(choices=[('ticker', 'Ticker'), ('name', 'Full name')], default='name', max_length=20)), ('alliance_name_source', models.CharField(choices=[('ticker', 'Ticker'), ('name', 'Full name')], default='name', max_length=20)),
('replace_spaces', models.BooleanField(default=False)), ('replace_spaces', models.BooleanField(default=False)),
('replace_spaces_with', models.CharField(blank=True, default='', help_text='Any spaces in the group name will be replaced with this.', max_length=10)), ('replace_spaces_with', models.CharField(blank=True, default='', help_text='Any spaces in the group name will be replaced with this.', max_length=10)),
('states', models.ManyToManyField(related_name='autogroups', to='authentication.state')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='ManagedAllianceGroup', name='ManagedAllianceGroup',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('alliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.eveallianceinfo')), ('alliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.EveAllianceInfo')),
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eve_autogroups.autogroupsconfig')), ('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eve_autogroups.AutogroupsConfig')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ManagedCorpGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eve_autogroups.AutogroupsConfig')),
('corp', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.EveCorporationInfo')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@@ -46,23 +56,16 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='autogroupsconfig', model_name='autogroupsconfig',
name='alliance_managed_groups', name='alliance_managed_groups',
field=models.ManyToManyField(help_text="A list of alliance groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='alliance_managed_config', through='eve_autogroups.ManagedAllianceGroup', to='auth.group'), field=models.ManyToManyField(help_text="A list of alliance groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='alliance_managed_config', through='eve_autogroups.ManagedAllianceGroup', to='auth.Group'),
),
migrations.CreateModel(
name='ManagedCorpGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eve_autogroups.autogroupsconfig')),
('corp', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.evecorporationinfo')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')),
],
options={
'abstract': False,
},
), ),
migrations.AddField( migrations.AddField(
model_name='autogroupsconfig', model_name='autogroupsconfig',
name='corp_managed_groups', name='corp_managed_groups',
field=models.ManyToManyField(help_text="A list of corporation groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='corp_managed_config', through='eve_autogroups.ManagedCorpGroup', to='auth.group'), field=models.ManyToManyField(help_text="A list of corporation groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='corp_managed_config', through='eve_autogroups.ManagedCorpGroup', to='auth.Group'),
),
migrations.AddField(
model_name='autogroupsconfig',
name='states',
field=models.ManyToManyField(related_name='autogroups', to='authentication.State'),
), ),
] ]

View File

@@ -1,6 +1,5 @@
import logging import logging
from typing import ClassVar
from django.db import models, transaction
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import models, transaction from django.db import models, transaction
@@ -40,13 +39,13 @@ class AutogroupsConfigManager(models.Manager):
""" """
if state is None: if state is None:
state = user.profile.state state = user.profile.state
for config in self.filter(states=state):
# grant user new groups for their state
config.update_group_membership_for_user(user)
for config in self.exclude(states=state): for config in self.exclude(states=state):
# ensure user does not have groups from previous state # ensure user does not have groups from previous state
config.remove_user_from_alliance_groups(user) config.remove_user_from_alliance_groups(user)
config.remove_user_from_corp_groups(user) config.remove_user_from_corp_groups(user)
for config in self.filter(states=state):
# grant user new groups for their state
config.update_group_membership_for_user(user)
class AutogroupsConfig(models.Model): class AutogroupsConfig(models.Model):
@@ -80,25 +79,25 @@ class AutogroupsConfig(models.Model):
max_length=10, default='', blank=True, max_length=10, default='', blank=True,
help_text='Any spaces in the group name will be replaced with this.') help_text='Any spaces in the group name will be replaced with this.')
objects: ClassVar[AutogroupsConfigManager] = AutogroupsConfigManager() objects = AutogroupsConfigManager()
def __str__(self) -> str: def __str__(self):
return 'States: ' + (' '.join(list(self.states.all().values_list('name', flat=True))) if self.pk else str(None)) return 'States: ' + (' '.join(list(self.states.all().values_list('name', flat=True))) if self.pk else str(None))
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def __repr__(self) -> str: def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__
def update_all_states_group_membership(self) -> None: def update_all_states_group_membership(self):
list(map(self.update_group_membership_for_state, self.states.all())) list(map(self.update_group_membership_for_state, self.states.all()))
def update_group_membership_for_state(self, state: State): def update_group_membership_for_state(self, state: State):
list(map(self.update_group_membership_for_user, get_users_for_state(state))) list(map(self.update_group_membership_for_user, get_users_for_state(state)))
@transaction.atomic @transaction.atomic
def update_group_membership_for_user(self, user: User) -> None: def update_group_membership_for_user(self, user: User):
self.update_alliance_group_membership(user) self.update_alliance_group_membership(user)
self.update_corp_group_membership(user) self.update_corp_group_membership(user)
@@ -236,10 +235,9 @@ class ManagedGroup(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def __str__(self) -> str: def __str__(self):
return f"Managed Group: {self.group.name}" return f"Managed Group: {self.group.name}"
class ManagedCorpGroup(ManagedGroup): class ManagedCorpGroup(ManagedGroup):
corp = models.ForeignKey(EveCorporationInfo, on_delete=models.CASCADE) corp = models.ForeignKey(EveCorporationInfo, on_delete=models.CASCADE)

View File

@@ -1,4 +1,3 @@
from allianceauth.eveonline.models import EveCorporationInfo
from django.test import TestCase from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
@@ -75,51 +74,3 @@ class AutogroupsConfigManagerTestCase(TestCase):
AutogroupsConfig.objects.update_groups_for_user(member) AutogroupsConfig.objects.update_groups_for_user(member)
self.assertTrue(update_groups.called) self.assertTrue(update_groups.called)
def test_update_group_membership_corp_in_two_configs(self):
# given
member = AuthUtils.create_member('test member')
AuthUtils.add_main_character_2(
member,
character_id='1234',
name='test character',
corp_id='2345',
corp_name='corp name',
corp_ticker='TIKK',
)
corp = EveCorporationInfo.objects.create(
corporation_id='2345',
corporation_name='corp name',
corporation_ticker='TIKK',
member_count=10,
)
member_state = AuthUtils.get_member_state()
member_config = AutogroupsConfig.objects.create(corp_groups=True)
member_config.states.add(member_state)
blue_state = AuthUtils.get_blue_state()
blue_state.member_corporations.add(corp)
blue_config = AutogroupsConfig.objects.create(corp_groups=True)
blue_config.states.add(blue_state)
member.profile.state = blue_state
member.profile.save()
AutogroupsConfig.objects.update_groups_for_user(member)
# Checks before test that the role is correctly applied
group = blue_config.get_corp_group(corp)
self.assertIn(group, member.groups.all())
# when
blue_state.member_corporations.remove(corp)
member_state.member_corporations.add(corp)
member.profile.state = member_state
member.profile.save()
# then
AutogroupsConfig.objects.update_groups_for_user(member)
group = member_config.get_corp_group(corp)
self.assertIn(group, member.groups.all())

View File

@@ -4,7 +4,7 @@ from urllib.parse import quote, urljoin
from . import _ESI_CATEGORY_ALLIANCE, _ESI_CATEGORY_CORPORATION, _ESI_CATEGORY_REGION, _ESI_CATEGORY_SOLARSYSTEM from . import _ESI_CATEGORY_ALLIANCE, _ESI_CATEGORY_CORPORATION, _ESI_CATEGORY_REGION, _ESI_CATEGORY_SOLARSYSTEM
_BASE_URL = 'https://evemaps.dotlan.net' _BASE_URL = 'http://evemaps.dotlan.net'
def _build_url(category: str, name: str) -> str: def _build_url(category: str, name: str) -> str:

View File

@@ -29,29 +29,29 @@ class TestDotlan(TestCase):
def test_alliance_url(self): def test_alliance_url(self):
self.assertEqual( self.assertEqual(
dotlan.alliance_url('Wayne Enterprices'), dotlan.alliance_url('Wayne Enterprices'),
'https://evemaps.dotlan.net/alliance/Wayne_Enterprices' 'http://evemaps.dotlan.net/alliance/Wayne_Enterprices'
) )
def test_corporation_url(self): def test_corporation_url(self):
self.assertEqual( self.assertEqual(
dotlan.corporation_url('Wayne Technology'), dotlan.corporation_url('Wayne Technology'),
'https://evemaps.dotlan.net/corp/Wayne_Technology' 'http://evemaps.dotlan.net/corp/Wayne_Technology'
) )
self.assertEqual( self.assertEqual(
dotlan.corporation_url('Crédit Agricole'), dotlan.corporation_url('Crédit Agricole'),
'https://evemaps.dotlan.net/corp/Cr%C3%A9dit_Agricole' 'http://evemaps.dotlan.net/corp/Cr%C3%A9dit_Agricole'
) )
def test_region_url(self): def test_region_url(self):
self.assertEqual( self.assertEqual(
dotlan.region_url('Black Rise'), dotlan.region_url('Black Rise'),
'https://evemaps.dotlan.net/map/Black_Rise' 'http://evemaps.dotlan.net/map/Black_Rise'
) )
def test_solar_system_url(self): def test_solar_system_url(self):
self.assertEqual( self.assertEqual(
dotlan.solar_system_url('Jita'), dotlan.solar_system_url('Jita'),
'https://evemaps.dotlan.net/system/Jita' 'http://evemaps.dotlan.net/system/Jita'
) )

View File

@@ -15,20 +15,10 @@ class EveCharacterProviderManager:
class EveCharacterManager(models.Manager): class EveCharacterManager(models.Manager):
provider = EveCharacterProviderManager() provider = EveCharacterProviderManager()
def exclude_biomassed(self): def create_character(self, character_id):
"""
Get a queryset of EveCharacter objects, excluding the "Doomheim" corporation (1000001).
:return:
:rtype:
"""
return self.exclude(corporation_id=1000001)
def create_character(self, character_id) -> models.Model:
return self.create_character_obj(self.provider.get_character(character_id)) return self.create_character_obj(self.provider.get_character(character_id))
def create_character_obj(self, character: providers.Character) -> models.Model: def create_character_obj(self, character: providers.Character):
return self.create( return self.create(
character_id=character.id, character_id=character.id,
character_name=character.name, character_name=character.name,

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-04 01:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0017_alliance_and_corp_names_are_not_unique'),
]
operations = [
migrations.AlterField(
model_name='evecharacter',
name='alliance_name',
field=models.CharField(blank=True, default='', max_length=254, null=True),
),
migrations.AlterField(
model_name='evecharacter',
name='alliance_ticker',
field=models.CharField(blank=True, default='', max_length=5, null=True),
),
migrations.AlterField(
model_name='evecharacter',
name='faction_name',
field=models.CharField(blank=True, default='', max_length=254, null=True),
),
]

View File

@@ -1,73 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-05 02:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [('eveonline', '0001_initial'), ('eveonline', '0002_remove_eveapikeypair_error_count'), ('eveonline', '0003_auto_20161026_0149'), ('eveonline', '0004_eveapikeypair_sso_verified'), ('eveonline', '0005_remove_eveallianceinfo_member_count'), ('eveonline', '0006_allow_null_evecharacter_alliance'), ('eveonline', '0007_unique_id_name'), ('eveonline', '0008_remove_apikeys'), ('eveonline', '0009_on_delete'), ('eveonline', '0010_alliance_ticker'), ('eveonline', '0011_ids_to_integers'), ('eveonline', '0012_index_additions'), ('eveonline', '0013_evecorporationinfo_ceo_id'), ('eveonline', '0014_auto_20210105_1413'), ('eveonline', '0015_factions'), ('eveonline', '0016_character_names_are_not_unique'), ('eveonline', '0017_alliance_and_corp_names_are_not_unique'), ('eveonline', '0018_alter_evecharacter_alliance_name_and_more')]
initial = True
dependencies = [
('auth', '__first__'),
]
operations = [
migrations.CreateModel(
name='EveAllianceInfo',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('alliance_id', models.PositiveIntegerField(unique=True)),
('alliance_name', models.CharField(db_index=True, max_length=254)),
('alliance_ticker', models.CharField(max_length=254)),
('executor_corp_id', models.PositiveIntegerField()),
],
options={
'indexes': [models.Index(fields=['executor_corp_id'], name='eveonline_e_executo_7f3280_idx')],
},
),
migrations.CreateModel(
name='EveFactionInfo',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('faction_id', models.PositiveIntegerField(db_index=True, unique=True)),
('faction_name', models.CharField(max_length=254, unique=True)),
],
),
migrations.CreateModel(
name='EveCorporationInfo',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('corporation_id', models.PositiveIntegerField(unique=True)),
('corporation_name', models.CharField(db_index=True, max_length=254)),
('corporation_ticker', models.CharField(max_length=254)),
('member_count', models.IntegerField()),
('alliance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='eveonline.eveallianceinfo')),
('ceo_id', models.PositiveIntegerField(blank=True, default=None, null=True)),
],
options={
'indexes': [models.Index(fields=['ceo_id'], name='eveonline_e_ceo_id_eea7b8_idx')],
},
),
migrations.CreateModel(
name='EveCharacter',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('character_id', models.PositiveIntegerField(unique=True)),
('character_name', models.CharField(db_index=True, max_length=254)),
('corporation_id', models.PositiveIntegerField()),
('corporation_name', models.CharField(max_length=254)),
('corporation_ticker', models.CharField(max_length=5)),
('alliance_id', models.PositiveIntegerField(blank=True, default=None, null=True)),
('alliance_name', models.CharField(blank=True, default='', max_length=254, null=True)),
('alliance_ticker', models.CharField(blank=True, default='', max_length=5, null=True)),
('faction_id', models.PositiveIntegerField(blank=True, default=None, null=True)),
('faction_name', models.CharField(blank=True, default='', max_length=254, null=True)),
],
options={
'indexes': [models.Index(fields=['corporation_id'], name='eveonline_e_corpora_cb4cd9_idx'), models.Index(fields=['alliance_id'], name='eveonline_e_allianc_39ee2a_idx'), models.Index(fields=['corporation_name'], name='eveonline_e_corpora_893c60_idx'), models.Index(fields=['alliance_name'], name='eveonline_e_allianc_63fd98_idx'), models.Index(fields=['faction_id'], name='eveonline_e_faction_d5274e_idx')],
},
),
]

View File

@@ -1,5 +1,4 @@
import logging import logging
from typing import ClassVar
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import models from django.db import models
@@ -33,7 +32,7 @@ class EveFactionInfo(models.Model):
provider = providers.provider provider = providers.provider
def __str__(self) -> str: def __str__(self):
return self.faction_name return self.faction_name
@staticmethod @staticmethod
@@ -76,16 +75,14 @@ class EveAllianceInfo(models.Model):
alliance_ticker = models.CharField(max_length=254) alliance_ticker = models.CharField(max_length=254)
executor_corp_id = models.PositiveIntegerField() executor_corp_id = models.PositiveIntegerField()
objects: ClassVar[EveAllianceManager] = EveAllianceManager() objects = EveAllianceManager()
provider: ClassVar[EveAllianceProviderManager] = EveAllianceProviderManager() provider = EveAllianceProviderManager()
class Meta: class Meta:
indexes = [models.Index(fields=['executor_corp_id',])] indexes = [models.Index(fields=['executor_corp_id',])]
def __str__(self):
def __str__(self) -> str:
return self.alliance_name return self.alliance_name
def populate_alliance(self):
def populate_alliance(self) -> None:
alliance = self.provider.get_alliance(self.alliance_id) alliance = self.provider.get_alliance(self.alliance_id)
for corp_id in alliance.corp_ids: for corp_id in alliance.corp_ids:
if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists(): if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists():
@@ -104,6 +101,8 @@ class EveAllianceInfo(models.Model):
self.save() self.save()
return self return self
@staticmethod @staticmethod
def generic_logo_url( def generic_logo_url(
alliance_id: int, size: int = _DEFAULT_IMAGE_SIZE alliance_id: int, size: int = _DEFAULT_IMAGE_SIZE
@@ -148,15 +147,13 @@ class EveCorporationInfo(models.Model):
EveAllianceInfo, blank=True, null=True, on_delete=models.SET_NULL EveAllianceInfo, blank=True, null=True, on_delete=models.SET_NULL
) )
objects: ClassVar[EveCorporationManager] = EveCorporationManager() objects = EveCorporationManager()
provider = EveCorporationProviderManager() provider = EveCorporationProviderManager()
class Meta: class Meta:
indexes = [models.Index(fields=['ceo_id',]),] indexes = [models.Index(fields=['ceo_id',]),]
def __str__(self):
def __str__(self) -> str:
return self.corporation_name return self.corporation_name
def update_corporation(self, corp: providers.Corporation = None): def update_corporation(self, corp: providers.Corporation = None):
if corp is None: if corp is None:
corp = self.provider.get_corporation(self.corporation_id) corp = self.provider.get_corporation(self.corporation_id)
@@ -169,6 +166,8 @@ class EveCorporationInfo(models.Model):
self.save() self.save()
return self return self
@staticmethod @staticmethod
def generic_logo_url( def generic_logo_url(
corporation_id: int, size: int = _DEFAULT_IMAGE_SIZE corporation_id: int, size: int = _DEFAULT_IMAGE_SIZE
@@ -210,12 +209,12 @@ class EveCharacter(models.Model):
corporation_name = models.CharField(max_length=254) corporation_name = models.CharField(max_length=254)
corporation_ticker = models.CharField(max_length=5) corporation_ticker = models.CharField(max_length=5)
alliance_id = models.PositiveIntegerField(blank=True, null=True, default=None) alliance_id = models.PositiveIntegerField(blank=True, null=True, default=None)
alliance_name = models.CharField(max_length=254, blank=True, null=True, default='') # noqa: DJ001 alliance_name = models.CharField(max_length=254, blank=True, default='')
alliance_ticker = models.CharField(max_length=5, blank=True, null=True, default='') # noqa: DJ001 alliance_ticker = models.CharField(max_length=5, blank=True, default='')
faction_id = models.PositiveIntegerField(blank=True, null=True, default=None) faction_id = models.PositiveIntegerField(blank=True, default=None)
faction_name = models.CharField(max_length=254, blank=True, null=True, default='') # noqa: DJ001 faction_name = models.CharField(max_length=254, blank=True, default='')
objects: ClassVar[EveCharacterManager] = EveCharacterManager() objects = EveCharacterManager()
provider = EveCharacterProviderManager() provider = EveCharacterProviderManager()
class Meta: class Meta:
@@ -227,7 +226,7 @@ class EveCharacter(models.Model):
models.Index(fields=['faction_id',]), models.Index(fields=['faction_id',]),
] ]
def __str__(self) -> str: def __str__(self):
return self.character_name return self.character_name
@property @property

View File

@@ -1,8 +1,6 @@
import logging import logging
import os import os
from typing import Literal
from bravado.client import SwaggerClient
from bravado.exception import HTTPError, HTTPNotFound, HTTPUnprocessableEntity from bravado.exception import HTTPError, HTTPNotFound, HTTPUnprocessableEntity
from jsonschema.exceptions import RefResolutionError from jsonschema.exceptions import RefResolutionError
@@ -10,7 +8,7 @@ from django.conf import settings
from esi.clients import esi_client_factory from esi.clients import esi_client_factory
from allianceauth import __version__, __title_useragent__, __url__ from allianceauth import __version__
from allianceauth.utils.django import StartupCommand from allianceauth.utils.django import StartupCommand
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname( SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(
@@ -38,7 +36,7 @@ class ObjectNotFound(Exception):
self.id = obj_id self.id = obj_id
self.type = type_name self.type = type_name
def __str__(self) -> str: def __str__(self):
return f'{self.type} with ID {self.id} not found.' return f'{self.type} with ID {self.id} not found.'
@@ -48,13 +46,13 @@ class Entity:
self.id = id self.id = id
self.name = name self.name = name
def __str__(self) -> str: def __str__(self):
return self.name return self.name
def __repr__(self) -> str: def __repr__(self):
return f"<{self.__class__.__name__} ({self.id}): {self.name}>" return f"<{self.__class__.__name__} ({self.id}): {self.name}>"
def __bool__(self) -> bool: def __bool__(self):
return bool(self.id) return bool(self.id)
def __eq__(self, other): def __eq__(self, other):
@@ -177,11 +175,7 @@ class EveProvider:
class EveSwaggerProvider(EveProvider): class EveSwaggerProvider(EveProvider):
def __init__(self, token=None, adapter=None) -> None: def __init__(self, token=None, adapter=None):
self._token = token
self.adapter = adapter or self
self._faction_list = None # what are the odds this will change? could cache forever!
if settings.DEBUG or StartupCommand().is_management_command: if settings.DEBUG or StartupCommand().is_management_command:
self._client = None self._client = None
logger.info('ESI client will be loaded on-demand') logger.info('ESI client will be loaded on-demand')
@@ -191,9 +185,7 @@ class EveSwaggerProvider(EveProvider):
self._client = esi_client_factory( self._client = esi_client_factory(
token=token, token=token,
spec_file=SWAGGER_SPEC_PATH, spec_file=SWAGGER_SPEC_PATH,
ua_appname=__title_useragent__, app_info_text=f"allianceauth v{__version__}"
ua_version=__version__,
ua_url=__url__
) )
except (HTTPError, RefResolutionError): except (HTTPError, RefResolutionError):
logger.exception( logger.exception(
@@ -202,19 +194,19 @@ class EveSwaggerProvider(EveProvider):
) )
self._client = None self._client = None
self._token = token
self.adapter = adapter or self
self._faction_list = None # what are the odds this will change? could cache forever!
@property @property
def client(self) -> SwaggerClient: def client(self):
if self._client is None: if self._client is None:
self._client = esi_client_factory( self._client = esi_client_factory(
token=self._token, token=self._token, spec_file=SWAGGER_SPEC_PATH, app_info_text=("allianceauth v" + __version__)
spec_file=SWAGGER_SPEC_PATH,
ua_appname=__title_useragent__,
ua_version=__version__,
ua_url=__url__
) )
return self._client return self._client
def __str__(self) -> str: def __str__(self):
return 'esi' return 'esi'
def get_alliance(self, alliance_id: int) -> Alliance: def get_alliance(self, alliance_id: int) -> Alliance:
@@ -231,8 +223,8 @@ class EveSwaggerProvider(EveProvider):
faction_id=data['faction_id'] if 'faction_id' in data else None, faction_id=data['faction_id'] if 'faction_id' in data else None,
) )
return model return model
except HTTPNotFound as e: except HTTPNotFound:
raise ObjectNotFound(alliance_id, 'alliance') from e raise ObjectNotFound(alliance_id, 'alliance')
def get_corp(self, corp_id: int) -> Corporation: def get_corp(self, corp_id: int) -> Corporation:
"""Fetch corporation from ESI.""" """Fetch corporation from ESI."""
@@ -248,8 +240,8 @@ class EveSwaggerProvider(EveProvider):
faction_id=data['faction_id'] if 'faction_id' in data else None, faction_id=data['faction_id'] if 'faction_id' in data else None,
) )
return model return model
except HTTPNotFound as e: except HTTPNotFound:
raise ObjectNotFound(corp_id, 'corporation') from e raise ObjectNotFound(corp_id, 'corporation')
def get_character(self, character_id: int) -> Character: def get_character(self, character_id: int) -> Character:
"""Fetch character from ESI.""" """Fetch character from ESI."""
@@ -264,8 +256,8 @@ class EveSwaggerProvider(EveProvider):
faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None, faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None,
) )
return model return model
except (HTTPNotFound, HTTPUnprocessableEntity, ObjectNotFound) as e: except (HTTPNotFound, HTTPUnprocessableEntity, ObjectNotFound):
raise ObjectNotFound(character_id, 'character') from e raise ObjectNotFound(character_id, 'character')
def _fetch_character_name(self, character_id: int) -> str: def _fetch_character_name(self, character_id: int) -> str:
"""Fetch character name from ESI.""" """Fetch character name from ESI."""
@@ -296,16 +288,16 @@ class EveSwaggerProvider(EveProvider):
return Entity(id=f['faction_id'], name=f['name']) return Entity(id=f['faction_id'], name=f['name'])
else: else:
raise KeyError() raise KeyError()
except (HTTPNotFound, HTTPUnprocessableEntity, KeyError) as e: except (HTTPNotFound, HTTPUnprocessableEntity, KeyError):
raise ObjectNotFound(faction_id, 'faction') from e raise ObjectNotFound(faction_id, 'faction')
def get_itemtype(self, type_id: int) -> ItemType: def get_itemtype(self, type_id: int) -> ItemType:
"""Fetch inventory item from ESI.""" """Fetch inventory item from ESI."""
try: try:
data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result() data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result()
return ItemType(id=type_id, name=data['name']) return ItemType(id=type_id, name=data['name'])
except (HTTPNotFound, HTTPUnprocessableEntity) as e: except (HTTPNotFound, HTTPUnprocessableEntity):
raise ObjectNotFound(type_id, 'type') from e raise ObjectNotFound(type_id, 'type')
provider = EveSwaggerProvider() provider = EveSwaggerProvider()

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
import logging import logging
from random import randint
from celery import shared_task from celery import shared_task
@@ -9,8 +8,7 @@ from .models import EveAllianceInfo, EveCharacter, EveCorporationInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TASK_PRIORITY = 7 TASK_PRIORITY = 7
CHARACTER_AFFILIATION_CHUNK_SIZE = 500 CHUNK_SIZE = 500
EVEONLINE_TASK_JITTER = 600
def chunks(lst, n): def chunks(lst, n):
@@ -20,13 +18,13 @@ def chunks(lst, n):
@shared_task @shared_task
def update_corp(corp_id: int) -> None: def update_corp(corp_id):
"""Update given corporation from ESI""" """Update given corporation from ESI"""
EveCorporationInfo.objects.update_corporation(corp_id) EveCorporationInfo.objects.update_corporation(corp_id)
@shared_task @shared_task
def update_alliance(alliance_id: int) -> None: def update_alliance(alliance_id):
"""Update given alliance from ESI""" """Update given alliance from ESI"""
EveAllianceInfo.objects.update_alliance(alliance_id).populate_alliance() EveAllianceInfo.objects.update_alliance(alliance_id).populate_alliance()
@@ -38,30 +36,23 @@ def update_character(character_id: int) -> None:
@shared_task @shared_task
def run_model_update() -> None: def run_model_update():
"""Update all alliances, corporations and characters from ESI""" """Update all alliances, corporations and characters from ESI"""
# Queue update tasks for Known Corporation Models #update existing corp models
for corp in EveCorporationInfo.objects.all().values('corporation_id'): for corp in EveCorporationInfo.objects.all().values('corporation_id'):
update_corp.apply_async( update_corp.apply_async(args=[corp['corporation_id']], priority=TASK_PRIORITY)
args=[corp['corporation_id']],
priority=TASK_PRIORITY,
countdown=randint(1, EVEONLINE_TASK_JITTER))
# Queue update tasks for Known Alliance Models # update existing alliance models
for alliance in EveAllianceInfo.objects.all().values('alliance_id'): for alliance in EveAllianceInfo.objects.all().values('alliance_id'):
update_alliance.apply_async( update_alliance.apply_async(args=[alliance['alliance_id']], priority=TASK_PRIORITY)
args=[alliance['alliance_id']],
priority=TASK_PRIORITY,
countdown=randint(1, EVEONLINE_TASK_JITTER))
# Queue update tasks for Known Character Models # update existing character models
character_ids = EveCharacter.objects.all().values_list('character_id', flat=True) character_ids = EveCharacter.objects.all().values_list('character_id', flat=True)
for character_ids_chunk in chunks(character_ids, CHARACTER_AFFILIATION_CHUNK_SIZE): for character_ids_chunk in chunks(character_ids, CHUNK_SIZE):
update_character_chunk.apply_async( update_character_chunk.apply_async(
args=[character_ids_chunk], args=[character_ids_chunk], priority=TASK_PRIORITY
priority=TASK_PRIORITY, )
countdown=randint(1, EVEONLINE_TASK_JITTER))
@shared_task @shared_task
@@ -76,9 +67,8 @@ def update_character_chunk(character_ids_chunk: list):
logger.info("Failed to bulk update characters. Attempting single updates") logger.info("Failed to bulk update characters. Attempting single updates")
for character_id in character_ids_chunk: for character_id in character_ids_chunk:
update_character.apply_async( update_character.apply_async(
args=[character_id], args=[character_id], priority=TASK_PRIORITY
priority=TASK_PRIORITY, )
countdown=randint(1, EVEONLINE_TASK_JITTER))
return return
affiliations = { affiliations = {
@@ -116,5 +106,5 @@ def update_character_chunk(character_ids_chunk: list):
if corp_changed or alliance_changed or name_changed: if corp_changed or alliance_changed or name_changed:
update_character.apply_async( update_character.apply_async(
args=[character.get('character_id')], args=[character.get('character_id')], priority=TASK_PRIORITY
priority=TASK_PRIORITY) )

View File

@@ -13,7 +13,7 @@ class BravadoResponseStub:
self.headers = headers if headers else {} self.headers = headers if headers else {}
self.raw_bytes = raw_bytes self.raw_bytes = raw_bytes
def __str__(self) -> str: def __str__(self):
return f"{self.status_code} {self.reason}" return f"{self.status_code} {self.reason}"
@@ -57,11 +57,11 @@ class EsiClientStub:
} }
try: try:
return BravadoOperationStub(data[int(alliance_id)]) return BravadoOperationStub(data[int(alliance_id)])
except KeyError as e: except KeyError:
response = BravadoResponseStub( response = BravadoResponseStub(
404, f"Alliance with ID {alliance_id} not found" 404, f"Alliance with ID {alliance_id} not found"
) )
raise HTTPNotFound(response) from e raise HTTPNotFound(response)
@staticmethod @staticmethod
def get_alliances_alliance_id_corporations(alliance_id): def get_alliances_alliance_id_corporations(alliance_id):
@@ -87,11 +87,11 @@ class EsiClientStub:
} }
try: try:
return BravadoOperationStub(data[int(character_id)]) return BravadoOperationStub(data[int(character_id)])
except KeyError as e: except KeyError:
response = BravadoResponseStub( response = BravadoResponseStub(
404, f"Character with ID {character_id} not found" 404, f"Character with ID {character_id} not found"
) )
raise HTTPNotFound(response) from e raise HTTPNotFound(response)
@staticmethod @staticmethod
def post_characters_affiliation(characters: list): def post_characters_affiliation(characters: list):
@@ -147,11 +147,11 @@ class EsiClientStub:
} }
try: try:
return BravadoOperationStub(data[int(corporation_id)]) return BravadoOperationStub(data[int(corporation_id)])
except KeyError as e: except KeyError:
response = BravadoResponseStub( response = BravadoResponseStub(
404, f"Corporation with ID {corporation_id} not found" 404, f"Corporation with ID {corporation_id} not found"
) )
raise HTTPNotFound(response) from e raise HTTPNotFound(response)
class Universe: class Universe:
@staticmethod @staticmethod

View File

@@ -676,6 +676,16 @@ class TestEveSwaggerProvider(TestCase):
self.assertTrue(mock_esi_client_factory.called) self.assertTrue(mock_esi_client_factory.called)
self.assertIsNotNone(my_provider._client) self.assertIsNotNone(my_provider._client)
@patch(MODULE_PATH + '.SWAGGER_SPEC_PATH', SWAGGER_OLD_SPEC_PATH)
@patch(MODULE_PATH + '.settings.DEBUG', False)
@patch('socket.socket')
def test_create_client_on_normal_startup_w_old_swagger_spec(
self, mock_socket
):
mock_socket.side_effect = Exception('Network blocked for testing')
my_provider = EveSwaggerProvider()
self.assertIsNone(my_provider._client)
@patch(MODULE_PATH + '.settings.DEBUG', True) @patch(MODULE_PATH + '.settings.DEBUG', True)
@patch(MODULE_PATH + '.esi_client_factory') @patch(MODULE_PATH + '.esi_client_factory')
def test_dont_create_client_on_debug_startup(self, mock_esi_client_factory): def test_dont_create_client_on_debug_startup(self, mock_esi_client_factory):
@@ -707,11 +717,11 @@ class TestEveSwaggerProvider(TestCase):
self.assertIsNotNone(my_provider._client) self.assertIsNotNone(my_provider._client)
self.assertEqual(my_client, 'my_client') self.assertEqual(my_client, 'my_client')
@patch(MODULE_PATH + '.__version__', '1.0.0')
def test_user_agent_header(self): def test_user_agent_header(self):
my_provider = EveSwaggerProvider() my_provider = EveSwaggerProvider()
my_client = my_provider.client my_client = my_provider.client
operation = my_client.Universe.get_universe_factions() operation = my_client.Universe.get_universe_factions()
self.assertEqual( self.assertEqual(
operation.future.request.headers['User-Agent'], operation.future.request.headers['User-Agent'], 'allianceauth v1.0.0'
f'AllianceAuth/{aa_version} (dummy@example.net; +{aa_url}) Django-ESI/{esi_version} (+{esi_url})'
) )

View File

@@ -84,7 +84,7 @@ class TestUpdateTasks(TestCase):
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
@patch('allianceauth.eveonline.providers.esi_client_factory') @patch('allianceauth.eveonline.providers.esi_client_factory')
@patch('allianceauth.eveonline.tasks.providers') @patch('allianceauth.eveonline.tasks.providers')
@patch('allianceauth.eveonline.tasks.CHARACTER_AFFILIATION_CHUNK_SIZE', 2) @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
class TestRunModelUpdate(TransactionTestCase): class TestRunModelUpdate(TransactionTestCase):
def test_should_run_updates(self, mock_providers, mock_esi_client_factory): def test_should_run_updates(self, mock_providers, mock_esi_client_factory):
# given # given
@@ -139,7 +139,7 @@ class TestRunModelUpdate(TransactionTestCase):
@patch('allianceauth.eveonline.tasks.update_character', wraps=update_character) @patch('allianceauth.eveonline.tasks.update_character', wraps=update_character)
@patch('allianceauth.eveonline.providers.esi_client_factory') @patch('allianceauth.eveonline.providers.esi_client_factory')
@patch('allianceauth.eveonline.tasks.providers') @patch('allianceauth.eveonline.tasks.providers')
@patch('allianceauth.eveonline.tasks.CHARACTER_AFFILIATION_CHUNK_SIZE', 2) @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
class TestUpdateCharacterChunk(TestCase): class TestUpdateCharacterChunk(TestCase):
@staticmethod @staticmethod
def _updated_character_ids(spy_update_character) -> set: def _updated_character_ids(spy_update_character) -> set:

View File

@@ -1,8 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class FatConfig(AppConfig): class FatConfig(AppConfig):
name = 'allianceauth.fleetactivitytracking' name = 'allianceauth.fleetactivitytracking'
label = 'fleetactivitytracking' label = 'fleetactivitytracking'
verbose_name = _('Fleet Activity Tracking')

View File

@@ -1,6 +1,6 @@
# Generated by Django 1.10.1 on 2016-09-05 22:20 # Generated by Django 1.10.1 on 2016-09-05 22:20
from datetime import datetime, timezone import datetime
from django.db import migrations, models from django.db import migrations, models
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='fatlink', model_name='fatlink',
name='fatdatetime', name='fatdatetime',
field=models.DateTimeField(default=datetime(2016, 9, 5, 22, 20, 2, 999041, tzinfo=timezone.utc)), field=models.DateTimeField(default=datetime.datetime(2016, 9, 5, 22, 20, 2, 999041, tzinfo=datetime.timezone.utc)),
), ),
] ]

View File

@@ -1,67 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-04 01:20
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
import allianceauth.framework.api.user
def create_permissions(apps, schema_editor) -> None:
# Remnant of AAv0
User = apps.get_model('auth', 'User')
ContentType = apps.get_model('contenttypes', 'ContentType')
Permission = apps.get_model('auth', 'Permission')
ct = ContentType.objects.get_for_model(User)
Permission.objects.get_or_create(codename="fleetactivitytracking", content_type=ct, name="fleetactivitytracking")
Permission.objects.get_or_create(codename="fleetactivitytracking_statistics", content_type=ct, name="fleetactivitytracking_statistics")
def reverse(apps, schema_editor) -> None:
pass
class Migration(migrations.Migration):
replaces = [('fleetactivitytracking', '0001_initial'), ('fleetactivitytracking', '0002_auto_20160905_2220'), ('fleetactivitytracking', '0003_auto_20160906_2354'), ('fleetactivitytracking', '0004_make_strings_more_stringy'), ('fleetactivitytracking', '0005_remove_fat_name'), ('fleetactivitytracking', '0006_auto_20180803_0430'), ('fleetactivitytracking', '0007_sentinel_user')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('eveonline', '0019_v5squash'),
]
operations = [
migrations.CreateModel(
name='Fatlink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('fatdatetime', models.DateTimeField(default=django.utils.timezone.now)),
('duration', models.PositiveIntegerField()),
('fleet', models.CharField(max_length=254)),
('hash', models.CharField(max_length=254, unique=True)),
('creator', models.ForeignKey(on_delete=models.SET(allianceauth.framework.api.user.get_sentinel_user), to=settings.AUTH_USER_MODEL)),
],
options={
'permissions': ()
},
),
migrations.CreateModel(
name='Fat',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('system', models.CharField(max_length=30)),
('shiptype', models.CharField(max_length=100)),
('station', models.CharField(max_length=125)),
('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.evecharacter')),
('fatlink', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleetactivitytracking.fatlink')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('character', 'fatlink')},
},
),
migrations.RunPython(create_permissions, reverse)
]

View File

@@ -13,15 +13,7 @@ class Fatlink(models.Model):
hash = models.CharField(max_length=254, unique=True) hash = models.CharField(max_length=254, unique=True)
creator = models.ForeignKey(User, on_delete=models.SET(get_sentinel_user)) creator = models.ForeignKey(User, on_delete=models.SET(get_sentinel_user))
class Meta: def __str__(self):
permissions = (
# Intentionally Commented out
# AAv0 has these in the Auth_ Content Type
# ('fleetactivitytracking', 'fleetactivitytracking'),
# ('fleetactivitytracking_statistics', 'fleetactivitytracking_statistics'),
)
def __str__(self) -> str:
return self.fleet return self.fleet
@@ -34,7 +26,7 @@ class Fat(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta: class Meta:
unique_together = (("character", "fatlink"),) unique_together = (('character', 'fatlink'),)
def __str__(self) -> str: def __str__(self):
return f"Fat-link for {self.character.character_name}" return f"Fat-link for {self.character.character_name}"

View File

@@ -36,7 +36,7 @@
<th class="text-center">{% translate "Character" %}</th> <th class="text-center">{% translate "Character" %}</th>
<th class="text-center">{% translate "System" %}</th> <th class="text-center">{% translate "System" %}</th>
<th class="text-center">{% translate "Ship" %}</th> <th class="text-center">{% translate "Ship" %}</th>
<th class="text-center">{% translate "EVE time" %}</th> <th class="text-center">{% translate "Eve Time" %}</th>
<th></th> <th></th>
</tr> </tr>

View File

@@ -72,7 +72,7 @@
<tr> <tr>
<th class="text-center">{% translate "Fleet" %}</th> <th class="text-center">{% translate "Fleet" %}</th>
<th class="text-center">{% translate "Creator" %}</th> <th class="text-center">{% translate "Creator" %}</th>
<th class="text-center">{% translate "EVE time" %}</th> <th class="text-center">{% translate "Eve Time" %}</th>
<th class="text-center">{% translate "Duration" %}</th> <th class="text-center">{% translate "Duration" %}</th>
<th class="text-center">{% translate "Edit" %}</th> <th class="text-center">{% translate "Edit" %}</th>
</tr> </tr>
@@ -80,7 +80,7 @@
{% for link in created_fats %} {% for link in created_fats %}
<tr> <tr>
<td class="text-center"> <td class="text-center">
<a href="{% url 'fatlink:click' link.hash %}" class="badge text-bg-primary"> <a href="{% url 'fatlink:click' link.hash %}" class="badge bg-primary">
{{ link.fleet }} {{ link.fleet }}
</a> </a>
</td> </td>

View File

@@ -41,7 +41,7 @@
<th scope="col" class="text-center">{% translate "Character" %}</th> <th scope="col" class="text-center">{% translate "Character" %}</th>
<th scope="col" class="text-center">{% translate "System" %}</th> <th scope="col" class="text-center">{% translate "System" %}</th>
<th scope="col" class="text-center">{% translate "Ship" %}</th> <th scope="col" class="text-center">{% translate "Ship" %}</th>
<th scope="col" class="text-center">{% translate "EVE time" %}</th> <th scope="col" class="text-center">{% translate "Eve Time" %}</th>
</tr> </tr>
{% for fat in fats %} {% for fat in fats %}
@@ -89,7 +89,7 @@
<th scope="col" class="text-center">{% translate "Name" %}</th> <th scope="col" class="text-center">{% translate "Name" %}</th>
<th scope="col" class="text-center">{% translate "Creator" %}</th> <th scope="col" class="text-center">{% translate "Creator" %}</th>
<th scope="col" class="text-center">{% translate "Fleet" %}</th> <th scope="col" class="text-center">{% translate "Fleet" %}</th>
<th scope="col" class="text-center">{% translate "EVE time" %}</th> <th scope="col" class="text-center">{% translate "Eve Time" %}</th>
<th scope="col" class="text-center">{% translate "Duration" %}</th> <th scope="col" class="text-center">{% translate "Duration" %}</th>
<th scope="col" class="text-center">{% translate "Edit" %}</th> <th scope="col" class="text-center">{% translate "Edit" %}</th>
</tr> </tr>
@@ -97,7 +97,7 @@
{% for link in fatlinks %} {% for link in fatlinks %}
<tr> <tr>
<td class="text-center"> <td class="text-center">
<a href="{% url 'fatlink:click' link.hash %}" class="badge text-bg-primary">{{ link.fleet }}</a> <a href="{% url 'fatlink:click' link.hash %}" class="badge bg-primary">{{ link.fleet }}</a>
</td> </td>
<td class="text-center">{{ link.creator.username }}</td> <td class="text-center">{{ link.creator.username }}</td>
<td class="text-center">{{ link.fleet }}</td> <td class="text-center">{{ link.fleet }}</td>

View File

@@ -1,12 +1,13 @@
import datetime
import logging import logging
import os import os
from datetime import datetime, timedelta, timezone
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -134,7 +135,7 @@ def fatlink_statistics_corp_view(request, corpid, year=None, month=None):
stat_list.sort(key=lambda stat: (stat.n_fats, stat.avg_fat), reverse=True) stat_list.sort(key=lambda stat: (stat.n_fats, stat.avg_fat), reverse=True)
context = {'fatStats': stat_list, 'month': start_of_month.strftime("%B"), 'year': year, 'previous_month': start_of_previous_month, 'corpid': corpid} context = {'fatStats': stat_list, 'month': start_of_month.strftime("%B"), 'year': year, 'previous_month': start_of_previous_month, 'corpid': corpid}
if datetime.datetime.now(timezone.utc) > start_of_next_month: if datetime.datetime.now() > start_of_next_month:
context.update({'next_month': start_of_next_month}) context.update({'next_month': start_of_next_month})
return render(request, 'fleetactivitytracking/fatlinkstatisticscorpview.html', context=context) return render(request, 'fleetactivitytracking/fatlinkstatisticscorpview.html', context=context)
@@ -142,12 +143,7 @@ def fatlink_statistics_corp_view(request, corpid, year=None, month=None):
@login_required @login_required
@permission_required('auth.fleetactivitytracking_statistics') @permission_required('auth.fleetactivitytracking_statistics')
def fatlink_statistics_view(request, year=None, month=None): def fatlink_statistics_view(request, year=datetime.date.today().year, month=datetime.date.today().month):
if year is None:
year = datetime.date.today().year
if month is None:
month = datetime.date.today().month
year = int(year) year = int(year)
month = int(month) month = int(month)
start_of_month = datetime.datetime(year, month, 1) start_of_month = datetime.datetime(year, month, 1)
@@ -173,19 +169,16 @@ def fatlink_statistics_view(request, year=None, month=None):
stat_list.sort(key=lambda stat: (stat.n_fats, stat.avg_fat), reverse=True) stat_list.sort(key=lambda stat: (stat.n_fats, stat.avg_fat), reverse=True)
context = {'fatStats': stat_list, 'month': start_of_month.strftime("%B"), 'year': year, 'previous_month': start_of_previous_month} context = {'fatStats': stat_list, 'month': start_of_month.strftime("%B"), 'year': year, 'previous_month': start_of_previous_month}
if datetime.datetime.now(timezone.utc) > start_of_next_month: if datetime.datetime.now() > start_of_next_month:
context.update({'next_month': start_of_next_month}) context.update({'next_month': start_of_next_month})
return render(request, 'fleetactivitytracking/fatlinkstatisticsview.html', context=context) return render(request, 'fleetactivitytracking/fatlinkstatisticsview.html', context=context)
@login_required @login_required
def fatlink_personal_statistics_view(request, year=None): def fatlink_personal_statistics_view(request, year=datetime.date.today().year):
if year is None:
year = datetime.date.today().year
year = int(year) year = int(year)
logger.debug(f"Personal statistics view for year {year} called by {request.user}") logger.debug("Personal statistics view for year %i called by %s" % (year, request.user))
user = request.user user = request.user
logger.debug(f"fatlink_personal_statistics_view called by user {request.user}") logger.debug(f"fatlink_personal_statistics_view called by user {request.user}")
@@ -201,7 +194,7 @@ def fatlink_personal_statistics_view(request, year=None):
monthlystats = [(i + 1, datetime.date(year, i + 1, 1).strftime("%h"), monthlystats[i]) for i in range(12)] monthlystats = [(i + 1, datetime.date(year, i + 1, 1).strftime("%h"), monthlystats[i]) for i in range(12)]
if datetime.datetime.now(timezone.utc) > datetime.datetime(year + 1, 1, 1): if datetime.datetime.now() > datetime.datetime(year + 1, 1, 1):
context = {'user': user, 'monthlystats': monthlystats, 'year': year, 'previous_year': year - 1, 'next_year': year + 1} context = {'user': user, 'monthlystats': monthlystats, 'year': year, 'previous_year': year - 1, 'next_year': year + 1}
else: else:
context = {'user': user, 'monthlystats': monthlystats, 'year': year, 'previous_year': year - 1} context = {'user': user, 'monthlystats': monthlystats, 'year': year, 'previous_year': year - 1}
@@ -271,7 +264,7 @@ def click_fatlink_view(request, token, fat_hash=None):
if character_online["online"] is True: if character_online["online"] is True:
fatlink = get_object_or_404(Fatlink, hash=fat_hash) fatlink = get_object_or_404(Fatlink, hash=fat_hash)
if (datetime.now(timezone.utc) - fatlink.fatdatetime) < timedelta(seconds=(fatlink.duration * 60)): if (timezone.now() - fatlink.fatdatetime) < datetime.timedelta(seconds=(fatlink.duration * 60)):
if character: if character:
# get data # get data
location = c.Location.get_characters_character_id_location(character_id=token.character_id).result() location = c.Location.get_characters_character_id_location(character_id=token.character_id).result()

View File

@@ -9,39 +9,24 @@ from allianceauth.authentication.models import CharacterOwnership
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
def get_all_characters_from_user(user: User, main_first: bool = False) -> list: def get_all_characters_from_user(user: User) -> list:
""" """
Get all characters from a user Get all characters from a user or an empty list
This function retrieves all characters associated with a given user, optionally ordering them when no characters are found for the user or the user is None
with the main character first.
If the user is None, an empty list is returned.
:param user: The user whose characters are to be retrieved :param user:
:type user: User :type user:
:param main_first: If True, the main character will be listed first :return:
:type main_first: bool :rtype:
:return: A list of EveCharacter objects associated with the user
:rtype: list[EveCharacter]
""" """
if user is None: if user is None:
return [] return []
try: try:
if main_first: characters = [
characters = [ char.character for char in CharacterOwnership.objects.filter(user=user)
char.character ]
for char in CharacterOwnership.objects.filter(user=user).order_by(
"-character__userprofile", "character__character_name"
)
]
else:
characters = [
char.character
for char in CharacterOwnership.objects.filter(user=user).order_by(
"character__character_name"
)
]
except AttributeError: except AttributeError:
return [] return []

View File

@@ -3,7 +3,6 @@ Framework App Config
""" """
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class FrameworkConfig(AppConfig): class FrameworkConfig(AppConfig):
@@ -13,4 +12,3 @@ class FrameworkConfig(AppConfig):
name = "allianceauth.framework" name = "allianceauth.framework"
label = "framework" label = "framework"
verbose_name = _("Framework")

View File

@@ -5,42 +5,11 @@
* to be used throughout Alliance Auth and its Community Apps * to be used throughout Alliance Auth and its Community Apps
*/ */
/* General
------------------------------------------------------------------------------------- */
@media all {
.navbar-toggler.collapsed {
transform: rotate(180deg);
}
ul#nav-right:has(li) + ul#nav-right-character-control > li:first-child {
display: list-item !important;
}
form.is-submitting button[type="submit"] {
cursor: not-allowed;
}
}
@media all and (max-width: 991px) {
ul#nav-left:has(li) + ul#nav-right + ul#nav-right-character-control > li:first-child {
display: list-item !important;
}
}
/* Bootstrap fixes /* Bootstrap fixes
------------------------------------------------------------------------------------- */ ------------------------------------------------------------------------------------- */
@media all { @media all {
.table { .table {
--bs-table-bg: transparent !important; --bs-table-bg: transparent;
}
}
/* Image overflow fix
------------------------------------------------------------------------------------- */
@media all {
img {
max-width: 100%;
height: auto;
} }
} }
@@ -71,12 +40,15 @@
} }
/* Chevron icons */ /* Chevron icons */
#sidebar-menu span[data-bs-toggle="collapse"] > i.fa-chevron-right { #sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-down,
transition: 0.25s transform ease-in-out; #sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-right {
display: block;
width: 16px;
} }
#sidebar-menu span[data-bs-toggle="collapse"][aria-expanded="true"] > i.fa-chevron-right { #sidebar-menu [data-bs-toggle="collapse"] > i.fa-chevron-right,
transform: rotate(90deg); #sidebar-menu [data-bs-toggle="collapse"].collapsed > i.fa-chevron-down {
display: none;
} }
} }
@@ -84,47 +56,47 @@
------------------------------------------------------------------------------------- */ ------------------------------------------------------------------------------------- */
@media all { @media all {
.cursor-auto { .cursor-auto {
cursor: auto !important; cursor: auto;
} }
.cursor-default { .cursor-default {
cursor: default !important; cursor: default;
} }
.cursor-pointer { .cursor-pointer {
cursor: pointer !important; cursor: pointer;
} }
.cursor-wait { .cursor-wait {
cursor: wait !important; cursor: wait;
} }
.cursor-text { .cursor-text {
cursor: text !important; cursor: text;
} }
.cursor-move { .cursor-move {
cursor: move !important; cursor: move;
} }
.cursor-help { .cursor-help {
cursor: help !important; cursor: help;
} }
.cursor-not-allowed { .cursor-not-allowed {
cursor: not-allowed !important; cursor: not-allowed;
} }
.cursor-inherit { .cursor-inherit {
cursor: inherit !important; cursor: inherit;
} }
.cursor-zoom-in { .cursor-zoom-in {
cursor: zoom-in !important; cursor: zoom-in;
} }
.cursor-zoom-out { .cursor-zoom-out {
cursor: zoom-out !important; cursor: zoom-out;
} }
} }

View File

@@ -1,343 +0,0 @@
/**
* Functions and utilities for the Alliance Auth framework.
*/
/* jshint -W097 */
'use strict';
/**
* Checks if the given item is an array.
*
* @usage
* ```javascript
* if (isArray(someVariable)) {
* console.log('This is an array');
* } else {
* console.log('This is not an array');
* }
* ```
*
* @param {*} item - The item to check.
* @returns {boolean} True if the item is an array, false otherwise.
*/
const isArray = (item) => {
return Array.isArray(item);
};
/**
* Checks if the given item is a plain object, excluding arrays and dates.
*
* @usage
* ```javascript
* if (isObject(someVariable)) {
* console.log('This is a plain object');
* } else {
* console.log('This is not a plain object');
* }
* ```
*
* @param {*} item - The item to check.
* @returns {boolean} True if the item is a plain object, false otherwise.
*/
const isObject = (item) => {
return (
item && typeof item === 'object' && !isArray(item) && !(item instanceof Date)
);
};
/**
* Fetch data from an ajax URL
*
* Do not call this function directly, use `fetchGet` or `fetchPost` instead.
*
* @param {string} url The URL to fetch data from
* @param {string} method The HTTP method to use for the request (default: 'get')
* @param {string|null} csrfToken The CSRF token to include in the request headers (default: null)
* @param {string|null} payload The payload (JSON|Object) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @returns {Promise<string>} The fetched data
* @throws {Error} Throws an error when:
* - The method is not valid (only `get` and `post` are allowed).
* - The CSRF token is required but not provided for POST requests.
* - The payload is not an object when using POST method.
* - The response status is not OK (HTTP 200-299).
* - There is a network error or if the response cannot be parsed as JSON.
*/
const _fetchAjaxData = async ({
url,
method = 'get',
csrfToken = null,
payload = null,
responseIsJson = true
}) => {
const normalizedMethod = method.toLowerCase();
// Validate the method
const validMethods = ['get', 'post'];
if (!validMethods.includes(normalizedMethod)) {
throw new Error(`Invalid method: ${method}. Valid methods are: get, post`);
}
const headers = {};
// Set headers based on response type
if (responseIsJson) {
headers['Accept'] = 'application/json'; // jshint ignore:line
headers['Content-Type'] = 'application/json';
}
let requestUrl = url;
let body = null;
if (normalizedMethod === 'post') {
if (!csrfToken) {
throw new Error('CSRF token is required for POST requests');
}
headers['X-CSRFToken'] = csrfToken;
if (payload !== null && !isObject(payload)) {
throw new Error('Payload must be an object when using POST method');
}
body = payload ? JSON.stringify(payload) : null;
} else if (normalizedMethod === 'get' && payload) {
const queryParams = new URLSearchParams(payload).toString(); // jshint ignore:line
requestUrl += (url.includes('?') ? '&' : '?') + queryParams;
}
/**
* Throws an error with a formatted message.
*
* @param {Response} response The error object containing the message to throw.
*/
const throwHTTPStatusError = (response) => {
throw new Error(`Error: ${response.status} - ${response.statusText}`);
};
try {
const response = await fetch(requestUrl, {
method: method.toUpperCase(),
headers: headers,
body: body
});
/**
* Throws an error if the response status is not OK (HTTP 200-299).
* This is used to handle HTTP errors gracefully.
*/
if (!response.ok) {
throwHTTPStatusError(response);
}
return responseIsJson ? await response.json() : await response.text();
} catch (error) {
// Log the error message to the console
console.log(`Error: ${error.message}`);
throw error;
}
};
/**
* Fetch data from an ajax URL using the GET method.
* This function is a wrapper around _fetchAjaxData to simplify GET requests.
*
* @usage
* ```javascript
* fetchGet({
* url: url,
* responseIsJson: false
* }).then((data) => {
* // Process the fetched data
* }).catch((error) => {
* console.error(`Error: ${error.message}`);
*
* // Handle the error appropriately
* });
* ```
*
* @param {string} url The URL to fetch data from
* @param {string|null} payload The payload (JSON) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @return {Promise<string>} The fetched data
*/
const fetchGet = async ({
url,
payload = null,
responseIsJson = true
}) => {
return await _fetchAjaxData({
url: url,
method: 'get',
payload: payload,
responseIsJson: responseIsJson
});
};
/**
* Fetch data from an ajax URL using the POST method.
* This function is a wrapper around _fetchAjaxData to simplify POST requests.
* It requires a CSRF token for security purposes.
*
* @usage
* ```javascript
* fetchPost({
* url: url,
* csrfToken: csrfToken,
* payload: {
* key: 'value',
* anotherKey: 'anotherValue'
* },
* responseIsJson: true
* }).then((data) => {
* // Process the fetched data
* }).catch((error) => {
* console.error(`Error: ${error.message}`);
*
* // Handle the error appropriately
* });
* ```
*
* @param {string} url The URL to fetch data from
* @param {string|null} csrfToken The CSRF token to include in the request headers (default: null)
* @param {string|null} payload The payload (JSON) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @return {Promise<string>} The fetched data
*/
const fetchPost = async ({
url,
csrfToken,
payload = null,
responseIsJson = true
}) => {
return await _fetchAjaxData({
url: url,
method: 'post',
csrfToken: csrfToken,
payload: payload,
responseIsJson: responseIsJson
});
};
/**
* Recursively merges properties from source objects into a target object. If a property at the current level is an object,
* and both target and source have it, the property is merged. Otherwise, the source property overwrites the target property.
* This function does not modify the source objects and prevents prototype pollution by not allowing __proto__, constructor,
* and prototype property names.
*
* @usage
* ```javascript
* const target = {a: 1, b: {c: 2}};
* const source1 = {b: {d: 3}, e: 4 };
* const source2 = {a: 5, b: {c: 6}};
*
* const merged = objectDeepMerge(target, source1, source2);
*
* console.log(merged); // {a: 5, b: {c: 6, d: 3}, e: 4}
* ```
*
* @param {Object} target The target object to merge properties into.
* @param {...Object} sources One or more source objects from which to merge properties.
* @returns {Object} The target object after merging properties from sources.
*/
function objectDeepMerge (target, ...sources) {
if (!sources.length) {
return target;
}
// Iterate through each source object without modifying the `sources` array.
sources.forEach(source => {
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue; // Skip potentially dangerous keys to prevent prototype pollution.
}
if (!target[key] || !isObject(target[key])) {
target[key] = {};
}
objectDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
});
return target;
}
/**
* Formats a number according to the specified locale.
* This function uses the Intl.NumberFormat API to format the number.
*
* @usage
* In your Django template get the current language code:
* ```django
* {% get_current_language as LANGUAGE_CODE %}
* ```
* Then use it in your JavaScript:
* ```javascript
* const userLocale = '{{ LANGUAGE_CODE }}'; // e.g., 'en-US', 'de-DE'
* const number = 1234567.89;
* const formattedNumber = numberFormatter({
* value: number,
* locales: userLocale,
* options: {
* style: 'currency',
* currency: 'ISK'
* }
* });
*
* // Output will vary based on locale
* // e.g., '1,234,567.89' for 'en-US', '1.234.567,89' for 'de-DE'
* console.log(formattedNumber);
* ```
*
* @param {number} value The number to format
* @param {string | string[]} locales The locale(s) to use for formatting (e.g., 'en-US', 'de-DE', ['en-US', 'de-DE']). If not provided, the browser's default locale will be used and any language settings from the user will be ignored.
* @param {Object} [options={}] Additional options for number formatting (see `Intl.NumberFormat` documentation - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat)
* @return {string} The formatted number as a string
*/
const numberFormatter = ({value, locales, options = {}}) => {
console.log('Formatting number:', value, 'for locale(s):', locales, 'with options:', options);
const formatter = new Intl.NumberFormat(locales, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
...options
});
return formatter.format(value);
};
/**
* When the document is ready …
*/
$(document).ready(() => {
/**
* Prevent double form submits by adding a class to the form
* when it is submitted.
*
* This class can be used to show a visual indicator that the form is being
* submitted, such as a spinner.
*
* This is useful to prevent users from double-clicking the submit button
* and submitting the form multiple times.
*/
document.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', (e) => {
// Prevent if already submitting
if (form.classList.contains('is-submitting')) {
e.preventDefault();
}
// Add class to hook our visual indicator on
form.classList.add('is-submitting');
});
});
});

View File

@@ -1,105 +0,0 @@
"""
Custom static files storage for Alliance Auth.
This module defines a custom static files storage class for
Alliance Auth, named `AaManifestStaticFilesStorage`.
Using `ManifestStaticFilesStorage` will give us a hashed name for
our static files, which is useful for cache busting.
This storage class extends Django's `ManifestStaticFilesStorage` to ignore missing files,
which the original class does not handle, and log them in debug mode.
It is useful for handling cases where static files may not exist, such as when a
CSS file references a background image that is not present in the static files directory.
With debug mode enabled, it will print a message for each missing file when running `collectstatic`,
which can help identify issues with static file references during development.
"""
from django.conf import settings
from django.contrib.staticfiles.storage import ManifestStaticFilesStorage
class AaManifestStaticFilesStorage(ManifestStaticFilesStorage):
"""
Custom static files storage that ignores missing files.
"""
@classmethod
def _cleanup_name(cls, name: str) -> str:
"""
Clean up the name by removing quotes.
This method is used to ensure that the name does not contain any quotes,
which can cause issues with file paths.
:param name: The name of the static file.
:type name: str
:return: The cleaned-up name without quotes.
:rtype: str
"""
# Remove quotes from the name
return name.replace('"', "").replace("'", "")
def __init__(self, *args, **kwargs):
"""
Initialize the static files storage, ignoring missing files.
:param args:
:type args:
:param kwargs:
:type kwargs:
"""
self.missing_files = []
super().__init__(*args, **kwargs)
def hashed_name(self, name, content=None, filename=None):
"""
Generate a hashed name for the given static file, ignoring missing files.
Ignore missing files, e.g. non-existent background image referenced from css.
Returns the original filename if the referenced file doesn't exist.
:param name: The name of the static file to hash.
:type name: str
:param content: The content of the static file, if available.
:type content: bytes | None
:param filename: The original filename of the static file, if available.
:type filename: str | None
:return: The hashed name of the static file, or the original name if the file is missing.
:rtype: str
"""
try:
clean_name = self._cleanup_name(name)
return super().hashed_name(clean_name, content, filename)
except ValueError as e:
if settings.DEBUG:
# In debug mode, we log the missing file message
message = e.args[0].split(" with ")[0]
self.missing_files.append(message)
# print(f'\x1b[0;30;41m{message}\x1b[0m')
return name
def post_process(self, *args, **kwargs):
"""
Post-process the static files, printing any missing files in debug mode.
:param args:
:type args:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
yield from super().post_process(*args, **kwargs)
if settings.DEBUG:
# In debug mode, print the missing files
for message in sorted(set(self.missing_files)):
print(f"\x1b[0;30;41m{message}\x1b[0m")

View File

@@ -1,13 +0,0 @@
<li class="nav-item">
<a href="{{ url }}" class="nav-link py-lg-0">
<span class="btn btn-{{ btn_modifier|default:'primary' }} d-none d-lg-inline-block">
{% if fa_icon and icon_on_desktop %}<i class="{{ fa_icon }} me-2"></i>{% endif %}
{{ title }}
</span>
<span class="d-inline-block d-lg-none">
{% if fa_icon and icon_on_mobile %}<i class="{{ fa_icon }} fa-fw me-2"></i>{% endif %}
{{ title }}
</span>
</a>
</li>

View File

@@ -1,12 +0,0 @@
<li class="nav-item">
<a href="{{ url }}" class="nav-link">
<span class="d-none d-lg-inline-block" title="{{ title }}">
<i class="{{ fa_icon }}"></i>
</span>
<span class="d-inline-block d-lg-none">
{% if icon_on_mobile %}<i class="{{ fa_icon }} me-2 fa-fw"></i>{% endif %}
{{ title }}
</span>
</a>
</li>

View File

@@ -2,14 +2,12 @@
{# {% include "framework/header/page-header.html" with title="Foobar" subtitle="Barfoo" %}#} {# {% include "framework/header/page-header.html" with title="Foobar" subtitle="Barfoo" %}#}
{% if title %} {% if title %}
<header class="aa-page-header mb-3"> <h1 class="page-header text-center mb-3">
<h1 class="page-header text-center"> {{ title }}
{{ title }}
{% if subtitle %} {% if subtitle %}
<br> <br>
<small class="text-muted">{{ subtitle }}</small> <small class="text-muted">{{ subtitle }}</small>
{% endif %} {% endif %}
</h1> </h1>
</header>
{% endif %} {% endif %}

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