Compare commits

..

92 Commits

Author SHA1 Message Date
Joel Falknau
85f1c02e09 shift migrations to v5 squash 2025-07-07 14:11:58 +10:00
Joel Falknau
eb1b2d39c9 ignore .ice files 2025-07-07 14:09:48 +10:00
Joel Falknau
ac408dbc64 Merge branch 'v5.x' of gitlab.com:allianceauth/allianceauth into djangomumble 2025-07-07 13:59:34 +10:00
Ariel Rin
c6f49cc824 Merge branch 'servicesworker' into 'v5.x'
Singlethread Services Worker

See merge request allianceauth/allianceauth!1698
2025-07-07 03:52:08 +00:00
Ariel Rin
3e89ae20d7 Singlethread Services Worker 2025-07-07 03:52:08 +00:00
Ariel Rin
ed4157fd0c Merge branch 'v5-squash' into 'v5.x'
V5 Squash

See merge request allianceauth/allianceauth!1697
2025-07-07 02:51:11 +00:00
Ariel Rin
99f43029df V5 Squash 2025-07-07 02:51:11 +00:00
Joel Falknau
6ab2af79eb fix discord test after merge 2025-07-07 12:41:00 +10:00
Joel Falknau
196d97271c Merge branch 'master' of gitlab.com:allianceauth/allianceauth into v5.x 2025-07-04 10:02:40 +10:00
Ariel Rin
6dede0ddb5 Merge branch 'app-announcement' into 'v5.x'
Application Announcement Hook

See merge request allianceauth/allianceauth!1717
2025-06-30 23:45:41 +00:00
T'rahk Rokym
77ebe26d52 Application Announcement Hook 2025-06-30 23:45:39 +00:00
Joel Falknau
4021b2dc72 use same task name to override existing task, use offset 2025-05-24 16:35:29 +10:00
Joel Falknau
10dac36dcc Version Bump 5.0.0a3 2025-05-24 16:12:40 +10:00
Joel Falknau
0ff17de419 make markdownlint happy 2025-05-24 15:59:20 +10:00
Joel Falknau
6ee6986174 Version Bump 5.0.0a2 2025-05-24 15:54:54 +10:00
Joel Falknau
49364e7d27 test not needed, feature removed 2025-05-24 15:53:15 +10:00
Joel Falknau
f15c4fc708 keep on demand for pipeline reasons 2025-05-24 15:52:48 +10:00
Joel Falknau
6452b082a8 use new user-agent generator, drop specfile 2025-05-24 15:34:37 +10:00
Joel Falknau
daaffaeabc use token subset cleanup task from Django-ESI 7 2025-05-24 15:33:59 +10:00
Joel Falknau
95608db611 bump libs to support dj52, notably django-esi 7 2025-05-24 15:33:44 +10:00
Joel Falknau
523aac6a08 drop Ruff, i thought it was cool, kinda unnecessary 2025-05-24 15:31:54 +10:00
Joel Falknau
49a271a99f update pre-commit 2025-04-07 14:04:06 +10:00
Joel Falknau
af87da876b Celery 5.5 + shutdown timeouts 2025-04-07 14:01:33 +10:00
Joel Falknau
57b3841293 internal network port only 2025-04-07 13:57:57 +10:00
Joel Falknau
b02413c30c Type Hints 2025-03-26 13:27:01 +10:00
Joel Falknau
7ba1699dc6 Bring these in line with modern Django 2025-03-26 13:19:25 +10:00
Joel Falknau
75d67aa1b1 typehints 2025-03-26 13:18:54 +10:00
Joel Falknau
876f1e48e7 Update docs to python312, drop old OS 2025-03-06 11:28:19 +10:00
Joel Falknau
c7db4f0bd3 tox pre-commit formatter 2025-03-06 09:59:08 +10:00
Joel Falknau
826198e5a7 django.utils.timezone.utc alias is removed 2025-03-04 11:59:37 +10:00
Joel Falknau
cc8f53af12 version Bump 5.0.0a1 2025-03-03 15:24:03 +10:00
Joel Falknau
f134b17c66 thirdparty bundle telnetlib for python 3.13 2025-03-03 15:18:11 +10:00
Joel Falknau
4036b0272c dont allow py313 tests to fail 2025-03-03 14:49:29 +10:00
Joel Falknau
33b3c5b36e fix tests after precommit 2025-03-03 14:48:10 +10:00
Joel Falknau
9547826272 precommit 2025-03-03 14:15:39 +10:00
Joel Falknau
15fc38ccfd Merge branch 'master' of gitlab.com:allianceauth/allianceauth into v5.x 2025-03-03 12:52:12 +10:00
Joel Falknau
a5f2e29a46 fixes 2024-12-30 22:26:35 +10:00
Joel Falknau
8f33c649b3 cleanup 2024-12-30 20:28:54 +10:00
Joel Falknau
f6cb28d64b clarify templinks page 2024-12-30 20:28:45 +10:00
Joel Falknau
96b54d15ac shift tests 2024-12-30 20:27:47 +10:00
Joel Falknau
498b876572 begin docs for new authenticator 2024-12-30 13:29:25 +10:00
Joel Falknau
7559b7ac09 Mumble Server Parameter 2024-12-30 13:28:36 +10:00
Joel Falknau
9e39769ad3 model docs 2024-12-30 13:28:05 +10:00
Joel Falknau
d68d75bd05 use new model formats, remove temp templates 2024-12-29 21:16:59 +10:00
Joel Falknau
1385a2ef16 unify user types 2024-12-29 21:15:48 +10:00
Joel Falknau
d09892397b add TempUser to more authenticator functions 2024-12-29 21:15:03 +10:00
Joel Falknau
168e6cc290 remove temps from more templates 2024-12-29 21:14:40 +10:00
Joel Falknau
baf3c85ac7 Cleanup of templinks merge 2024-12-29 17:36:04 +10:00
Joel Falknau
d10562e9fc remove more bs3 fallback remnants 2024-12-28 14:15:09 +10:00
Joel Falknau
168b023a72 u_id param not needed 2024-12-28 13:41:39 +10:00
Joel Falknau
9df76443b1 correct discourse user updater 2024-12-28 13:35:24 +10:00
Joel Falknau
5c07f75eb5 Merge branch 'v5.x' of gitlab.com:allianceauth/allianceauth into v5.x 2024-12-28 13:14:49 +10:00
Ariel Rin
d61a49f2d9 Use lts tag for mariadb docker 2024-12-19 02:36:46 +00:00
Joel Falknau
3ef3098ad1 push templink framework 2024-12-06 21:07:42 +10:00
Joel Falknau
3b792117d9 Django Context Mumble Authenticator 2024-12-06 19:59:47 +10:00
Joel Falknau
a3c6d5345b These are warnings, id needs to be unique 2024-12-05 11:07:44 +10:00
Joel Falknau
5e836c4285 pre-commit fixes 2024-12-04 22:08:47 +10:00
Joel Falknau
dc0c1a2818 collapse docker compose to make discord less unhappy 2024-12-04 21:33:48 +10:00
Joel Falknau
eaba01ad97 remove checks that have expired and bump others 2024-12-04 21:33:20 +10:00
Joel Falknau
f4c024d199 pre-commit fixes 2024-12-04 21:32:54 +10:00
Joel Falknau
8f4daea14f sphinx theme v3 is now stable 2024-12-04 21:32:28 +10:00
Joel Falknau
b95f393a4c update pre-commit 2024-12-04 21:31:16 +10:00
Joel Falknau
757c6fa491 Drop BS3 templates 2024-09-16 11:59:35 +10:00
Joel Falknau
cc11761d8e docker-compose version is deprecated 2024-09-16 11:47:10 +10:00
Joel Falknau
30bb855381 bump infra 2024-09-16 11:46:52 +10:00
Joel Falknau
4bda887234 Use Python 3.12 2024-09-16 11:45:56 +10:00
Joel Falknau
dd255664d4 Move to Mariadb 11.4 LTS 2024-09-16 11:45:33 +10:00
Joel Falknau
f54fc26a1c BS 5.3.3 (for login page) 2024-09-16 11:24:07 +10:00
Joel Falknau
23259e919c jqueryui 1.14 2024-09-16 11:22:34 +10:00
Joel Falknau
c9bee08a6e moment.js 2.30.1 2024-09-16 11:21:36 +10:00
Joel Falknau
c0cc927788 Fontawesome 6.6 2024-09-16 11:21:19 +10:00
Joel Falknau
ae16a3de81 Datatables 2.x 2024-09-16 11:20:29 +10:00
Joel Falknau
c4cbaac454 Add Git Blame Ignore 2024-09-13 23:19:30 +10:00
Joel Falknau
a99315ea55 formatting storm 2024-09-13 23:10:37 +10:00
Joel Falknau
ec5cf08eef update classifiers and dependencies 2024-09-13 23:09:12 +10:00
Ariel Rin
27cf74f507 Merge branch 'ruff' into 'v5.x'
Linting and Formatting changes for 5.x

See merge request allianceauth/allianceauth!1644
2024-09-13 10:50:16 +00:00
Ariel Rin
98509b0dbf Linting and Formatting changes for 5.x 2024-09-13 10:50:15 +00:00
Joel Falknau
a14038c61a Merge branch 'master' of gitlab.com:allianceauth/allianceauth into v5.x 2024-09-13 20:29:08 +10:00
Joel Falknau
63fb449060 django now doesnt like unsaved models, fair 2024-08-24 15:42:11 +10:00
Joel Falknau
235675fa9b django.utils.timezone.utc was removed. (shame it was handy) 2024-08-24 15:41:37 +10:00
Joel Falknau
a065f043eb correct index for new page name 2024-08-24 15:09:12 +10:00
Joel Falknau
0839920032 use defined pygments 2024-08-24 15:09:02 +10:00
Joel Falknau
29ad4acff7 bring docs up to python 312 across the board 2024-08-24 15:08:41 +10:00
Joel Falknau
3a5b84d1f9 remove \u2060 from image name 2024-08-24 13:42:55 +10:00
Joel Falknau
bbcb94021e run pyupgrade 2024-08-23 13:55:25 +10:00
Joel Falknau
d50f13528b add python 3.13 RC to tox allow fail 2024-08-22 12:58:54 +10:00
Joel Falknau
c88521af88 django-celery-beat >=2.7.0 2024-08-22 12:54:31 +10:00
Joel Falknau
2bd5ff8723 Merge branch 'master' of gitlab.com:allianceauth/allianceauth into v5.x 2024-08-12 13:23:34 +10:00
Joel Falknau
84484cebcb move to django 5.1.x 2024-08-12 13:20:27 +10:00
Joel Falknau
5ee34fcb2d bring docker major up 2024-08-12 13:20:11 +10:00
Joel Falknau
046473def1 use 3.12 in builds 2024-08-12 13:19:57 +10:00
Joel Falknau
6aaba2bf3d Drop Python 3.8 and 3.9 2024-08-12 13:19:08 +10:00
517 changed files with 9719 additions and 5572 deletions

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

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -19,21 +19,23 @@ exclude: |
\.po|
\.mo|
swagger\.json|
static/(.*)/libs/
static/(.*)/libs/|
telnetlib\.py|
\.ice
)
repos:
# Code Upgrades
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py38-plus]
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.25.0
hooks:
- id: django-upgrade
args: [--target-version=4.2]
args: [--target-version=5.2]
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py310-plus]
# Formatting
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -76,20 +78,21 @@ repos:
language: node
args:
- --disable=MD013
# Infrastructure
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.6.0
hooks:
- id: pyproject-fmt
name: pyproject.toml formatter
description: "Format the pyproject.toml file."
args:
- --indent=4
additional_dependencies:
- tox==4.24.1 # https://github.com/tox-dev/tox/releases/latest
- tox==4.26.0 # https://github.com/tox-dev/tox/releases/latest
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.5.0
hooks:
- id: tox-ini-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
name: Validate pyproject.toml
description: "Validate the pyproject.toml file."

View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1,19 @@
"""Admin site for admin status applicaton"""
from django.contrib import admin
from allianceauth.admin_status.models import ApplicationAnnouncement
@admin.register(ApplicationAnnouncement)
class ApplicationAnnouncementAdmin(admin.ModelAdmin):
list_display = ["application_name", "announcement_number", "announcement_text", "hide_announcement"]
list_filter = ["hide_announcement"]
ordering = ["application_name", "announcement_number"]
readonly_fields = ["application_name", "announcement_number", "announcement_text", "announcement_url"]
fields = ["application_name", "announcement_number", "announcement_text", "announcement_url", "hide_announcement"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False

View File

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

View File

@@ -0,0 +1,207 @@
import hashlib
import logging
from dataclasses import dataclass
from enum import Enum
from urllib.parse import quote_plus
import requests
from django.core.cache import cache
from allianceauth.hooks import get_hooks, register
logger = logging.getLogger(__name__)
# timeout for all requests
REQUESTS_TIMEOUT = 5 # 5 seconds
# max pages to be fetched from gitlab
MAX_PAGES = 50
# Cache time
NOTIFICATION_CACHE_TIME = 300 # 5 minutes
@dataclass
class Announcement:
"""
Dataclass storing all data for an announcement to be sent arround
"""
application_name: str
announcement_url: str
announcement_number: int
announcement_text: str
@classmethod
def build_from_gitlab_issue_dict(cls, application_name: str, gitlab_issue: dict) -> "Announcement":
"""Builds the announcement from the JSON dict of a GitLab issue"""
return Announcement(application_name, gitlab_issue["web_url"], gitlab_issue["iid"], gitlab_issue["title"])
@classmethod
def build_from_github_issue_dict(cls, application_name: str, github_issue: dict) -> "Announcement":
"""Builds the announcement from the JSON dict of a GitHub issue"""
return Announcement(application_name, github_issue["html_url"], github_issue["number"], github_issue["title"])
def get_hash(self):
"""Get a hash of the Announcement for comparison"""
name = f"{self.application_name}.{self.announcement_number}"
hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest()
return hash_value
@dataclass
class AppAnnouncementHook:
"""
A hook for an application to send GitHub/GitLab issues as announcements on the dashboard
Args:
- app_name: The name of your application
- repository_namespace: The namespace of the remote repository of your application source code.
It should look like `<username>/<application_name>`.
- repository_kind: Enumeration to determine if your repository is a GitHub or GitLab repository.
- label: The label applied to issues that should be seen as announcements, case-sensitive.
Default value: `announcement`
"""
class Service(Enum):
"""Simple enumeration to determine which api should be called to access issues"""
GITLAB = "gitlab"
GITHUB = "github"
app_name: str
repository_namespace: str
repository_kind: Service
label: str = "announcement"
def get_announcement_list(self) -> list[Announcement]:
"""
Checks the application repository to find issues with the `Announcement` tag and return their title and link to
be displayed.
"""
logger.debug("Getting announcement list for the app %s", self.app_name)
match self.repository_kind:
case AppAnnouncementHook.Service.GITHUB:
announcement_list = self._get_github_announcement_list()
case AppAnnouncementHook.Service.GITLAB:
announcement_list = self._get_gitlab_announcement_list()
case _:
announcement_list = []
logger.debug("Announcements for app %s: %s", self.app_name, announcement_list)
return announcement_list
def _get_github_announcement_list(self) -> list[Announcement]:
"""
Return the issue list for a GitHub repository
Will filter if the `pull_request` attribute is present
"""
raw_list = _fetch_list_from_github(
f"https://api.github.com/repos/{self.repository_namespace}/issues"
f"?labels={self.label}"
)
return [Announcement.build_from_github_issue_dict(self.app_name, github_issue) for github_issue in raw_list]
def _get_gitlab_announcement_list(self) -> list[Announcement]:
"""Return the issues list for a GitLab repository"""
raw_list = _fetch_list_from_gitlab(
f"https://gitlab.com/api/v4/projects/{quote_plus(self.repository_namespace)}/issues"
f"?labels={self.label}&state=opened")
return [Announcement.build_from_gitlab_issue_dict(self.app_name, gitlab_issue) for gitlab_issue in raw_list]
@register("app_announcement_hook")
def alliance_auth_announcements_hook():
return AppAnnouncementHook("AllianceAuth", "allianceauth/allianceauth", AppAnnouncementHook.Service.GITLAB)
def get_all_applications_announcements() -> list[Announcement]:
"""
Retrieve all known application announcements and returns them
"""
application_notifications = []
hooks = [fn() for fn in get_hooks("app_announcement_hook")]
for hook in hooks:
logger.debug(hook)
try:
application_notifications.extend(cache.get_or_set(
f"{hook.app_name}_notification_issues",
hook.get_announcement_list,
NOTIFICATION_CACHE_TIME,
))
except requests.HTTPError:
logger.warning("Error when getting %s notifications", hook, exc_info=True)
logger.debug(application_notifications)
if application_notifications:
application_notifications = application_notifications[:10]
return application_notifications
def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list:
"""returns a list from the GitLab API. Supports paging"""
result = []
for page in range(1, max_pages + 1):
try:
request = requests.get(
url, params={'page': page}, timeout=REQUESTS_TIMEOUT
)
request.raise_for_status()
except requests.exceptions.RequestException as e:
error_str = str(e)
logger.warning(
f'Unable to fetch from GitLab API. Error: {error_str}',
exc_info=True,
)
return result
result += request.json()
if 'x-total-pages' in request.headers:
try:
total_pages = int(request.headers['x-total-pages'])
except ValueError:
total_pages = None
else:
total_pages = None
if not total_pages or page >= total_pages:
break
return result
def _fetch_list_from_github(url: str, max_pages: int = MAX_PAGES) -> list:
"""returns a list from the GitHub API. Supports paging"""
result = []
for page in range(1, max_pages+1):
try:
request = requests.get(
url,
params={'page': page},
headers={
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
},
timeout=REQUESTS_TIMEOUT,
)
request.raise_for_status()
except requests.exceptions.RequestException as e:
error_str = str(e)
logger.warning(
f'Unable to fetch from GitHub API. Error: {error_str}',
exc_info=True,
)
return result
result += request.json()
logger.debug(request.json())
# https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28
# See Example creating a pagination method
if not ('link' in request.headers and 'rel=\"next\"' in request.headers['link']):
break
return result

View File

@@ -0,0 +1,57 @@
from typing import TYPE_CHECKING
from django.db import models
from allianceauth.admin_status.hooks import (
Announcement,
get_all_applications_announcements,
)
from allianceauth.services.hooks import get_extension_logger
if TYPE_CHECKING:
from .models import ApplicationAnnouncement
logger = get_extension_logger(__name__)
class ApplicationAnnouncementManager(models.Manager):
def sync_and_return(self):
"""
Checks all hooks if new notifications need to be created.
Return all notification objects after
"""
logger.info("Syncing announcements")
current_announcements = get_all_applications_announcements()
self._delete_obsolete_announcements(current_announcements)
self._store_new_announcements(current_announcements)
return self.all()
def _delete_obsolete_announcements(self, current_announcements: list[Announcement]):
"""Deletes all announcements stored in the database that aren't retrieved anymore"""
hashes = [announcement.get_hash() for announcement in current_announcements]
self.exclude(announcement_hash__in=hashes).delete()
def _store_new_announcements(self, current_announcements: list[Announcement]):
"""Stores a new database object for new application announcements"""
for current_announcement in current_announcements:
try:
announcement = self.get(announcement_hash=current_announcement.get_hash())
except self.model.DoesNotExist:
self.create_from_announcement(current_announcement)
else:
# if exists update the text only
if announcement.announcement_text != current_announcement.announcement_text:
announcement.announcement_text = current_announcement.announcement_text
announcement.save()
def create_from_announcement(self, announcement: Announcement) -> "ApplicationAnnouncement":
"""Creates from the Announcement dataclass"""
return self.create(
application_name=announcement.application_name,
announcement_number=announcement.announcement_number,
announcement_text=announcement.announcement_text,
announcement_url=announcement.announcement_url,
announcement_hash=announcement.get_hash(),
)

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.9 on 2025-05-18 15:43
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ApplicationAnnouncement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('application_name', models.CharField(help_text='Name of the application that issued the announcement', max_length=50)),
('announcement_number', models.IntegerField(help_text='Issue number on the notification source')),
('announcement_text', models.TextField(help_text='Issue title text displayed on the dashboard', max_length=300)),
('announcement_url', models.TextField(max_length=200)),
('announcement_hash', models.CharField(default=None, editable=False, help_text='hash of an announcement. Must be nullable for unique comparison.', max_length=64, null=True, unique=True)),
('hide_announcement', models.BooleanField(default=False, help_text='Set to true if the announcement should not be displayed on the dashboard')),
],
options={
'constraints': [models.UniqueConstraint(fields=('application_name', 'announcement_number'), name='functional_pk_applicationissuenumber')],
},
managers=[
('object', django.db.models.manager.Manager()),
],
),
]

View File

@@ -0,0 +1,45 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from allianceauth.admin_status.managers import ApplicationAnnouncementManager
class ApplicationAnnouncement(models.Model):
"""
Announcement originating from an application
"""
object = ApplicationAnnouncementManager()
application_name = models.CharField(max_length=50, help_text=_("Name of the application that issued the announcement"))
announcement_number = models.IntegerField(help_text=_("Issue number on the notification source"))
announcement_text = models.TextField(max_length=300, help_text=_("Issue title text displayed on the dashboard"))
announcement_url = models.TextField(max_length=200)
announcement_hash = models.CharField(
max_length=64,
default=None,
unique=True,
editable=False,
help_text="hash of an announcement."
)
hide_announcement = models.BooleanField(
default=False,
help_text=_("Set to true if the announcement should not be displayed on the dashboard")
)
class Meta:
# Should be updated to a composite key when the switch to Django 5.2 is made
# https://docs.djangoproject.com/en/5.2/topics/composite-primary-key/
constraints = [
models.UniqueConstraint(
fields=["application_name", "announcement_number"], name="functional_pk_applicationissuenumber"
)
]
def __str__(self):
return f"{self.application_name} announcement #{self.announcement_number}"
def is_hidden(self) -> bool:
"""Function in case rules are made in the future to force hide/force show some announcements"""
return self.hide_announcement

View File

@@ -2,7 +2,6 @@
{% load admin_status %}
<div
id="celery-progress-bar-{{ label }}"
class="progress-bar text-bg-{{ level }} task-status-progress-bar"
role="progressbar"
aria-valuenow="{% decimal_widthratio tasks_count tasks_total 100 %}"
@@ -10,5 +9,5 @@
aria-valuemax="100"
style="width: {% decimal_widthratio tasks_count tasks_total 100 %}%;"
>
<span id="celery-progress-bar-{{ label }}-progress">{% widthratio tasks_count tasks_total 100 %}%</span>
<span>{% widthratio tasks_count tasks_total 100 %}%</span>
</div>

View File

@@ -0,0 +1,37 @@
{% load i18n %}
<div id="esi-alert" class="col-12 collapse">
<div class="alert alert-warning">
<p class="text-center ">{% translate 'Your Server received an ESI error response code of ' %}<b id="esi-code">?</b></p>
<hr>
<pre id="esi-data" class="text-center text-wrap"></pre>
</div>
</div>
<script>
const elemCard = document.getElementById("esi-alert");
const elemMessage = document.getElementById("esi-data");
const elemCode = document.getElementById("esi-code");
fetch('{% url "authentication:esi_check" %}')
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Something went wrong");
})
.then((responseJson) => {
console.log("ESI Check: ", JSON.stringify(responseJson, null, 2));
const status = responseJson.status;
if (status !== 200) {
elemCode.textContent = status
elemMessage.textContent = responseJson.data.error;
new bootstrap.Collapse(elemCard, {
toggle: true
})
}
})
.catch((error) => {
console.log(error);
});
</script>

View File

@@ -0,0 +1,166 @@
{% load i18n %}
{% load humanize %}
{% if notifications %}
<div id="aa-dashboard-panel-admin-application-notifications" class="col-12 mb-3">
<div class="card">
<div class="card-body">
{% translate "Announcements" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<ul class="list-group">
{% for notif in notifications %}
<li class="list-group-item">
<span class="badge text-bg-success me-2">{% translate "Open" %}</span>
<a href="{{ notif.web_url }}" target="_blank">#{{ notif.iid }} {{ notif.title }}</a>
</li>
{% empty %}
<div class="alert alert-primary" role="alert">
{% translate "No notifications at this time" %}
</div>
{% endfor %}
</ul>
<div class="text-end pt-3">
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none">
<span class="badge text-bg-danger">
<i class="fab fa-gitlab" aria-hidden="true"></i>
{% translate 'Powered by GitLab' %}
</span>
</a>
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
<span class="badge text-bg-info">
<i class="fab fa-discord" aria-hidden="true"></i>
{% translate 'Support Discord' %}
</span>
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="col-12 mb-3">
<div class="card">
<div class="card-body row">
<div id="aa-dashboard-panel-software-version" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
{% translate "Software Version" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<ul class="list-group list-group-horizontal w-100" role="group" aria-label="{% translate 'Software Version' %}">
<li class="list-group-item w-100">
<div class="btn h-100 w-100 cursor-default">
<h5 class="list-group-item-heading">{% translate "Current" %}</h5>
<p class="list-group-item-text">{{ current_version }}</p>
</div>
</li>
<li class="list-group-item text-bg-{% if latest_patch %}success{% elif latest_minor %}warning{% else %}danger{% endif %} w-100">
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_patch_version }}">
<h5 class="list-group-item-heading">{% translate "Latest Stable" %}</h5>
<p class="list-group-item-text">
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
{{ latest_patch_version }}
{% if not latest_patch %}<br>{% translate "Update available" %}{% endif %}
</p>
</a>
</li>
{% if latest_beta %}
<li class="list-group-item text-bg-info w-100">
<a class="btn h-100 w-100" href="https://gitlab.com/allianceauth/allianceauth/-/releases/v{{ latest_beta_version }}">
<h5 class="list-group-item-heading">{% translate "Latest Pre-Release" %}</h5>
<p class="list-group-item-text">
<i class="fab fa-gitlab hidden-xs" aria-hidden="true"></i>
{{ latest_beta_version }}
<br>{% translate "Pre-Release available" %}
</p>
</a>
</li>
{% endif %}
</ul>
</div>
</div>
<div id="aa-dashboard-panel-task-queue" class="col-xl-6 col-lg-12 col-md-12 col-sm-12">
{% translate "Task Queue" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<p>
{% blocktranslate with total=tasks_total|intcomma latest=earliest_task|timesince|default:"?" %}
Status of {{ total }} processed tasks • last {{ latest }}
{% endblocktranslate %}
</p>
<div
class="progress"
style="height: 21px;"
title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed"
>
{% include "admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %}
{% include "admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %}
{% include "admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}
</div>
<p>
<span id="task-counts">?</span> {% translate 'running' %} |
<span id="queued-tasks-count">?</span> {% translate 'queued' %}
</p>
</div>
</div>
<div class="text-end pt-3">
<a href="https://gitlab.com/allianceauth/allianceauth/issues" target="_blank" class="me-1 text-decoration-none">
<span class="badge" style="background-color: rgb(230 83 40);">
<i class="fab fa-gitlab" aria-hidden="true"></i>
{% translate 'Powered by GitLab' %}
</span>
</a>
<a href="https://discord.com/invite/fjnHAmk" target="_blank" class="text-decoration-none">
<span class="badge" style="background-color: rgb(110 133 211);">
<i class="fab fa-discord" aria-hidden="true"></i>
{% translate 'Support Discord' %}
</span>
</a>
</div>
</div>
</div>
</div>
<script>
const elemRunning = document.getElementById("task-counts");
const elemQueued = document.getElementById("queued-tasks-count");
fetch('{% url "authentication:task_counts" %}')
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Something went wrong");
})
.then((responseJson) => {
const running = responseJson.tasks_running;
if (running == null) {
elemRunning.textContent = "N/A";
} else {
elemRunning.textContent = running.toLocaleString();
}
const queued = responseJson.tasks_queued;
if (queued == null) {
elemQueued.textContent = "N/A";
} else {
elemQueued.textContent = queued.toLocaleString();
}
})
.catch((error) => {
console.log(error);
elemRunning.textContent = "ERROR";
elemQueued.textContent = "ERROR";
});
</script>

View File

@@ -8,6 +8,7 @@ from django.conf import settings
from django.core.cache import cache
from allianceauth import __version__
from allianceauth.admin_status.models import ApplicationAnnouncement
from allianceauth.authentication.task_statistics.counters import (
dashboard_results,
)
@@ -25,10 +26,6 @@ MAX_PAGES = 50
GITLAB_AUTH_REPOSITORY_TAGS_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
)
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
'?labels=announcement&state=opened'
)
logger = logging.getLogger(__name__)
@@ -41,10 +38,10 @@ def decimal_widthratio(this_value, max_value, max_width) -> str:
return str(round(this_value / max_value * max_width, 2))
@register.inclusion_tag('allianceauth/admin-status/overview.html')
@register.inclusion_tag('admin-status/overview.html')
def status_overview() -> dict:
response = {
"notifications": list(),
"notifications": [],
"current_version": __version__,
"tasks_succeeded": 0,
"tasks_retried": 0,
@@ -52,7 +49,6 @@ def status_overview() -> dict:
"tasks_total": 0,
"tasks_hours": 0,
"earliest_task": None,
"debug": settings.DEBUG if settings.DISPLAY_DEBUG else False,
}
response.update(_current_notifications())
response.update(_current_version_summary())
@@ -74,32 +70,15 @@ def _celery_stats() -> dict:
def _current_notifications() -> dict:
"""returns the newest 5 announcement issues"""
try:
notifications = cache.get_or_set(
'gitlab_notification_issues',
_fetch_notification_issues_from_gitlab,
NOTIFICATION_CACHE_TIME
)
except requests.HTTPError:
logger.warning('Error while getting gitlab notifications', exc_info=True)
top_notifications = []
else:
if notifications:
top_notifications = notifications[:5]
else:
top_notifications = []
"""returns announcements from AllianceAuth and third party applications"""
application_notifications = ApplicationAnnouncement.object.sync_and_return()
response = {
'notifications': top_notifications,
'notifications': application_notifications,
}
return response
def _fetch_notification_issues_from_gitlab() -> list:
return _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, max_pages=10)
def _current_version_summary() -> dict:
"""returns the current version info"""
try:
@@ -145,8 +124,8 @@ def _latests_versions(tags: list) -> tuple:
Non-compliant tags will be ignored
"""
versions = list()
betas = list()
versions = []
betas = []
for tag in tags:
try:
version = Pep440Version(tag.get('name'))
@@ -168,7 +147,7 @@ def _latests_versions(tags: list) -> tuple:
def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list:
"""returns a list from the GitLab API. Supports paging"""
result = list()
result = []
for page in range(1, max_pages + 1):
try:

View File

@@ -0,0 +1,194 @@
import requests_mock
from allianceauth.admin_status.hooks import Announcement
from allianceauth.services.hooks import AppAnnouncementHook
from allianceauth.utils.testing import NoSocketsTestCase
class TestHooks(NoSocketsTestCase):
@requests_mock.mock()
def test_fetch_gitlab(self, requests_mocker):
# given
announcement_hook = AppAnnouncementHook("test GitLab app", "r0kym/allianceauth-example-plugin",
AppAnnouncementHook.Service.GITLAB)
requests_mocker.get(
"https://gitlab.com/api/v4/projects/r0kym%2Fallianceauth-example-plugin/issues?labels=announcement&state=opened",
json=[
{
"id": 166279127,
"iid": 1,
"project_id": 67653102,
"title": "Test GitLab issue",
"description": "Test issue",
"state": "opened",
"created_at": "2025-04-20T21:26:57.914Z",
"updated_at": "2025-04-21T11:04:30.501Z",
"closed_at": None,
"closed_by": None,
"labels": [
"announcement"
],
"milestone": None,
"assignees": [],
"author": {
"id": 14491514,
"username": "r0kym",
"public_email": "",
"name": "T'rahk Rokym",
"state": "active",
"locked": False,
"avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/14491514/avatar.png",
"web_url": "https://gitlab.com/r0kym"
},
"type": "ISSUE",
"assignee": None,
"user_notes_count": 0,
"merge_requests_count": 0,
"upvotes": 0,
"downvotes": 0,
"due_date": None,
"confidential": False,
"discussion_locked": None,
"issue_type": "issue",
"web_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
"time_stats": {
"time_estimate": 0,
"total_time_spent": 0,
"human_time_estimate": None,
"human_total_time_spent": None
},
"task_completion_status": {
"count": 0,
"completed_count": 0
},
"blocking_issues_count": 0,
"has_tasks": True,
"task_status": "0 of 0 checklist items completed",
"_links": {
"self": "https://gitlab.com/api/v4/projects/67653102/issues/1",
"notes": "https://gitlab.com/api/v4/projects/67653102/issues/1/notes",
"award_emoji": "https://gitlab.com/api/v4/projects/67653102/issues/1/award_emoji",
"project": "https://gitlab.com/api/v4/projects/67653102",
"closed_as_duplicate_of": None
},
"references": {
"short": "#1",
"relative": "#1",
"full": "r0kym/allianceauth-example-plugin#1"
},
"severity": "UNKNOWN",
"moved_to_id": None,
"imported": False,
"imported_from": "none",
"service_desk_reply_to": None
}
]
)
# when
announcements = announcement_hook.get_announcement_list()
# then
self.assertEqual(len(announcements), 1)
self.assertIn(Announcement(
application_name="test GitLab app",
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
announcement_number=1,
announcement_text="Test GitLab issue"
), announcements)
@requests_mock.mock()
def test_fetch_github(self, requests_mocker):
# given
announcement_hook = AppAnnouncementHook("test GitHub app", "r0kym/test", AppAnnouncementHook.Service.GITHUB)
requests_mocker.get(
"https://api.github.com/repos/r0kym/test/issues?labels=announcement",
json=[
{
"url": "https://api.github.com/repos/r0kym/test/issues/1",
"repository_url": "https://api.github.com/repos/r0kym/test",
"labels_url": "https://api.github.com/repos/r0kym/test/issues/1/labels{/name}",
"comments_url": "https://api.github.com/repos/r0kym/test/issues/1/comments",
"events_url": "https://api.github.com/repos/r0kym/test/issues/1/events",
"html_url": "https://github.com/r0kym/test/issues/1",
"id": 3007269496,
"node_id": "I_kwDOOc2YvM6zP0p4",
"number": 1,
"title": "GitHub issue",
"user": {
"login": "r0kym",
"id": 56434393,
"node_id": "MDQ6VXNlcjU2NDM0Mzkz",
"avatar_url": "https://avatars.githubusercontent.com/u/56434393?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/r0kym",
"html_url": "https://github.com/r0kym",
"followers_url": "https://api.github.com/users/r0kym/followers",
"following_url": "https://api.github.com/users/r0kym/following{/other_user}",
"gists_url": "https://api.github.com/users/r0kym/gists{/gist_id}",
"starred_url": "https://api.github.com/users/r0kym/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/r0kym/subscriptions",
"organizations_url": "https://api.github.com/users/r0kym/orgs",
"repos_url": "https://api.github.com/users/r0kym/repos",
"events_url": "https://api.github.com/users/r0kym/events{/privacy}",
"received_events_url": "https://api.github.com/users/r0kym/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": False
},
"labels": [
{
"id": 8487814480,
"node_id": "LA_kwDOOc2YvM8AAAAB-enFUA",
"url": "https://api.github.com/repos/r0kym/test/labels/announcement",
"name": "announcement",
"color": "aaaaaa",
"default": False,
"description": None
}
],
"state": "open",
"locked": False,
"assignee": None,
"assignees": [],
"milestone": None,
"comments": 0,
"created_at": "2025-04-20T22:41:10Z",
"updated_at": "2025-04-21T11:05:08Z",
"closed_at": None,
"author_association": "OWNER",
"active_lock_reason": None,
"sub_issues_summary": {
"total": 0,
"completed": 0,
"percent_completed": 0
},
"body": None,
"closed_by": None,
"reactions": {
"url": "https://api.github.com/repos/r0kym/test/issues/1/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/r0kym/test/issues/1/timeline",
"performed_via_github_app": None,
"state_reason": None
}
]
)
# when
announcements = announcement_hook.get_announcement_list()
# then
self.assertEqual(len(announcements), 1)
self.assertIn(Announcement(
application_name="test GitHub app",
announcement_url="https://github.com/r0kym/test/issues/1",
announcement_number=1,
announcement_text="GitHub issue"
), announcements)

View File

@@ -0,0 +1,75 @@
from unittest.mock import patch
from allianceauth.admin_status.hooks import Announcement
from allianceauth.admin_status.models import ApplicationAnnouncement
from allianceauth.utils.testing import NoSocketsTestCase
MODULE_PATH = 'allianceauth.admin_status.managers'
DEFAULT_ANNOUNCEMENTS = [
Announcement(
application_name="Test GitHub Application",
announcement_number=1,
announcement_text="GitHub issue",
announcement_url="https://github.com/r0kym/test/issues/1",
),
Announcement(
application_name="Test Gitlab Application",
announcement_number=1,
announcement_text="GitLab issue",
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
)
]
class TestSyncManager(NoSocketsTestCase):
def setUp(self):
ApplicationAnnouncement.object.create(
application_name="Test GitHub Application",
announcement_number=1,
announcement_text="GitHub issue",
announcement_url="https://github.com/r0kym/test/issues/1",
announcement_hash="9dbedb9c47529bb43cfecb704768a35d085b145930e13cced981623e5f162a85",
)
ApplicationAnnouncement.object.create(
application_name="Test Gitlab Application",
announcement_number=1,
announcement_text="GitLab issue",
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
announcement_hash="8955a9c12a1cfa9e1776662bdaf111147b84e35c79f24bfb758e35333a18b1bd",
)
@patch(MODULE_PATH + '.get_all_applications_announcements')
def test_announcements_stay_as_is(self, all_announcements_mocker):
# given
announcement_ids = set(ApplicationAnnouncement.object.values_list("id", flat=True))
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS
# when
ApplicationAnnouncement.object.sync_and_return()
# then
self.assertEqual(ApplicationAnnouncement.object.count(), 2)
self.assertEqual(set(ApplicationAnnouncement.object.values_list("id", flat=True)), announcement_ids)
@patch(MODULE_PATH + '.get_all_applications_announcements')
def test_announcement_add(self, all_announcements_mocker):
# given
returned_announcements = DEFAULT_ANNOUNCEMENTS + [Announcement(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com")]
all_announcements_mocker.return_value = returned_announcements
# when
ApplicationAnnouncement.object.sync_and_return()
# then
self.assertEqual(ApplicationAnnouncement.object.count(), 3)
self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com"))
@patch(MODULE_PATH + '.get_all_applications_announcements')
def test_announcement_remove(self, all_announcements_mocker):
# given
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS
ApplicationAnnouncement.object.sync_and_return()
self.assertEqual(ApplicationAnnouncement.object.count(), 2)
all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS[:1]
# when
ApplicationAnnouncement.object.sync_and_return()
# then
self.assertEqual(ApplicationAnnouncement.object.count(), 1)
self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test GitHub Application").exists())

View File

@@ -8,19 +8,61 @@ from packaging.version import Version as Pep440Version
from django.core.cache import cache
from django.test import TestCase
from allianceauth.templatetags.admin_status import (
_current_notifications, _current_version_summary, _fetch_list_from_gitlab,
_fetch_notification_issues_from_gitlab, _latests_versions, status_overview,
from allianceauth.admin_status.models import ApplicationAnnouncement
from allianceauth.admin_status.templatetags.admin_status import (
_current_notifications,
_current_version_summary,
_fetch_list_from_gitlab,
_latests_versions,
status_overview,
)
MODULE_PATH = 'allianceauth.templatetags'
MODULE_PATH = 'allianceauth.admin_status.templatetags'
def create_tags_list(tag_names: list):
return [{'name': str(tag_name)} for tag_name in tag_names]
def get_app_announcement_as_dict(app_announcement: ApplicationAnnouncement) -> dict:
"""Transforms an app announcement object in a dict easy to compare"""
return {
"application_name": app_announcement.application_name,
"announcement_number": app_announcement.announcement_number,
"announcement_text": app_announcement.announcement_text,
"announcement_url": app_announcement.announcement_url,
}
GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
STORED_NOTIFICATIONS = [
ApplicationAnnouncement(
application_name="Test GitHub Application",
announcement_number=1,
announcement_text="GitHub issue",
announcement_url="https://github.com/r0kym/test/issues/1",
announcement_hash="hash1",
),
ApplicationAnnouncement(
application_name="Test Gitlab Application",
announcement_number=1,
announcement_text="GitLab issue",
announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
announcement_hash="hash2",
),
]
ANNOUNCEMENT_DICT = [
{
"application_name": "Test GitHub Application",
"announcement_number": 1,
"announcement_text": "GitHub issue",
"announcement_url": "https://github.com/r0kym/test/issues/1",
}, {
"application_name": "Test Gitlab Application",
"announcement_number": 1,
"announcement_text": "GitLab issue",
"announcement_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1",
}
]
GITHUB_NOTIFICATION_ISSUES = [
{
'id': 1,
@@ -48,6 +90,10 @@ GITHUB_NOTIFICATION_ISSUES = [
},
]
TEST_VERSION = '2.6.5'
GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
'?labels=announcement&state=opened'
)
class TestStatusOverviewTag(TestCase):
@@ -103,18 +149,19 @@ class TestNotifications(TestCase):
)
requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
# when
result = _fetch_notification_issues_from_gitlab()
result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10)
# then
self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_normal(self, mock_cache):
@patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement')
def test_current_notifications_normal(self, mock_application_announcement):
# given
mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS
# when
result = _current_notifications()
# then
self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5])
for notification in result["notifications"]:
self.assertIn(get_app_announcement_as_dict(notification), ANNOUNCEMENT_DICT)
@requests_mock.mock()
def test_current_notifications_failed(self, requests_mocker):
@@ -127,16 +174,7 @@ class TestNotifications(TestCase):
# when
result = _current_notifications()
# then
self.assertEqual(result['notifications'], list())
@patch(MODULE_PATH + '.admin_status.cache')
def test_current_notifications_is_none(self, mock_cache):
# given
mock_cache.get_or_set.return_value = None
# when
result = _current_notifications()
# then
self.assertEqual(result['notifications'], list())
self.assertEqual(list(result['notifications']), [])
class TestCeleryQueueLength(TestCase):

View File

@@ -1,7 +1,8 @@
from solo.admin import SingletonModelAdmin
from django.contrib import admin
from .models import AnalyticsIdentifier, AnalyticsTokens
from solo.admin import SingletonModelAdmin
@admin.register(AnalyticsIdentifier)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
from typing import Literal
from uuid import uuid4
from solo.models import SingletonModel
from django.db import models
from django.utils.translation import gettext_lazy as _
from solo.models import SingletonModel
from uuid import uuid4
class AnalyticsIdentifier(SingletonModel):
@@ -27,3 +29,6 @@ class AnalyticsTokens(models.Model):
token = models.CharField(max_length=254, blank=False)
secret = models.CharField(max_length=254, blank=True)
send_stats = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name

View File

@@ -1,17 +1,16 @@
import requests
import logging
from django.conf import settings
from django.apps import apps
import requests
from celery import shared_task
from .models import AnalyticsTokens, AnalyticsIdentifier
from .utils import (
existence_baremetal_or_docker,
install_stat_addons,
install_stat_tokens,
install_stat_users)
from django.apps import apps
from django.conf import settings
from allianceauth import __version__
from .models import AnalyticsIdentifier, AnalyticsTokens
from .utils import existence_baremetal_or_docker, install_stat_addons, install_stat_tokens, install_stat_users
logger = logging.getLogger(__name__)
BASE_URL = "https://www.google-analytics.com"

View File

@@ -1,9 +1,8 @@
from allianceauth.analytics.models import AnalyticsIdentifier
from uuid import uuid4
from django.test.testcases import TestCase
from uuid import uuid4
from allianceauth.analytics.models import AnalyticsIdentifier
# Identifiers
uuid_1 = "ab33e241fbf042b6aa77c7655a768af7"

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import os
from django.apps import apps
from allianceauth.authentication.models import User
from esi.models import Token
from allianceauth.authentication.models import User
def install_stat_users() -> int:
"""Count and Return the number of User accounts

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<form class="dropdown-item" 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 %}
{% for lang_code, lang_name in LANGUAGES %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
import json
import requests_mock
from unittest.mock import patch
import requests_mock
from django.test import RequestFactory, TestCase
from allianceauth.authentication.views import task_counts, esi_check
from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.authentication.constants import ESI_ERROR_MESSAGE_OVERRIDES
from allianceauth.authentication.views import esi_check, task_counts
from allianceauth.tests.auth_utils import AuthUtils
MODULE_PATH = "allianceauth.authentication.views"
TEMPLATETAGS_PATH = "allianceauth.templatetags.admin_status"
def jsonresponse_to_dict(response) -> dict:
@@ -18,7 +18,6 @@ def jsonresponse_to_dict(response) -> dict:
@patch(MODULE_PATH + ".queued_tasks_count")
@patch(MODULE_PATH + ".active_tasks_count")
@patch(MODULE_PATH + "._celery_stats")
class TestRunningTasksCount(TestCase):
@classmethod
def setUpClass(cls) -> None:
@@ -28,64 +27,36 @@ class TestRunningTasksCount(TestCase):
cls.user.is_superuser = True
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
mock_tasks_running.return_value = 2
mock_tasks_queued.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",
}
mock_active_tasks_count.return_value = 2
mock_queued_tasks_count.return_value = 3
request = self.factory.get("/")
request.user = self.user
# when
response = task_counts(request)
# then
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
jsonresponse_to_dict(response),
{
"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,
}
jsonresponse_to_dict(response), {
"tasks_running": 2, "tasks_queued": 3}
)
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.save()
self.user.refresh_from_db()
# given
mock_tasks_running.return_value = 2
mock_tasks_queued.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",
}
mock_active_tasks_count.return_value = 2
mock_queued_tasks_count.return_value = 3
request = self.factory.get("/")
request.user = self.user
# when
response = task_counts(request)
# then
self.assertEqual(response.status_code, 302)

View File

@@ -38,7 +38,6 @@ urlpatterns = [
name='token_refresh'
),
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('esi-check/', views.esi_check, name='esi_check'),
]

View File

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

View File

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

View File

@@ -2,16 +2,18 @@
Django system checks for Alliance Auth
"""
from typing import List
from django import db
from django.core.checks import CheckMessage, Error, register, Warning
from allianceauth.utils.cache import get_redis_client
from django.utils import timezone
from packaging.version import InvalidVersion, Version as Pep440Version
from celery import current_app
from django.conf import settings
from datetime import datetime, timezone
from sqlite3.dbapi2 import sqlite_version_info
from celery import current_app
from packaging.version import InvalidVersion, Version as Pep440Version
from django import db
from django.conf import settings
from django.core.checks import CheckMessage, Error, Warning, register
from allianceauth.utils.cache import get_redis_client
"""
A = System Packages
B = Configuration
@@ -19,7 +21,7 @@ B = Configuration
@register()
def django_settings(app_configs, **kwargs) -> List[CheckMessage]:
def django_settings(app_configs, **kwargs) -> list[CheckMessage]:
"""
Check that Django settings are correctly configured
@@ -31,7 +33,7 @@ def django_settings(app_configs, **kwargs) -> List[CheckMessage]:
:rtype:
"""
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
# Check for SITE_URL
if hasattr(settings, "SITE_URL"):
@@ -109,7 +111,7 @@ def django_settings(app_configs, **kwargs) -> List[CheckMessage]:
@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
@@ -123,7 +125,7 @@ def system_package_redis(app_configs, **kwargs) -> List[CheckMessage]:
allianceauth_redis_install_link = "https://allianceauth.readthedocs.io/en/latest/installation/allianceauth.html#redis-and-other-tools"
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
try:
redis_version = Pep440Version(get_redis_client().info()["redis_version"])
@@ -135,8 +137,8 @@ def system_package_redis(app_configs, **kwargs) -> List[CheckMessage]:
if (
redis_version.major == 7
and redis_version.minor == 2
and timezone.now()
> timezone.datetime(year=2025, month=8, day=31, tzinfo=timezone.utc)
and datetime.now(timezone.utc)
> datetime(year=2025, month=8, day=31, tzinfo=timezone.utc)
):
errors.append(
Error(
@@ -174,7 +176,7 @@ def system_package_redis(app_configs, **kwargs) -> List[CheckMessage]:
@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
@@ -188,7 +190,7 @@ def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]:
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():
if connection.vendor == "mysql":
@@ -203,7 +205,7 @@ def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]:
# MySQL 8
if mysql_version.major == 8:
if mysql_version.minor == 4 and timezone.now() > timezone.datetime(
if mysql_version.minor == 4 and datetime.now(timezone.utc) > datetime(
year=2032, month=4, day=30, tzinfo=timezone.utc
):
errors.append(
@@ -213,7 +215,8 @@ def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]:
id="allianceauth.checks.A004",
)
)
elif mysql_version.minor == 3:
# Demote versions down here once EOL
elif mysql_version.minor in [1, 2, 3]:
errors.append(
Warning(
msg=f"MySQL {mysql_version.public} Non LTS",
@@ -221,23 +224,7 @@ def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]:
id="allianceauth.checks.A005",
)
)
elif mysql_version.minor == 2:
errors.append(
Warning(
msg=f"MySQL {mysql_version.public} Non LTS",
hint=mysql_quick_guide_link,
id="allianceauth.checks.A006",
)
)
elif mysql_version.minor == 1:
errors.append(
Error(
msg=f"MySQL {mysql_version.public} EOL",
hint=mysql_quick_guide_link,
id="allianceauth.checks.A007",
)
)
elif mysql_version.minor == 0 and timezone.now() > timezone.datetime(
elif mysql_version.minor == 0 and datetime.now(timezone.utc) > datetime(
year=2026, month=4, day=30, tzinfo=timezone.utc
):
errors.append(
@@ -263,21 +250,14 @@ def system_package_mysql(app_configs, **kwargs) -> List[CheckMessage]:
@register()
def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
def system_package_mariadb(app_configs, **kwargs) -> list[CheckMessage]: # noqa: C901
"""
Check that MariaDB is a supported version
:param app_configs:
:type app_configs:
:param kwargs:
:type kwargs:
:return:
:rtype:
"""
mariadb_download_link = "https://mariadb.org/download/?t=repo-config"
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
for connection in db.connections.all():
# TODO: Find a way to determine MySQL vs. MariaDB
@@ -293,7 +273,7 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
# MariaDB 11
if mariadb_version.major == 11:
if mariadb_version.minor == 4 and timezone.now() > timezone.datetime(
if mariadb_version.minor == 4 and datetime.now(timezone.utc) > datetime(
year=2029, month=5, day=19, tzinfo=timezone.utc
):
errors.append(
@@ -303,46 +283,8 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
id="allianceauth.checks.A010",
)
)
elif mariadb_version.minor == 2:
errors.append(
Warning(
msg=f"MariaDB {mariadb_version.public} Non LTS",
hint=mariadb_download_link,
id="allianceauth.checks.A018",
)
)
if timezone.now() > timezone.datetime(
year=2024, month=11, day=21, tzinfo=timezone.utc
):
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A011",
)
)
elif mariadb_version.minor == 1:
errors.append(
Warning(
msg=f"MariaDB {mariadb_version.public} Non LTS",
hint=mariadb_download_link,
id="allianceauth.checks.A019",
)
)
if timezone.now() > timezone.datetime(
year=2024, month=8, day=21, tzinfo=timezone.utc
):
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
hint=mariadb_download_link,
id="allianceauth.checks.A012",
)
)
# Demote versions down here once EOL
elif mariadb_version.minor in [0, 3]:
elif mariadb_version.minor in [0, 1, 2, 3, 5, 6]:
errors.append(
Error(
msg=f"MariaDB {mariadb_version.public} EOL",
@@ -353,7 +295,7 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
# MariaDB 10
elif mariadb_version.major == 10:
if mariadb_version.minor == 11 and timezone.now() > timezone.datetime(
if mariadb_version.minor == 11 and datetime.now(timezone.utc) > datetime(
year=2028, month=2, day=10, tzinfo=timezone.utc
):
errors.append(
@@ -363,7 +305,7 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
id="allianceauth.checks.A014",
)
)
elif mariadb_version.minor == 6 and timezone.now() > timezone.datetime(
elif mariadb_version.minor == 6 and datetime.now(timezone.utc) > datetime(
year=2026, month=7, day=6, tzinfo=timezone.utc
):
errors.append(
@@ -373,7 +315,7 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
id="allianceauth.checks.A0015",
)
)
elif mariadb_version.minor == 5 and timezone.now() > timezone.datetime(
elif mariadb_version.minor == 5 and datetime.now(timezone.utc) > datetime(
year=2025, month=6, day=24, tzinfo=timezone.utc
):
errors.append(
@@ -397,7 +339,7 @@ def system_package_mariadb(app_configs, **kwargs) -> List[CheckMessage]:
@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
@@ -409,7 +351,7 @@ def system_package_sqlite(app_configs, **kwargs) -> List[CheckMessage]:
:rtype:
"""
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
for connection in db.connections.all():
if connection.vendor == "sqlite":
@@ -434,7 +376,7 @@ def system_package_sqlite(app_configs, **kwargs) -> List[CheckMessage]:
@register()
def sql_settings(app_configs, **kwargs) -> List[CheckMessage]:
def sql_settings(app_configs, **kwargs) -> list[CheckMessage]:
"""
Check that SQL settings are correctly configured
@@ -446,7 +388,7 @@ def sql_settings(app_configs, **kwargs) -> List[CheckMessage]:
:rtype:
"""
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
for connection in db.connections.all():
if connection.vendor == "mysql":
@@ -496,7 +438,7 @@ def sql_settings(app_configs, **kwargs) -> List[CheckMessage]:
@register()
def celery_settings(app_configs, **kwargs) -> List[CheckMessage]:
def celery_settings(app_configs, **kwargs) -> list[CheckMessage]:
"""
Check that Celery settings are correctly configured
@@ -508,7 +450,7 @@ def celery_settings(app_configs, **kwargs) -> List[CheckMessage]:
:rtype:
"""
errors: List[CheckMessage] = []
errors: list[CheckMessage] = []
try:
if current_app.conf.broker_transport_options != {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,17 @@ import logging
import os
from typing import ClassVar
from allianceauth.authentication.models import CharacterOwnership, UserProfile
from bravado.exception import HTTPForbidden
from django.db import models
from esi.errors import TokenError
from esi.models import Token
from allianceauth.eveonline.models import EveCorporationInfo, EveCharacter, EveAllianceInfo
from allianceauth.notifications import notify
from allianceauth.authentication.models import CharacterOwnership, UserProfile
from allianceauth.corputils.managers import CorpStatsManager
from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from allianceauth.notifications import notify
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'swagger.json')
"""
@@ -32,6 +34,8 @@ class CorpStats(models.Model):
corp = models.OneToOneField(EveCorporationInfo, on_delete=models.CASCADE)
last_update = models.DateTimeField(auto_now=True)
objects: ClassVar[CorpStatsManager] = CorpStatsManager()
class Meta:
permissions = (
('view_corp_corpstats', 'Can view corp stats of their corporation.'),
@@ -41,12 +45,10 @@ class CorpStats(models.Model):
verbose_name = "corp stats"
verbose_name_plural = "corp stats"
objects: ClassVar[CorpStatsManager] = CorpStatsManager()
def __str__(self) -> str:
return f"{self.__class__.__name__} for {self.corp}"
def update(self):
def update(self) -> None:
try:
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)
@@ -77,21 +79,21 @@ class CorpStats(models.Model):
logger.warning(f"{self} failed to update: {e}")
if self.token.user:
notify(
self.token.user, "%s failed to update with your ESI token." % self,
self.token.user, f"{self} failed to update with your ESI token.",
message="Your token has expired or is no longer valid. Please add a new one to create a new CorpStats.",
level="error")
self.delete()
except HTTPForbidden as e:
logger.warning(f"{self} failed to update: {e}")
if self.token.user:
notify(self.token.user, "%s failed to update with your ESI token." % self, message=f"{e.status_code}: {e.message}", level="error")
notify(self.token.user, f"{self} failed to update with your ESI token.", message=f"{e.status_code}: {e.message}", level="error")
self.delete()
except AssertionError:
logger.warning("%s token character no longer in corp." % self)
logger.warning(f"{self} token character no longer in corp.")
if self.token.user:
notify(
self.token.user, "%s cannot update with your ESI token." % self,
message="%s cannot update with your ESI token as you have left corp." % self, level="error")
self.token.user, f"{self} cannot update with your ESI token.",
message=f"{self} cannot update with your ESI token as you have left corp.", level="error")
self.delete()
@property
@@ -99,11 +101,11 @@ class CorpStats(models.Model):
return self.members.count()
@property
def user_count(self):
def user_count(self) -> int:
return len({m.main_character for m in self.members.all() if m.main_character})
@property
def registered_member_count(self):
def registered_member_count(self) -> int:
return len(self.registered_members)
@property
@@ -119,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])
@property
def main_count(self):
def main_count(self) -> int:
return len(self.mains)
@property
@@ -132,10 +134,10 @@ class CorpStats(models.Model):
def can_update(self, user):
return self.token.user == user or self.visible_to(user)
def corp_logo(self, size=128):
def corp_logo(self, size=128) -> str:
return self.corp.logo_url(size)
def alliance_logo(self, size=128):
def alliance_logo(self, size=128) -> str:
if self.corp.alliance:
return self.corp.alliance.logo_url(size)
else:
@@ -152,11 +154,11 @@ class CorpMember(models.Model):
unique_together = ('corpstats', 'character_id')
ordering = ['character_name']
def __str__(self):
def __str__(self) -> str:
return self.character_name
@property
def character(self):
def character(self) -> EveCharacter | None:
try:
return EveCharacter.objects.get(character_id=self.character_id)
except EveCharacter.DoesNotExist:
@@ -177,20 +179,20 @@ class CorpMember(models.Model):
return []
@property
def registered(self):
def registered(self) -> bool:
return CharacterOwnership.objects.filter(character__character_id=self.character_id).exists()
def portrait_url(self, size=32):
def portrait_url(self, size=32) -> str:
return EveCharacter.generic_portrait_url(self.character_id, size)
@property
def portrait_url_32(self):
def portrait_url_32(self) -> str:
return self.portrait_url(32)
@property
def portrait_url_64(self):
def portrait_url_64(self) -> str:
return self.portrait_url(64)
@property
def portrait_url_128(self):
def portrait_url_128(self) -> str:
return self.portrait_url(128)

View File

@@ -1,4 +1,5 @@
from celery import shared_task
from allianceauth.corputils.models import CorpStats

View File

@@ -11,7 +11,7 @@
{% endblock header_nav_brand %}
{% 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">
{% translate "Corporations" %}
</a>
@@ -26,7 +26,11 @@
{% endfor %}
{% 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' %}">
{% translate "Add corporation" %}
</a>

View File

@@ -1,14 +1,18 @@
from unittest import mock
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils
from .models import CorpStats, CorpMember
from allianceauth.eveonline.models import EveCorporationInfo, EveAllianceInfo, EveCharacter
from esi.models import Token
from esi.errors import TokenError
from bravado.exception import HTTPForbidden
from django.contrib.auth.models import Permission
from django.test import TestCase
from esi.errors import TokenError
from esi.models import Token
from allianceauth.authentication.models import CharacterOwnership
from allianceauth.eveonline.models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from allianceauth.tests.auth_utils import AuthUtils
from .models import CorpMember, CorpStats
class CorpStatsManagerTestCase(TestCase):

View File

@@ -1,4 +1,5 @@
from django.urls import path
from . import views
app_name = 'corputils'

View File

@@ -1,16 +1,19 @@
import os
from bravado.exception import HTTPError
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required, user_passes_test
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import IntegrityError
from django.shortcuts import render, redirect, get_object_or_404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from esi.decorators import token_required
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo
from .models import CorpStats, CorpMember
from .models import CorpMember, CorpStats
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'swagger.json')
"""
@@ -59,7 +62,7 @@ def corpstats_add(request, token):
@login_required
@user_passes_test(access_corpstats_test)
def corpstats_view(request, corp_id=None):
def corpstats_view(request, corp_id=None): # noqa: C901
corpstats = None
# get requested model

View File

@@ -1,8 +1,9 @@
# Generated by Django 4.2.16 on 2025-01-20 06:16
import allianceauth.crontab.models
from django.db import migrations, models
import allianceauth.crontab.models
class Migration(migrations.Migration):

View File

@@ -1,7 +1,9 @@
from random import random
from solo.models import SingletonModel
from django.db import models
from django.utils.translation import gettext_lazy as _
from solo.models import SingletonModel
def random_default() -> float:
@@ -9,7 +11,6 @@ def random_default() -> float:
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)

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