Compare commits

..

No commits in common. "master" and "v2.9.0" have entirely different histories.

930 changed files with 18698 additions and 62192 deletions

View File

@ -19,6 +19,5 @@ exclude_lines =
if __name__ == .__main__.: if __name__ == .__main__.:
def __repr__ def __repr__
raise AssertionError raise AssertionError
if TYPE_CHECKING:
ignore_errors = True ignore_errors = True

View File

@ -22,7 +22,3 @@ indent_style = tab
[*.bat] [*.bat]
indent_style = tab indent_style = tab
[{Dockerfile,*.dockerfile}]
indent_style = space
indent_size = 4

5
.gitignore vendored
View File

@ -38,6 +38,7 @@ htmlcov/
.tox/ .tox/
.coverage .coverage
.cache .cache
nosetests.xml
coverage.xml coverage.xml
# Translations # Translations
@ -69,8 +70,10 @@ celerybeat-schedule
#gitlab configs #gitlab configs
.gitlab/ .gitlab/
#transifex
.tx/
#other #other
.flake8 .flake8
.pylintrc .pylintrc
Makefile Makefile
alliance_auth.sqlite3

View File

@ -1,20 +1,12 @@
.only-default: &only-default
only:
- master
- branches
- merge_requests
stages: stages:
- pre-commit - pre-commit
- gitlab - gitlab
- test - test
- deploy - deploy
- docker
include: include:
- template: Dependency-Scanning.gitlab-ci.yml - template: Dependency-Scanning.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml - template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
before_script: before_script:
- apt-get update && apt-get install redis-server -y - apt-get update && apt-get install redis-server -y
@ -23,14 +15,13 @@ before_script:
- pip install wheel tox - pip install wheel tox
pre-commit-check: pre-commit-check:
<<: *only-default
stage: pre-commit stage: pre-commit
image: python:3.11-bookworm image: python:3.6-buster
# variables: variables:
# PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit
# cache: cache:
# paths: paths:
# - ${PRE_COMMIT_HOME} - ${PRE_COMMIT_HOME}
script: script:
- pip install pre-commit - pip install pre-commit
- pre-commit run --all-files - pre-commit run --all-files
@ -42,239 +33,75 @@ sast:
dependency_scanning: dependency_scanning:
stage: gitlab stage: gitlab
before_script: before_script:
- apt-get update && apt-get install redis-server libmariadb-dev -y - apt-get update && apt-get install redis-server libmariadb-dev -y
- redis-server --daemonize yes - redis-server --daemonize yes
- python -V - python -V
- pip install wheel tox - pip install wheel tox
secret_detection: test-3.7-core:
stage: gitlab image: python:3.7-bullseye
before_script: [] script:
- tox -e py37-core
artifacts:
when: always
reports:
cobertura: coverage.xml
test-3.8-core: test-3.8-core:
<<: *only-default image: python:3.8-bullseye
image: python:3.8-bookworm
script: script:
- tox -e py38-core - tox -e py38-core
artifacts: artifacts:
when: always when: always
reports: reports:
coverage_report: cobertura: coverage.xml
coverage_format: cobertura
path: coverage.xml
test-3.9-core: test-3.9-core:
<<: *only-default image: python:3.9-bullseye
image: python:3.9-bookworm
script: script:
- tox -e py39-core - tox -e py39-core
artifacts: artifacts:
when: always when: always
reports: reports:
coverage_report: cobertura: coverage.xml
coverage_format: cobertura
path: coverage.xml
test-3.10-core: test-3.7-all:
<<: *only-default image: python:3.7-bullseye
image: python:3.10-bookworm
script: script:
- tox -e py310-core - tox -e py37-all
artifacts: artifacts:
when: always when: always
reports: reports:
coverage_report: cobertura: coverage.xml
coverage_format: cobertura
path: coverage.xml
test-3.11-core:
<<: *only-default
image: python:3.11-bookworm
script:
- tox -e py311-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.12-core:
<<: *only-default
image: python:3.12-bookworm
script:
- tox -e py312-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-all: test-3.8-all:
<<: *only-default image: python:3.8-bullseye
image: python:3.8-bookworm
script: script:
- tox -e py38-all - tox -e py38-all
artifacts: artifacts:
when: always when: always
reports: reports:
coverage_report: cobertura: coverage.xml
coverage_format: cobertura
path: coverage.xml
test-3.9-all: test-3.9-all:
<<: *only-default image: python:3.9-bullseye
image: python:3.9-bookworm
script: script:
- tox -e py39-all - tox -e py39-all
artifacts: artifacts:
when: always when: always
reports: reports:
coverage_report: cobertura: coverage.xml
coverage_format: cobertura
path: coverage.xml
test-3.10-all:
<<: *only-default
image: python:3.10-bookworm
script:
- tox -e py310-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.11-all:
<<: *only-default
image: python:3.11-bookworm
script:
- tox -e py311-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
test-3.12-all:
<<: *only-default
image: python:3.12-bookworm
script:
- tox -e py312-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
build-test:
stage: test
image: python:3.11-bookworm
before_script:
- python -m pip install --upgrade pip
- python -m pip install --upgrade build
- python -m pip install --upgrade setuptools wheel
script:
- python -m build
artifacts:
when: always
name: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- dist/*
expire_in: 1 year
test-docs:
<<: *only-default
image: python:3.11-bookworm
script:
- tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.11-bookworm image: python:3.9-bullseye
before_script: before_script:
- python -m pip install --upgrade pip - pip install twine wheel
- python -m pip install --upgrade build
- python -m pip install --upgrade setuptools wheel twine
script: script:
- python -m build - python setup.py sdist bdist_wheel
- python -m twine upload dist/* - twine upload dist/*
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
build-image:
before_script: []
image: docker:24.0
stage: docker
services:
- docker:24.0-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
CURRENT_TAG=$CI_REGISTRY_IMAGE/auth:$CI_COMMIT_TAG
MINOR_TAG=$CI_REGISTRY_IMAGE/auth:$(echo $CI_COMMIT_TAG | cut -d '.' -f 1-2)
MAJOR_TAG=$CI_REGISTRY_IMAGE/auth:$(echo $CI_COMMIT_TAG | cut -d '.' -f 1)
LATEST_TAG=$CI_REGISTRY_IMAGE/auth:latest
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker run --privileged --rm tonistiigi/binfmt --uninstall qemu-*
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --use --name new-builder
docker buildx build . --tag $IMAGE_TAG --tag $CURRENT_TAG --tag $MINOR_TAG --tag $MAJOR_TAG --tag $LATEST_TAG --file docker/Dockerfile --platform linux/amd64,linux/arm64 --push --build-arg AUTH_VERSION=$(echo $CI_COMMIT_TAG | cut -c 2-)
rules:
- if: $CI_COMMIT_TAG
when: delayed
start_in: 10 minutes
build-image-dev:
before_script: []
image: docker:24.0
stage: docker
services:
- docker:24.0-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
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker run --privileged --rm tonistiigi/binfmt --uninstall qemu-*
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --use --name new-builder
docker buildx build . --tag $IMAGE_TAG --file docker/Dockerfile --platform linux/amd64,linux/arm64 --push --build-arg AUTH_PACKAGE=git+https://gitlab.com/allianceauth/allianceauth@$CI_COMMIT_BRANCH
rules:
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == ""'
when: manual
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME != ""'
when: never
build-image-mr:
before_script: []
image: docker:24.0
stage: docker
services:
- docker:24.0-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
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker run --privileged --rm tonistiigi/binfmt --uninstall qemu-*
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --use --name new-builder
docker buildx build . --tag $IMAGE_TAG --file docker/Dockerfile --platform linux/amd64,linux/arm64 --push --build-arg AUTH_PACKAGE=git+$CI_MERGE_REQUEST_SOURCE_PROJECT_URL@$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: manual
- if: '$CI_PIPELINE_SOURCE != "merge_request_event"'
when: never

View File

@ -3,93 +3,26 @@
# Update this file: # Update this file:
# pre-commit autoupdate # pre-commit autoupdate
# Set the default language versions for the hooks
default_language_version:
python: python3 # Force all Python hooks to use Python 3
node: 22.12.0 # Force all Node hooks to use Node 22.12.0
# Globally exclude files
# https://pre-commit.com/#top_level-exclude
exclude: |
(?x)(
LICENSE|
allianceauth\/static\/allianceauth\/css\/themes\/bootstrap-locals.less|
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json|
static/(.*)/libs/
)
repos: 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]
# Formatting
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v4.0.1
hooks: hooks:
# Identify invalid files
- id: check-ast
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-xml
# git checks
- id: check-merge-conflict
- id: check-added-large-files
args: [--maxkb=1000]
- id: detect-private-key
- id: check-case-conflict - id: check-case-conflict
# Python checks - id: check-json
# - id: check-docstring-first - id: check-xml
- id: debug-statements - id: check-yaml
# - id: requirements-txt-fixer
- id: fix-encoding-pragma
args: [--remove]
- id: fix-byte-order-marker - id: fix-byte-order-marker
# General quality checks
- id: mixed-line-ending
args: [--fix=lf]
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] exclude: (\.min\.css|\.min\.js|\.mo|\.po|swagger\.json)$
- id: check-executables-have-shebangs
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: (\.min\.css|\.min\.js|\.mo|\.po|swagger\.json)$
- id: mixed-line-ending
args: [ '--fix=lf' ]
- id: fix-encoding-pragma
args: [ '--remove' ]
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python - repo: https://github.com/editorconfig-checker/editorconfig-checker.python
rev: 3.2.1 rev: 2.3.5
hooks: hooks:
- id: editorconfig-checker - id: editorconfig-checker
- repo: https://github.com/igorshubovych/markdownlint-cli exclude: ^(LICENSE|allianceauth\/static\/css\/themes\/bootstrap-locals.less|allianceauth\/eveonline\/swagger.json|(.*.po)|(.*.mo))
rev: v0.45.0
hooks:
- id: markdownlint
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
- 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

@ -5,28 +5,19 @@
# Required # Required
version: 2 version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
apt_packages:
- redis
tools:
python: "3.11"
jobs:
post_system_dependencies:
- redis-server --daemonize yes
# Build documentation in the docs/ directory with Sphinx # Build documentation in the docs/ directory with Sphinx
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
# Build documentation with MkDocs
#mkdocs:
# configuration: mkdocs.yml
# Optionally build your docs in additional formats such as PDF and ePub # Optionally build your docs in additional formats such as PDF and ePub
formats: all formats: all
# Python requirements required to build your docs # Optionally set the version of Python and requirements required to build your docs
python: python:
version: 3.7
install: install:
- method: pip - requirements: docs/requirements.txt
path: .
extra_requirements:
- docs

View File

@ -1,10 +0,0 @@
filters:
- filter_type: file
file_format: PO
source_file: allianceauth/locale/en/LC_MESSAGES/django.po
source_language: en
translation_files_expression: allianceauth/locale/<lang>/LC_MESSAGES/django.po
settings:
language_mapping:
zh-Hans: zh_Hans

7
MANIFEST.in Normal file
View File

@ -0,0 +1,7 @@
include LICENSE
include README.md
include MANIFEST.in
graft allianceauth
global-exclude __pycache__
global-exclude *.py[co]

46
README.md Normal file → Executable file
View File

@ -1,56 +1,50 @@
# Alliance Auth # Alliance Auth
[![License](https://img.shields.io/badge/license-GPLv2-green)](https://pypi.org/project/allianceauth/) [![license](https://img.shields.io/badge/license-GPLv2-green)](https://pypi.org/project/allianceauth/)
[![Python Versions](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/) [![python](https://img.shields.io/pypi/pyversions/allianceauth)](https://pypi.org/project/allianceauth/)
[![Django Versions](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/) [![django](https://img.shields.io/pypi/djversions/allianceauth?label=django)](https://pypi.org/project/allianceauth/)
[![Stable AA Version](https://img.shields.io/pypi/v/allianceauth?label=release)](https://pypi.org/project/allianceauth/) [![version](https://img.shields.io/pypi/v/allianceauth?label=release)](https://pypi.org/project/allianceauth/)
[![Pipeline Status](https://gitlab.com/allianceauth/allianceauth/badges/master/pipeline.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master) [![pipeline status](https://gitlab.com/allianceauth/allianceauth/badges/master/pipeline.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master)
[![Documentation Status](https://readthedocs.org/projects/allianceauth/badge/?version=latest)](https://allianceauth.readthedocs.io/?badge=latest) [![Documentation Status](https://readthedocs.org/projects/allianceauth/badge/?version=latest)](http://allianceauth.readthedocs.io/?badge=latest)
[![Test Coverage Report](https://gitlab.com/allianceauth/allianceauth/badges/master/coverage.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master) [![coverage report](https://gitlab.com/allianceauth/allianceauth/badges/master/coverage.svg)](https://gitlab.com/allianceauth/allianceauth/commits/master)
[![Chat on Discord](https://img.shields.io/discord/399006117012832262.svg)](https://discord.gg/fjnHAmk) [![Chat on Discord](https://img.shields.io/discord/399006117012832262.svg)](https://discord.gg/fjnHAmk)
A flexible authentication platform for EVE Online to help in-game organizations manage access to applications and services. AA provides both, a stable core, and a robust framework for community development and custom applications. An auth system for EVE Online to help in-game organizations manage online service access.
## Content ## Content
- [Overview](#overview) - [Overview](#overview)
- [Documentation](https://allianceauth.rtfd.io) - [Documentation](http://allianceauth.rtfd.io)
- [Support](#support) - [Support](#support)
- [Release Notes](https://gitlab.com/allianceauth/allianceauth/-/releases) - [Release Notes](https://gitlab.com/allianceauth/allianceauth/-/releases)
- [Developer Team](#development-team) - [Developer Team](#developer-team)
- [Contributing](#contributing) - [Contributing](#contributing)
## Overview ## Overview
Alliance Auth (AA) is a platform that helps Eve Online organizations efficiently manage access to applications and services. Alliance Auth (AA) is a web site that helps Eve Online organizations efficiently manage access to applications and services.
Main features: Main features:
- Automatically grants or revokes user access to external services (e.g.: Discord, Mumble) based on the user's current membership to [a variety of EVE Online affiliation](https://allianceauth.readthedocs.io/en/latest/features/core/states/) and [groups](https://allianceauth.readthedocs.io/en/latest/features/core/groups/) - Automatically grants or revokes user access to external services (e.g. Discord, Mumble) and web apps (e.g. SRP requests) based on the user's current membership to [in-game organizations](https://allianceauth.readthedocs.io/en/latest/features/core/states/) and [groups](https://allianceauth.readthedocs.io/en/latest/features/core/groups/)
- Provides a central web site where users can directly access web apps (e.g. SRP requests, Fleet Schedule) and manage their access to external services and groups. - Provides a central web site where users can directly access web apps (e.g. SRP requests, Fleet Schedule) and manage their access to external services and groups.
- Includes a set of connectors (called ["Services"](https://allianceauth.readthedocs.io/en/latest/features/services/)) for integrating access management with many popular external applications / services like Discord, Mumble, Teamspeak 3, SMF and others - Includes a set of connectors (called ["services"](https://allianceauth.readthedocs.io/en/latest/features/services/)) for integrating access management with many popular external applications / services like Discord, Mumble, Teamspeak 3, SMF and others
- Includes a set of web [Apps](https://allianceauth.readthedocs.io/en/latest/features/apps/) which add many useful functions, e.g.: fleet schedule, timer board, SRP request management, fleet activity tracker - Includes a set of web [apps](https://allianceauth.readthedocs.io/en/latest/features/apps/) which add many useful functions, e.g.: fleet schedule, timer board, SRP request management, fleet activity tracker
- Can be easily extended with additional services and apps. Many are provided by the community and can be found here: [Community Creations](https://gitlab.com/allianceauth/community-creations) - Can be easily extended with additional services and apps. Many are provided by the community and can be found here: [Community Creations](https://gitlab.com/allianceauth/community-creations)
- English :flag_gb:, Chinese :flag_cn:, German :flag_de:, Spanish :flag_es:, Korean :flag_kr:, Russian :flag_ru:, Italian :flag_it:, French :flag_fr:, Japanese :flag_jp: and Ukrainian :flag_ua: Localization - English :flag_gb:, Chinese :flag_cn:, German :flag_de:, Spanish :flag_es:, Korean :flag_kr: and Russian :flag_ru: localization
For further details about AA - including an installation guide and a full list of included services and plugin apps - please see the [official documentation](https://allianceauth.rtfd.io). For further details about AA - including an installation guide and a full list of included services and plugin apps - please see the [official documentation](http://allianceauth.rtfd.io).
## Screenshot ## Screenshot
Here is an example of the Alliance Auth web site with a mixture of Services, Apps and Community Creations enabled: Here is an example of the Alliance Auth web site with some plug-ins apps and services enabled:
### Flatly Theme ![screenshot](https://i.imgur.com/2tnX9kD.png)
![Flatly Theme](docs/_static/images/promotion/SampleInstallation-Flatly.png)
### Darkly Theme
![Darkly Theme](docs/_static/images/promotion/SampleInstallation-Darkly.png)
## Support ## Support
@ -62,15 +56,13 @@ Here is an example of the Alliance Auth web site with a mixture of Services, App
- [Aaron Kable](https://gitlab.com/aaronkable/) - [Aaron Kable](https://gitlab.com/aaronkable/)
- [Ariel Rin](https://gitlab.com/soratidus999/) - [Ariel Rin](https://gitlab.com/soratidus999/)
- [Basraah](https://gitlab.com/basraah/)
- [Col Crunch](https://gitlab.com/colcrunch/) - [Col Crunch](https://gitlab.com/colcrunch/)
- [Erik Kalkoken](https://gitlab.com/ErikKalkoken/) - [Erik Kalkoken](https://gitlab.com/ErikKalkoken/)
- [Rounon Dax](https://gitlab.com/ppfeufer)
- [snipereagle1](https://gitlab.com/mckernanin)
### Former Developers ### Former Developers
- [Adarnof](https://gitlab.com/adarnof/) - [Adarnof](https://gitlab.com/adarnof/)
- [Basraah](https://gitlab.com/basraah/)
### Beta Testers / Bug Fixers ### Beta Testers / Bug Fixers

View File

@ -1,11 +1,8 @@
"""An auth system for EVE Online to help in-game organizations
manage online service access.
"""
# This will make sure the app is always imported when # This will make sure the app is always imported when
# Django starts so that shared_task will use this app. # Django starts so that shared_task will use this app.
__version__ = '4.8.0' __version__ = '2.9.0'
__title__ = 'Alliance Auth' __title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth' __url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}' NAME = '%s v%s' % (__title__, __version__)
default_app_config = 'allianceauth.apps.AllianceAuthConfig'

View File

@ -0,0 +1 @@
default_app_config = 'allianceauth.analytics.apps.AnalyticsConfig'

View File

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

View File

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

View File

@ -3,10 +3,11 @@
"model": "analytics.AnalyticsTokens", "model": "analytics.AnalyticsTokens",
"pk": 1, "pk": 1,
"fields": { "fields": {
"name": "AA Team Public Google Analytics (V4)", "name": "AA Team Public Google Analytics (Universal)",
"type": "GA-V4", "type": "GA-V4",
"token": "G-6LYSMYK8DE", "token": "UA-186249766-2",
"secret": "KLlpjLZ-SRGozS5f5wb_kw", "send_page_views": "False",
"send_celery_tasks": "False",
"send_stats": "False" "send_stats": "False"
} }
}, },

View File

@ -0,0 +1,49 @@
from bs4 import BeautifulSoup
from django.utils.deprecation import MiddlewareMixin
from .models import AnalyticsTokens, AnalyticsIdentifier
from .tasks import send_ga_tracking_web_view
import re
class AnalyticsMiddleware(MiddlewareMixin):
def process_response(self, request, response):
"""Django Middleware: Process Page Views and creates Analytics Celery Tasks"""
analyticstokens = AnalyticsTokens.objects.all()
client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
try:
title = BeautifulSoup(
response.content, "html.parser").html.head.title.text
except AttributeError:
title = ''
for token in analyticstokens:
# Check if Page View Sending is Disabled
if token.send_page_views is False:
continue
# Check Exclusions
ignore = False
for ignore_path in token.ignore_paths.values():
ignore_path_regex = re.compile(ignore_path["ignore_path"])
if re.search(ignore_path_regex, request.path) is not None:
ignore = True
if ignore is True:
continue
tracking_id = token.token
locale = request.LANGUAGE_CODE
path = request.path
try:
useragent = request.headers["User-Agent"]
except KeyError:
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
send_ga_tracking_web_view.s(tracking_id=tracking_id,
client_id=client_id,
page=path,
title=title,
locale=locale,
useragent=useragent).\
apply_async(priority=9)
return response

View File

@ -1,11 +1,11 @@
# Generated by Django 3.1.13 on 2021-10-15 05:02 # Generated by Django 3.1.13 on 2021-10-15 05:02
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations from django.db import migrations
def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor): def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# Add /admin/ and /user_notifications_count/ path to ignore # We can't import the Person model directly as it may be a newer
# version than this migration expects. We use the historical version.
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath') AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
admin = AnalyticsPath.objects.create(ignore_path=r"^\/admin\/.*") admin = AnalyticsPath.objects.create(ignore_path=r"^\/admin\/.*")
@ -17,19 +17,8 @@ def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor): def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# # nothing should need to migrate away here?
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath') return True
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
try:
admin = AnalyticsPath.objects.get(ignore_path=r"^\/admin\/.*", analyticstokens=token)
user_notifications_count = AnalyticsPath.objects.get(ignore_path=r"^\/user_notifications_count\/.*", analyticstokens=token)
admin.delete()
user_notifications_count.delete()
except ObjectDoesNotExist:
# Its fine if it doesnt exist, we just dont want them building up when re-migrating
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-17 16:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('analytics', '0004_auto_20211015_0502'),
]
operations = [
migrations.AlterField(
model_name='analyticspath',
name='ignore_path',
field=models.CharField(default='/example/', help_text='Regex Expression, If matched no Analytics Page View is sent', max_length=254),
),
]

View File

@ -1,40 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-19 01:47
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations
def modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
# Add the /account/activate path to ignore
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
account_activate = AnalyticsPath.objects.create(ignore_path=r"^\/account\/activate\/.*")
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
token.ignore_paths.add(account_activate)
def undo_modify_aa_team_token_add_page_ignore_paths(apps, schema_editor):
#
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
token = Tokens.objects.get(token="UA-186249766-2")
try:
account_activate = AnalyticsPath.objects.get(ignore_path=r"^\/account\/activate\/.*", analyticstokens=token)
account_activate.delete()
except ObjectDoesNotExist:
# Its fine if it doesnt exist, we just dont want them building up when re-migrating
pass
class Migration(migrations.Migration):
dependencies = [
('analytics', '0005_alter_analyticspath_ignore_path'),
]
operations = [
migrations.RunPython(modify_aa_team_token_add_page_ignore_paths, undo_modify_aa_team_token_add_page_ignore_paths)
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.0.6 on 2022-08-30 05:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('analytics', '0006_more_ignore_paths'),
]
operations = [
migrations.AddField(
model_name='analyticstokens',
name='secret',
field=models.CharField(blank=True, max_length=254),
),
]

View File

@ -1,64 +0,0 @@
# Generated by Django 3.1.4 on 2020-12-30 08:53
from django.db import migrations
from django.core.exceptions import ObjectDoesNotExist
def add_aa_team_token(apps, schema_editor):
# We can't import the Person model directly as it may be a newer
# version than this migration expects. We use the historical version.
Tokens = apps.get_model('analytics', 'AnalyticsTokens')
AnalyticsPath = apps.get_model('analytics', 'AnalyticsPath')
token = Tokens()
try:
ua_token = Tokens.objects.get(token="UA-186249766-2")
original_send_page_views = ua_token.send_page_views
original_send_celery_tasks = ua_token.send_celery_tasks
original_send_stats = ua_token.send_stats
except ObjectDoesNotExist:
original_send_page_views = True
original_send_celery_tasks = True
original_send_stats = True
try:
user_notifications_count = AnalyticsPath.objects.get(ignore_path=r"^\/user_notifications_count\/.*",)
except ObjectDoesNotExist:
user_notifications_count = AnalyticsPath.objects.create(ignore_path=r"^\/user_notifications_count\/.*")
try:
admin = AnalyticsPath.objects.get(ignore_path=r"^\/admin\/.*")
except ObjectDoesNotExist:
admin = AnalyticsPath.objects.create(ignore_path=r"^\/admin\/.*")
try:
account_activate = AnalyticsPath.objects.get(ignore_path=r"^\/account\/activate\/.*")
except ObjectDoesNotExist:
account_activate = AnalyticsPath.objects.create(ignore_path=r"^\/account\/activate\/.*")
token.type = 'GA-V4'
token.token = 'G-6LYSMYK8DE'
token.secret = 'KLlpjLZ-SRGozS5f5wb_kw'
token.send_page_views = original_send_page_views
token.send_celery_tasks = original_send_celery_tasks
token.send_stats = original_send_stats
token.name = 'AA Team Public Google Analytics (V4)'
token.save()
token.ignore_paths.add(admin, user_notifications_count, account_activate)
token.save()
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()
class Migration(migrations.Migration):
dependencies = [
('analytics', '0007_analyticstokens_secret'),
]
operations = [migrations.RunPython(
add_aa_team_token, remove_aa_team_token)]

View File

@ -1,28 +0,0 @@
# Generated by Django 4.0.10 on 2023-05-08 05:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('analytics', '0008_add_AA_GA-4_Team_Token '),
]
operations = [
migrations.RemoveField(
model_name='analyticstokens',
name='ignore_paths',
),
migrations.RemoveField(
model_name='analyticstokens',
name='send_celery_tasks',
),
migrations.RemoveField(
model_name='analyticstokens',
name='send_page_views',
),
migrations.DeleteModel(
name='AnalyticsPath',
),
]

View File

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

View File

@ -1,19 +1,26 @@
from typing import Literal
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from solo.models import SingletonModel
from uuid import uuid4 from uuid import uuid4
class AnalyticsIdentifier(SingletonModel): class AnalyticsIdentifier(models.Model):
identifier = models.UUIDField(default=uuid4, editable=False) identifier = models.UUIDField(default=uuid4,
editable=False)
def __str__(self) -> Literal['Analytics Identifier']: def save(self, *args, **kwargs):
return "Analytics Identifier" if not self.pk and AnalyticsIdentifier.objects.exists():
# Force a single object
raise ValidationError('There is can be only one \
AnalyticsIdentifier instance')
self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1
return super(AnalyticsIdentifier, self).save(*args, **kwargs)
class Meta:
verbose_name = "Analytics Identifier" class AnalyticsPath(models.Model):
ignore_path = models.CharField(max_length=254, default="/example/", help_text="Regex Expression, If matched no Analytics Page View is sent")
class AnalyticsTokens(models.Model): class AnalyticsTokens(models.Model):
@ -25,5 +32,7 @@ class AnalyticsTokens(models.Model):
name = models.CharField(max_length=254) name = models.CharField(max_length=254)
type = models.CharField(max_length=254, choices=Analytics_Type.choices) type = models.CharField(max_length=254, choices=Analytics_Type.choices)
token = models.CharField(max_length=254, blank=False) token = models.CharField(max_length=254, blank=False)
secret = models.CharField(max_length=254, blank=True) send_page_views = models.BooleanField(default=False)
send_celery_tasks = models.BooleanField(default=False)
send_stats = models.BooleanField(default=False) send_stats = models.BooleanField(default=False)
ignore_paths = models.ManyToManyField(AnalyticsPath, blank=True)

View File

@ -0,0 +1,50 @@
from allianceauth.analytics.tasks import analytics_event
from celery.signals import task_failure, task_success
import logging
logger = logging.getLogger(__name__)
@task_failure.connect
def process_failure_signal(
exception, traceback,
sender, task_id, signal,
args, kwargs, einfo, **kw):
logger.debug("Celery task_failure signal %s" % sender.__class__.__name__)
category = sender.__module__
if 'allianceauth.analytics' not in category:
if category.endswith(".tasks"):
category = category[:-6]
action = sender.__name__
label = f"{exception.__class__.__name__}"
analytics_event(category=category,
action=action,
label=label)
@task_success.connect
def celery_success_signal(sender, result=None, **kw):
logger.debug("Celery task_success signal %s" % sender.__class__.__name__)
category = sender.__module__
if 'allianceauth.analytics' not in category:
if category.endswith(".tasks"):
category = category[:-6]
action = sender.__name__
label = "Success"
value = 0
if isinstance(result, int):
value = result
analytics_event(category=category,
action=action,
label=label,
value=value)

View File

@ -3,28 +3,26 @@ import logging
from django.conf import settings from django.conf import settings
from django.apps import apps from django.apps import apps
from celery import shared_task from celery import shared_task
from allianceauth import __version__
from .models import AnalyticsTokens, AnalyticsIdentifier from .models import AnalyticsTokens, AnalyticsIdentifier
from .utils import ( from .utils import (
existence_baremetal_or_docker,
install_stat_addons, install_stat_addons,
install_stat_tokens, install_stat_tokens,
install_stat_users) install_stat_users)
from allianceauth import __version__
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BASE_URL = "https://www.google-analytics.com" BASE_URL = "https://www.google-analytics.com/"
DEBUG_URL = f"{BASE_URL}/debug/mp/collect" DEBUG_URL = f"{BASE_URL}debug/collect"
COLLECTION_URL = f"{BASE_URL}/mp/collect" COLLECTION_URL = f"{BASE_URL}collect"
if getattr(settings, "ANALYTICS_ENABLE_DEBUG", False) and settings.DEBUG: if getattr(settings, "ANALYTICS_ENABLE_DEBUG", False) and settings.DEBUG:
# Force sending of analytics data during in a debug/test environment # Force sending of analytics data during in a debug/test environemt
# Useful for developers working on this feature. # Usefull for developers working on this feature.
logger.warning( logger.warning(
"You have 'ANALYTICS_ENABLE_DEBUG' Enabled! " "You have 'ANALYTICS_ENABLE_DEBUG' Enabled! "
"This debug instance will send analytics data!") "This debug instance will send analytics data!")
DEBUG_URL = COLLECTION_URL DEBUG_URL = COLLECTION_URL
ANALYTICS_URL = COLLECTION_URL ANALYTICS_URL = COLLECTION_URL
@ -33,175 +31,177 @@ if settings.DEBUG is True:
ANALYTICS_URL = DEBUG_URL ANALYTICS_URL = DEBUG_URL
def analytics_event(namespace: str, def analytics_event(category: str,
task: str, action: str,
label: str = "", label: str,
result: str = "", value: int = 0,
value: int = 1,
event_type: str = 'Celery'): event_type: str = 'Celery'):
""" """
Send a Google Analytics Event for each token stored Send a Google Analytics Event for each token stored
Includes check for if its enabled/disabled Includes check for if its enabled/disabled
Args: Parameters
`namespace` (str): Celery Namespace -------
`task` (str): Task Name `category` (str): Celery Namespace
`label` (str): Optional, additional task label `action` (str): Task Name
`result` (str): Optional, Task Success/Exception `label` (str): Optional, Task Success/Exception
`value` (int): Optional, If bulk, Query size, can be a Boolean `value` (int): Optional, If bulk, Query size, can be a binary True/False
`event_type` (str): Optional, Celery or Stats only, Default to Celery `event_type` (str): Optional, Celery or Stats only, Default to Celery
""" """
for token in AnalyticsTokens.objects.filter(type='GA-V4'): analyticstokens = AnalyticsTokens.objects.all()
if event_type == 'Stats': client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
for token in analyticstokens:
if event_type == 'Celery':
allowed = token.send_celery_tasks
elif event_type == 'Stats':
allowed = token.send_stats allowed = token.send_stats
else: else:
allowed = False allowed = False
if allowed is True: if allowed is True:
send_ga_tracking_celery_event.s( tracking_id = token.token
measurement_id=token.token, send_ga_tracking_celery_event.s(tracking_id=tracking_id,
secret=token.secret, client_id=client_id,
namespace=namespace, category=category,
task=task, action=action,
label=label, label=label,
result=result, value=value).\
value=value).apply_async(priority=9) apply_async(priority=9)
@shared_task @shared_task()
def analytics_daily_stats() -> None: def analytics_daily_stats():
"""Celery Task: Do not call directly """Celery Task: Do not call directly
Gathers a series of daily statistics Gathers a series of daily statistics and sends analytics events containing them"""
Sends analytics events containing them
"""
users = install_stat_users() users = install_stat_users()
tokens = install_stat_tokens() tokens = install_stat_tokens()
addons = install_stat_addons() addons = install_stat_addons()
existence_type = existence_baremetal_or_docker()
logger.debug("Running Daily Analytics Upload") logger.debug("Running Daily Analytics Upload")
analytics_event(namespace='allianceauth.analytics', analytics_event(category='allianceauth.analytics',
task='send_install_stats', action='send_install_stats',
label='existence', label='existence',
value=1, value=1,
event_type='Stats') event_type='Stats')
analytics_event(namespace='allianceauth.analytics', analytics_event(category='allianceauth.analytics',
task='send_install_stats', action='send_install_stats',
label=existence_type,
value=1,
event_type='Stats')
analytics_event(namespace='allianceauth.analytics',
task='send_install_stats',
label='users', label='users',
value=users, value=users,
event_type='Stats') event_type='Stats')
analytics_event(namespace='allianceauth.analytics', analytics_event(category='allianceauth.analytics',
task='send_install_stats', action='send_install_stats',
label='tokens', label='tokens',
value=tokens, value=tokens,
event_type='Stats') event_type='Stats')
analytics_event(namespace='allianceauth.analytics', analytics_event(category='allianceauth.analytics',
task='send_install_stats', action='send_install_stats',
label='addons', label='addons',
value=addons, value=addons,
event_type='Stats') event_type='Stats')
for appconfig in apps.get_app_configs(): for appconfig in apps.get_app_configs():
if appconfig.label in [ analytics_event(category='allianceauth.analytics',
"django_celery_beat", action='send_extension_stats',
"bootstrapform", label=appconfig.label,
"messages", value=1,
"sessions", event_type='Stats')
"auth",
"staticfiles",
"users",
"addons",
"admin",
"humanize",
"contenttypes",
"sortedm2m",
"django_bootstrap5",
"tokens",
"authentication",
"services",
"framework",
"notifications"
"eveonline",
"navhelper",
"analytics",
"menu",
"theme"
]:
pass
else:
analytics_event(namespace='allianceauth.analytics',
task='send_extension_stats',
label=appconfig.label,
value=1,
event_type='Stats')
@shared_task @shared_task()
def send_ga_tracking_celery_event( def send_ga_tracking_web_view(
measurement_id: str, tracking_id: str,
secret: str, client_id: str,
namespace: str, page: str,
task: str, title: str,
label: str = "", locale: str,
result: str = "", useragent: str) -> requests.Response:
value: int = 1):
"""Celery Task: Do not call directly """Celery Task: Do not call directly
Sends an events to GA Sends Page View events to GA, Called only via analytics.middleware
Parameters Parameters
---------- ----------
`measurement_id` (str): GA Token `tracking_id` (str): Unique Server Identifier
`secret` (str): GA Authentication Secret `client_id` (str): GA Token
`namespace` (str): Celery Namespace `page` (str): Page Path
`task` (str): Task Name `title` (str): Page Title
`label` (str): Optional, additional task label `locale` (str): Browser Language
`result` (str): Optional, Task Success/Exception `useragent` (str): Browser UserAgent
`value` (int): Optional, If bulk, Query size, can be a binary True/False
"""
parameters = { Returns
'measurement_id': measurement_id, -------
'api_secret': secret requests.Reponse Object
} """
headers = {"User-Agent": useragent}
payload = { payload = {
'client_id': AnalyticsIdentifier.get_solo().identifier.hex, 'v': '1',
"user_properties": { 'tid': tracking_id,
"allianceauth_version": { 'cid': client_id,
"value": __version__ 't': 'pageview',
'dp': page,
'dt': title,
'ul': locale,
'ua': useragent,
'aip': 1,
'an': "allianceauth",
'av': __version__
} }
},
'non_personalized_ads': True, response = requests.post(
"events": [{ ANALYTICS_URL, data=payload,
"name": "celery_event", timeout=5, headers=headers)
"params": { logger.debug(f"Analytics Page View HTTP{response.status_code}")
"namespace": namespace, return response
"task": task,
'result': result,
'label': label, @shared_task()
"value": value def send_ga_tracking_celery_event(
tracking_id: str,
client_id: str,
category: str,
action: str,
label: str,
value: int) -> requests.Response:
"""Celery Task: Do not call directly
Sends Page View events to GA, Called only via analytics.middleware
Parameters
----------
`tracking_id` (str): Unique Server Identifier
`client_id` (str): GA Token
`category` (str): Celery Namespace
`action` (str): Task Name
`label` (str): Optional, Task Success/Exception
`value` (int): Optional, If bulk, Query size, can be a binary True/False
Returns
-------
requests.Reponse Object
"""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"}
payload = {
'v': '1',
'tid': tracking_id,
'cid': client_id,
't': 'event',
'ec': category,
'ea': action,
'el': label,
'ev': value,
'aip': 1,
'an': "allianceauth",
'av': __version__
} }
}]
} response = requests.post(
try: ANALYTICS_URL, data=payload,
response = requests.post( timeout=5, headers=headers)
ANALYTICS_URL, logger.debug(f"Analytics Celery/Stats Event HTTP{response.status_code}")
params=parameters, return response
json=payload,
timeout=10)
response.raise_for_status()
logger.debug(
f"Analytics Celery/Stats Event HTTP{response.status_code}")
return response.status_code
except requests.exceptions.HTTPError as e:
logger.debug(e)
return response.status_code
except requests.exceptions.ConnectionError as e:
logger.debug(e)
return "Failed"

View File

@ -0,0 +1,23 @@
from allianceauth.analytics.middleware import AnalyticsMiddleware
from unittest.mock import Mock
from django.test.testcases import TestCase
class TestAnalyticsMiddleware(TestCase):
def setUp(self):
self.middleware = AnalyticsMiddleware()
self.request = Mock()
self.request.headers = {
"User-Agent": "AUTOMATED TEST"
}
self.request.path = '/testURL/'
self.request.session = {}
self.request.LANGUAGE_CODE = 'en'
self.response = Mock()
self.response.content = 'hello world'
def test_middleware(self):
response = self.middleware.process_response(self.request, self.response)
self.assertEqual(self.response, response)

View File

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

View File

@ -1,69 +1,119 @@
import requests_mock
from django.test.utils import override_settings
from allianceauth.analytics.tasks import ( from allianceauth.analytics.tasks import (
analytics_event, analytics_event,
send_ga_tracking_celery_event) send_ga_tracking_celery_event,
from allianceauth.utils.testing import NoSocketsTestCase send_ga_tracking_web_view)
from django.test.testcases import TestCase
GOOGLE_ANALYTICS_DEBUG_URL = 'https://www.google-analytics.com/debug/mp/collect' class TestAnalyticsTasks(TestCase):
def test_analytics_event(self):
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
@requests_mock.Mocker()
class TestAnalyticsTasks(NoSocketsTestCase):
def test_analytics_event(self, requests_mocker):
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL)
analytics_event( analytics_event(
namespace='allianceauth.analytics', category='allianceauth.analytics',
task='send_tests', action='send_tests',
label='test', label='test',
value=1, value=1,
result="Success", event_type='Stats')
event_type='Stats')
def test_send_ga_tracking_celery_event_sent(self, requests_mocker): def test_send_ga_tracking_web_view_sent(self):
# given # This test sends if the event SENDS to google
requests_mocker.register_uri('POST', GOOGLE_ANALYTICS_DEBUG_URL) # Not if it was successful
token = 'G-6LYSMYK8DE' tracking_id = 'UA-186249766-2'
secret = 'KLlpjLZ-SRGozS5f5wb_kw', client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
response = send_ga_tracking_web_view(
tracking_id,
client_id,
page,
title,
locale,
useragent)
self.assertEqual(response.status_code, 200)
def test_send_ga_tracking_web_view_success(self):
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
json_response = send_ga_tracking_web_view(
tracking_id,
client_id,
page,
title,
locale,
useragent).json()
self.assertTrue(json_response["hitParsingResult"][0]["valid"])
def test_send_ga_tracking_web_view_invalid_token(self):
tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
page = '/index/'
title = 'Hello World'
locale = 'en'
useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
json_response = send_ga_tracking_web_view(
tracking_id,
client_id,
page,
title,
locale,
useragent).json()
self.assertFalse(json_response["hitParsingResult"][0]["valid"])
self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.")
# [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}]
def test_send_ga_tracking_celery_event_sent(self):
tracking_id = 'UA-186249766-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test' category = 'test'
action = 'test' action = 'test'
label = 'test' label = 'test'
value = '1' value = '1'
# when response = send_ga_tracking_celery_event(
task = send_ga_tracking_celery_event( tracking_id,
token, client_id,
secret, category,
category, action,
action, label,
label, value)
value) self.assertEqual(response.status_code, 200)
# then
self.assertEqual(task, 200)
def test_send_ga_tracking_celery_event_success(self, requests_mocker): def test_send_ga_tracking_celery_event_success(self):
# given tracking_id = 'UA-186249766-2'
requests_mocker.register_uri( client_id = 'ab33e241fbf042b6aa77c7655a768af7'
'POST',
GOOGLE_ANALYTICS_DEBUG_URL,
json={"validationMessages": []}
)
token = 'G-6LYSMYK8DE'
secret = 'KLlpjLZ-SRGozS5f5wb_kw',
category = 'test' category = 'test'
action = 'test' action = 'test'
label = 'test' label = 'test'
value = '1' value = '1'
# when json_response = send_ga_tracking_celery_event(
task = send_ga_tracking_celery_event( tracking_id,
token, client_id,
secret, category,
category, action,
action, label,
label, value).json()
value) self.assertTrue(json_response["hitParsingResult"][0]["valid"])
# then
self.assertTrue(task, 200) def test_send_ga_tracking_celery_event_invalid_token(self):
tracking_id = 'UA-IntentionallyBadTrackingID-2'
client_id = 'ab33e241fbf042b6aa77c7655a768af7'
category = 'test'
action = 'test'
label = 'test'
value = '1'
json_response = send_ga_tracking_celery_event(
tracking_id,
client_id,
category,
action,
label,
value).json()
self.assertFalse(json_response["hitParsingResult"][0]["valid"])
self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.")
# [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}]

View File

@ -18,17 +18,17 @@ def create_testdata():
'abc@example.com', 'abc@example.com',
'password' 'password'
) )
# Token.objects.all().delete() #Token.objects.all().delete()
# Token.objects.create( #Token.objects.create(
# character_id=101, # character_id=101,
# character_name='character1', # character_name='character1',
# access_token='my_access_token' # access_token='my_access_token'
# ) #)
# Token.objects.create( #Token.objects.create(
# character_id=102, # character_id=102,
# character_name='character2', # character_name='character2',
# access_token='my_access_token' # access_token='my_access_token'
# ) #)
class TestAnalyticsUtils(TestCase): class TestAnalyticsUtils(TestCase):
@ -40,7 +40,7 @@ class TestAnalyticsUtils(TestCase):
users = install_stat_users() users = install_stat_users()
self.assertEqual(users, expected) self.assertEqual(users, expected)
# def test_install_stat_tokens(self): #def test_install_stat_tokens(self):
# create_testdata() # create_testdata()
# expected = 2 # expected = 2
# #

View File

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

View File

@ -3,6 +3,3 @@ from django.apps import AppConfig
class AllianceAuthConfig(AppConfig): class AllianceAuthConfig(AppConfig):
name = 'allianceauth' name = 'allianceauth'
def ready(self) -> None:
import allianceauth.checks # noqa

View File

@ -0,0 +1 @@
default_app_config = 'allianceauth.authentication.apps.AuthenticationConfig'

View File

@ -1,44 +1,26 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import User as BaseUser, \
from django.contrib.auth.models import Permission as BasePermission Permission as BasePermission, Group
from django.contrib.auth.models import User as BaseUser
from django.db.models import Count, Q from django.db.models import Count, Q
from allianceauth.services.hooks import ServicesHook
from django.db.models.signals import pre_save, post_save, pre_delete, \
post_delete, m2m_changed
from django.db.models.functions import Lower 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.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.forms import ModelForm
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from allianceauth.authentication.models import ( from allianceauth.authentication.models import State, get_guest_state,\
CharacterOwnership, CharacterOwnership, UserProfile, OwnershipRecord
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.hooks import get_hooks
from allianceauth.services.hooks import ServicesHook from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\
EveAllianceInfo
from .app_settings import ( from allianceauth.eveonline.tasks import update_character
AUTHENTICATION_ADMIN_USERS_MAX_CHARS, from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_GROUPS, \
AUTHENTICATION_ADMIN_USERS_MAX_GROUPS AUTHENTICATION_ADMIN_USERS_MAX_CHARS
)
from .forms import UserChangeForm, UserProfileForm
def make_service_hooks_update_groups_action(service): def make_service_hooks_update_groups_action(service):
@ -54,8 +36,8 @@ def make_service_hooks_update_groups_action(service):
for user in queryset: # queryset filtering doesn't work here? for user in queryset: # queryset filtering doesn't work here?
service.update_groups(user) service.update_groups(user)
update_service_groups.__name__ = str(f'update_{slugify(service.name)}_groups') update_service_groups.__name__ = str('update_{}_groups'.format(slugify(service.name)))
update_service_groups.short_description = f"Sync groups for selected {service.title} accounts" update_service_groups.short_description = "Sync groups for selected {} accounts".format(service.title)
return update_service_groups return update_service_groups
@ -72,15 +54,24 @@ def make_service_hooks_sync_nickname_action(service):
for user in queryset: # queryset filtering doesn't work here? for user in queryset: # queryset filtering doesn't work here?
service.sync_nickname(user) service.sync_nickname(user)
sync_nickname.__name__ = str(f'sync_{slugify(service.name)}_nickname') sync_nickname.__name__ = str('sync_{}_nickname'.format(slugify(service.name)))
sync_nickname.short_description = f"Sync nicknames for selected {service.title} accounts" sync_nickname.short_description = "Sync nicknames for selected {} accounts".format(service.title)
return sync_nickname return sync_nickname
class QuerysetModelForm(ModelForm):
# allows specifying FK querysets through kwarg
def __init__(self, querysets=None, *args, **kwargs):
querysets = querysets or {}
super().__init__(*args, **kwargs)
for field, qs in querysets.items():
self.fields[field].queryset = qs
class UserProfileInline(admin.StackedInline): class UserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
readonly_fields = ('state',) readonly_fields = ('state',)
form = UserProfileForm form = QuerysetModelForm
verbose_name = '' verbose_name = ''
verbose_name_plural = 'Profile' verbose_name_plural = 'Profile'
@ -108,7 +99,6 @@ class UserProfileInline(admin.StackedInline):
return False return False
@admin.display(description="")
def user_profile_pic(obj): def user_profile_pic(obj):
"""profile pic column data for user objects """profile pic column data for user objects
@ -121,10 +111,13 @@ def user_profile_pic(obj):
'<img src="{}" class="img-circle">', '<img src="{}" class="img-circle">',
user_obj.profile.main_character.portrait_url(size=32) user_obj.profile.main_character.portrait_url(size=32)
) )
return None else:
return None
user_profile_pic.short_description = ''
@admin.display(description="user / main", ordering="username")
def user_username(obj): def user_username(obj):
"""user column data for user objects """user column data for user objects
@ -146,17 +139,18 @@ def user_username(obj):
user_obj.username, user_obj.username,
user_obj.profile.main_character.character_name user_obj.profile.main_character.character_name
) )
return format_html( else:
'<strong><a href="{}">{}</a></strong>', return format_html(
link, '<strong><a href="{}">{}</a></strong>',
user_obj.username, link,
) user_obj.username,
)
user_username.short_description = 'user / main'
user_username.admin_order_field = 'username'
@admin.display(
description="Corporation / Alliance (Main)",
ordering="profile__main_character__corporation_name"
)
def user_main_organization(obj): def user_main_organization(obj):
"""main organization column data for user objects """main organization column data for user objects
@ -165,13 +159,23 @@ def user_main_organization(obj):
""" """
user_obj = obj.user if hasattr(obj, 'user') else obj user_obj = obj.user if hasattr(obj, 'user') else obj
if not user_obj.profile.main_character: if not user_obj.profile.main_character:
return '' result = None
result = user_obj.profile.main_character.corporation_name else:
if user_obj.profile.main_character.alliance_id: corporation = user_obj.profile.main_character.corporation_name
result += f'<br>{user_obj.profile.main_character.alliance_name}' if user_obj.profile.main_character.alliance_id:
elif user_obj.profile.main_character.faction_name: result = format_html(
result += f'<br>{user_obj.profile.main_character.faction_name}' '{}<br>{}',
return format_html(result) corporation,
user_obj.profile.main_character.alliance_name
)
else:
result = corporation
return result
user_main_organization.short_description = 'Corporation / Alliance (Main)'
user_main_organization.admin_order_field = \
'profile__main_character__corporation_name'
class MainCorporationsFilter(admin.SimpleListFilter): class MainCorporationsFilter(admin.SimpleListFilter):
@ -190,19 +194,21 @@ class MainCorporationsFilter(admin.SimpleListFilter):
.distinct()\ .distinct()\
.order_by(Lower('corporation_name')) .order_by(Lower('corporation_name'))
return tuple( return tuple(
(x['corporation_id'], x['corporation_name']) for x in qs [(x['corporation_id'], x['corporation_name']) for x in qs]
) )
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
if qs.model == User: else:
return qs.filter( if qs.model == User:
profile__main_character__corporation_id=self.value() return qs.filter(
) profile__main_character__corporation_id=self.value()
return qs.filter( )
user__profile__main_character__corporation_id=self.value() else:
) return qs.filter(
user__profile__main_character__corporation_id=self.value()
)
class MainAllianceFilter(admin.SimpleListFilter): class MainAllianceFilter(admin.SimpleListFilter):
@ -215,61 +221,28 @@ class MainAllianceFilter(admin.SimpleListFilter):
parameter_name = 'main_alliance_id__exact' parameter_name = 'main_alliance_id__exact'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = ( qs = EveCharacter.objects\
EveCharacter.objects .exclude(alliance_id=None)\
.exclude(alliance_id=None) .exclude(userprofile=None)\
.exclude(userprofile=None) .values('alliance_id', 'alliance_name')\
.values('alliance_id', 'alliance_name') .distinct()\
.distinct()
.order_by(Lower('alliance_name')) .order_by(Lower('alliance_name'))
)
return tuple( return tuple(
(x['alliance_id'], x['alliance_name']) for x in qs [(x['alliance_id'], x['alliance_name']) for x in qs]
) )
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
if qs.model == User: else:
return qs.filter(profile__main_character__alliance_id=self.value()) if qs.model == User:
return qs.filter( return qs.filter(profile__main_character__alliance_id=self.value())
user__profile__main_character__alliance_id=self.value() else:
) return qs.filter(
user__profile__main_character__alliance_id=self.value()
)
class MainFactionFilter(admin.SimpleListFilter):
"""Custom filter to filter on factions from mains only
works for both User objects and objects with `user` as FK to User
To be used for all user based admin lists
"""
title = 'faction'
parameter_name = 'main_faction_id__exact'
def lookups(self, request, model_admin):
qs = (
EveCharacter.objects
.exclude(faction_id=None)
.exclude(userprofile=None)
.values('faction_id', 'faction_name')
.distinct()
.order_by(Lower('faction_name'))
)
return tuple(
(x['faction_id'], x['faction_name']) for x in qs
)
def queryset(self, request, qs):
if self.value() is None:
return qs.all()
if qs.model == User:
return qs.filter(profile__main_character__faction_id=self.value())
return qs.filter(
user__profile__main_character__faction_id=self.value()
)
@admin.display(description="Update main character model from ESI")
def update_main_character_model(modeladmin, request, queryset): def update_main_character_model(modeladmin, request, queryset):
tasks_count = 0 tasks_count = 0
for obj in queryset: for obj in queryset:
@ -278,72 +251,33 @@ def update_main_character_model(modeladmin, request, queryset):
tasks_count += 1 tasks_count += 1
modeladmin.message_user( modeladmin.message_user(
request, f'Update from ESI started for {tasks_count} characters' request,
'Update from ESI started for {} characters'.format(tasks_count)
) )
update_main_character_model.short_description = \
'Update main character model from ESI'
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
"""Extending Django's UserAdmin model """Extending Django's UserAdmin model
Behavior of groups and characters columns can be configured via settings Behavior of groups and characters columns can be configured via settings
""" """
inlines = [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
MainFactionFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = ('username', 'character_ownerships__character__character_name')
readonly_fields = ('date_joined', 'last_login')
filter_horizontal = ('groups', 'user_permissions',)
form = UserChangeForm
class Media: class Media:
css = { css = {
"all": ("allianceauth/authentication/css/admin.css",) "all": ("authentication/css/admin.css",)
} }
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("character_ownerships__character", "groups") return qs.prefetch_related("character_ownerships__character", "groups")
def get_form(self, request, obj=None, **kwargs):
"""Inject current request into change form object."""
MyForm = super().get_form(request, obj, **kwargs)
if obj:
class MyFormInjected(MyForm):
def __new__(cls, *args, **kwargs):
kwargs['request'] = request
return MyForm(*args, **kwargs)
return MyFormInjected
return MyForm
def get_actions(self, request): def get_actions(self, request):
actions = super().get_actions(request) actions = super(BaseUserAdmin, self).get_actions(request)
actions[update_main_character_model.__name__] = ( actions[update_main_character_model.__name__] = (
update_main_character_model, update_main_character_model,
update_main_character_model.__name__, update_main_character_model.__name__,
@ -387,6 +321,37 @@ class UserAdmin(BaseUserAdmin):
) )
return result return result
inlines = BaseUserAdmin.inlines + [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = (
'username',
'character_ownerships__character__character_name'
)
def _characters(self, obj): def _characters(self, obj):
character_ownerships = list(obj.character_ownerships.all()) character_ownerships = list(obj.character_ownerships.all())
characters = [obj.character.character_name for obj in character_ownerships] characters = [obj.character.character_name for obj in character_ownerships]
@ -395,16 +360,22 @@ class UserAdmin(BaseUserAdmin):
AUTHENTICATION_ADMIN_USERS_MAX_CHARS AUTHENTICATION_ADMIN_USERS_MAX_CHARS
) )
@admin.display(ordering="profile__state") _characters.short_description = 'characters'
def _state(self, obj): def _state(self, obj):
return obj.profile.state.name return obj.profile.state.name
_state.short_description = 'state'
_state.admin_order_field = 'profile__state'
def _groups(self, obj): def _groups(self, obj):
my_groups = sorted(group.name for group in list(obj.groups.all())) my_groups = sorted([group.name for group in list(obj.groups.all())])
return self._list_2_html_w_tooltips( return self._list_2_html_w_tooltips(
my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
) )
_groups.short_description = 'groups'
def _role(self, obj): def _role(self, obj):
if obj.is_superuser: if obj.is_superuser:
role = 'Superuser' role = 'Superuser'
@ -414,6 +385,8 @@ class UserAdmin(BaseUserAdmin):
role = 'User' role = 'User'
return role return role
_role.short_description = 'role'
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_user') return request.user.has_perm('auth.change_user')
@ -423,28 +396,12 @@ class UserAdmin(BaseUserAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return request.user.has_perm('auth.delete_user') return request.user.has_perm('auth.delete_user')
def get_object(self, *args , **kwargs):
obj = super().get_object(*args , **kwargs)
self.obj = obj # storing current object for use in formfield_for_manytomany
return obj
def formfield_for_manytomany(self, db_field, request, **kwargs): def formfield_for_manytomany(self, db_field, request, **kwargs):
"""overriding this formfield to have sorted lists in the form"""
if db_field.name == "groups": if db_field.name == "groups":
groups_qs = Group.objects.filter(authgroup__states__isnull=True) kwargs["queryset"] = Group.objects.all().order_by(Lower('name'))
obj_state = self.obj.profile.state
if obj_state:
matching_groups_qs = Group.objects.filter(authgroup__states=obj_state)
groups_qs = groups_qs | matching_groups_qs
kwargs["queryset"] = groups_qs.order_by(Lower("name"))
return super().formfield_for_manytomany(db_field, request, **kwargs) return super().formfield_for_manytomany(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
if obj and not request.user.is_superuser:
return self.readonly_fields + (
"is_staff", "is_superuser", "user_permissions"
)
return self.readonly_fields
@admin.register(State) @admin.register(State)
class StateAdmin(admin.ModelAdmin): class StateAdmin(admin.ModelAdmin):
@ -455,9 +412,10 @@ class StateAdmin(admin.ModelAdmin):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.annotate(user_count=Count("userprofile__id")) return qs.annotate(user_count=Count("userprofile__id"))
@admin.display(description="Users", ordering="user_count")
def _user_count(self, obj): def _user_count(self, obj):
return obj.user_count return obj.user_count
_user_count.short_description = 'Users'
_user_count.admin_order_field = 'user_count'
fieldsets = ( fieldsets = (
(None, { (None, {
@ -468,8 +426,7 @@ class StateAdmin(admin.ModelAdmin):
'public', 'public',
'member_characters', 'member_characters',
'member_corporations', 'member_corporations',
'member_alliances', 'member_alliances'
'member_factions'
), ),
}) })
) )
@ -477,7 +434,6 @@ class StateAdmin(admin.ModelAdmin):
'member_characters', 'member_characters',
'member_corporations', 'member_corporations',
'member_alliances', 'member_alliances',
'member_factions',
'permissions' 'permissions'
] ]
@ -492,9 +448,6 @@ class StateAdmin(admin.ModelAdmin):
elif db_field.name == "member_alliances": elif db_field.name == "member_alliances":
kwargs["queryset"] = EveAllianceInfo.objects.all()\ kwargs["queryset"] = EveAllianceInfo.objects.all()\
.order_by(Lower('alliance_name')) .order_by(Lower('alliance_name'))
elif db_field.name == "member_factions":
kwargs["queryset"] = EveFactionInfo.objects.all()\
.order_by(Lower('faction_name'))
elif db_field.name == "permissions": elif db_field.name == "permissions":
kwargs["queryset"] = Permission.objects.select_related("content_type").all() kwargs["queryset"] = Permission.objects.select_related("content_type").all()
return super().formfield_for_manytomany(db_field, request, **kwargs) return super().formfield_for_manytomany(db_field, request, **kwargs)
@ -502,7 +455,7 @@ class StateAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
if obj == get_guest_state(): if obj == get_guest_state():
return False return False
return super().has_delete_permission(request, obj=obj) return super(StateAdmin, self).has_delete_permission(request, obj=obj)
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
if obj == get_guest_state(): if obj == get_guest_state():
@ -511,15 +464,15 @@ class StateAdmin(admin.ModelAdmin):
'fields': ('permissions', 'priority'), 'fields': ('permissions', 'priority'),
}), }),
) )
return super().get_fieldsets(request, obj=obj) return super(StateAdmin, self).get_fieldsets(request, obj=obj)
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("permissions",)
return self.readonly_fields
class BaseOwnershipAdmin(admin.ModelAdmin): class BaseOwnershipAdmin(admin.ModelAdmin):
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
list_select_related = ( list_select_related = (
'user__profile__state', 'user__profile__main_character', 'character') 'user__profile__state', 'user__profile__main_character', 'character')
list_display = ( list_display = (
@ -532,19 +485,13 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
'user__username', 'user__username',
'character__character_name', 'character__character_name',
'character__corporation_name', 'character__corporation_name',
'character__alliance_name', 'character__alliance_name'
'character__faction_name'
) )
list_filter = ( list_filter = (
MainCorporationsFilter, MainCorporationsFilter,
MainAllianceFilter, MainAllianceFilter,
) )
class Media:
css = {
"all": ("allianceauth/authentication/css/admin.css",)
}
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj and obj.pk: if obj and obj.pk:
return 'owner_hash', 'character' return 'owner_hash', 'character'

View File

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

View File

@ -1,59 +0,0 @@
from allianceauth.hooks import DashboardItemHook
from allianceauth import hooks
from .views import dashboard_characters, dashboard_esi_check, dashboard_groups, dashboard_admin
class UserCharactersHook(DashboardItemHook):
def __init__(self):
DashboardItemHook.__init__(
self,
dashboard_characters,
5
)
class UserGroupsHook(DashboardItemHook):
def __init__(self):
DashboardItemHook.__init__(
self,
dashboard_groups,
5
)
class AdminHook(DashboardItemHook):
def __init__(self):
DashboardItemHook.__init__(
self,
dashboard_admin,
1
)
class ESICheckHook(DashboardItemHook):
def __init__(self):
DashboardItemHook.__init__(
self,
dashboard_esi_check,
0
)
@hooks.register('dashboard_hook')
def register_character_hook():
return UserCharactersHook()
@hooks.register('dashboard_hook')
def register_groups_hook():
return UserGroupsHook()
@hooks.register('dashboard_hook')
def register_admin_hook():
return AdminHook()
@hooks.register('dashboard_hook')
def register_esi_hook():
return ESICheckHook()

View File

@ -36,21 +36,17 @@ class StateBackend(ModelBackend):
try: try:
ownership = CharacterOwnership.objects.get(character__character_id=token.character_id) ownership = CharacterOwnership.objects.get(character__character_id=token.character_id)
if ownership.owner_hash == token.character_owner_hash: if ownership.owner_hash == token.character_owner_hash:
logger.debug(f'Authenticating {ownership.user} by ownership of character {token.character_name}') logger.debug('Authenticating {0} by ownership of character {1}'.format(ownership.user, token.character_name))
if ownership.user.profile.main_character: return ownership.user
if ownership.user.profile.main_character.character_id == token.character_id:
return ownership.user
else: # this is an alt, enforce main only.
return None
else: else:
logger.debug(f'{token.character_name} has changed ownership. Creating new user account.') logger.debug('{0} has changed ownership. Creating new user account.'.format(token.character_name))
ownership.delete() ownership.delete()
return self.create_user(token) return self.create_user(token)
except CharacterOwnership.DoesNotExist: except CharacterOwnership.DoesNotExist:
try: try:
# insecure legacy main check for pre-sso registration auth installs # insecure legacy main check for pre-sso registration auth installs
profile = UserProfile.objects.get(main_character__character_id=token.character_id) profile = UserProfile.objects.get(main_character__character_id=token.character_id)
logger.debug(f'Authenticating {profile.user} by their main character {profile.main_character} without active ownership.') logger.debug('Authenticating {0} by their main character {1} without active ownership.'.format(profile.user, profile.main_character))
# attach an ownership # attach an ownership
token.user = profile.user token.user = profile.user
CharacterOwnership.objects.create_by_token(token) CharacterOwnership.objects.create_by_token(token)
@ -61,20 +57,15 @@ class StateBackend(ModelBackend):
if records.exists(): if records.exists():
# we've seen this character owner before. Re-attach to their old user account # we've seen this character owner before. Re-attach to their old user account
user = records[0].user user = records[0].user
if user.profile.main_character:
if user.profile.main_character.character_id != token.character_id:
# this is an alt, enforce main only due to trust issues in SSO.
return None
token.user = user token.user = user
co = CharacterOwnership.objects.create_by_token(token) co = CharacterOwnership.objects.create_by_token(token)
logger.debug(f'Authenticating {user} by matching owner hash record of character {co.character}') logger.debug('Authenticating {0} by matching owner hash record of character {1}'.format(user, co.character))
if not user.profile.main_character:
# set this as their main by default as they have none # set this as their main by default if they have none
user.profile.main_character = co.character user.profile.main_character = co.character
user.profile.save() user.profile.save()
return user return user
logger.debug(f'Unable to authenticate character {token.character_name}. Creating new user.') logger.debug('Unable to authenticate character {0}. Creating new user.'.format(token.character_name))
return self.create_user(token) return self.create_user(token)
def create_user(self, token): def create_user(self, token):
@ -86,7 +77,7 @@ class StateBackend(ModelBackend):
co = CharacterOwnership.objects.create_by_token(token) # assign ownership to this user co = CharacterOwnership.objects.create_by_token(token) # assign ownership to this user
user.profile.main_character = co.character # assign main character as token character user.profile.main_character = co.character # assign main character as token character
user.profile.save() user.profile.save()
logger.debug(f'Created new user {user}') logger.debug('Created new user {0}'.format(user))
return user return user
@staticmethod @staticmethod
@ -96,10 +87,10 @@ class StateBackend(ModelBackend):
if User.objects.filter(username__startswith=name).exists(): if User.objects.filter(username__startswith=name).exists():
u = User.objects.filter(username__startswith=name) u = User.objects.filter(username__startswith=name)
num = len(u) num = len(u)
username = f"{name}_{num}" username = "%s_%s" % (name, num)
while u.filter(username=username).exists(): while u.filter(username=username).exists():
num += 1 num += 1
username = f"{name}_{num}" username = "%s_%s" % (name, num)
else: else:
username = name username = name
return username return username

View File

@ -1,12 +0,0 @@
from django.utils.translation import gettext_lazy as _
# Overide ESI messages in the dashboard widget
# when the returned messages are not helpful or out of date
ESI_ERROR_MESSAGE_OVERRIDES = {
420: _("This software has exceeded the error limit for ESI. "
"If you are a user, please contact the maintainer of this software."
" If you are a developer/maintainer, please make a greater "
"effort in the future to receive valid responses. For tips on how, "
"come have a chat with us in ##3rd-party-dev-and-esi on the EVE "
"Online Discord. https://www.eveonline.com/discord")
}

View File

@ -1,48 +0,0 @@
"""API for interacting with celery workers."""
import itertools
import logging
from typing import Optional
from amqp.exceptions import ChannelError
from celery import current_app
from django.conf import settings
logger = logging.getLogger(__name__)
def active_tasks_count() -> Optional[int]:
"""Return count of currently active tasks
or None if celery workers are not online.
"""
inspect = current_app.control.inspect()
return _tasks_count(inspect.active())
def _tasks_count(data: dict) -> Optional[int]:
"""Return count of tasks in data from celery inspect API."""
try:
tasks = itertools.chain(*data.values())
except AttributeError:
return None
return len(list(tasks))
def queued_tasks_count() -> Optional[int]:
"""Return count of queued tasks. Return None if there was an error."""
try:
with current_app.connection_or_acquire() as conn:
result = conn.default_channel.queue_declare(
queue=getattr(settings, "CELERY_DEFAULT_QUEUE", "celery"), passive=True
)
return result.message_count
except ChannelError:
# Queue doesn't exist, probably empty
return 0
except Exception:
logger.exception("Failed to get celery queue length")
return None

View File

@ -1,31 +1,18 @@
from django.urls import include from django.conf.urls import include
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from functools import wraps from functools import wraps
from typing import Callable, Iterable, Optional
from django.urls import include
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect from django.shortcuts import redirect
from django.contrib import messages
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.decorators import login_required
def user_has_main_character(user): def user_has_main_character(user):
return bool(user.profile.main_character) return bool(user.profile.main_character)
def decorate_url_patterns( def decorate_url_patterns(urls, decorator):
urls, decorator: Callable, excluded_views: Optional[Iterable] = None
):
"""Decorate views given in url patterns except when they are explicitly excluded.
Args:
- urls: Django URL patterns
- decorator: Decorator to be added to each view
- exclude_views: Optional iterable of view names to be excluded
"""
url_list, app_name, namespace = include(urls) url_list, app_name, namespace = include(urls)
def process_patterns(url_patterns): def process_patterns(url_patterns):
@ -35,8 +22,6 @@ def decorate_url_patterns(
process_patterns(pattern.url_patterns) process_patterns(pattern.url_patterns)
else: else:
# this is a pattern # this is a pattern
if excluded_views and pattern.lookup_str in excluded_views:
return
pattern.callback = decorator(pattern.callback) pattern.callback = decorator(pattern.callback)
process_patterns(url_list) process_patterns(url_list)

View File

@ -1,66 +1,8 @@
from django import forms from django import forms
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from allianceauth.authentication.models import User from allianceauth.authentication.models import User
class RegistrationForm(forms.Form): class RegistrationForm(forms.Form):
email = forms.EmailField(label=_('Email'), max_length=254, required=True) email = forms.EmailField(label=_('Email'), max_length=254, required=True)
class _meta: class _meta:
model = User model = User
class UserProfileForm(ModelForm):
"""Allows specifying FK querysets through kwarg"""
def __init__(self, querysets=None, *args, **kwargs):
querysets = querysets or {}
super().__init__(*args, **kwargs)
for field, qs in querysets.items():
self.fields[field].queryset = qs
class UserChangeForm(BaseUserChangeForm):
"""Add custom cleaning to UserChangeForm"""
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request") # Inject current request into form object
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
if not self.request.user.is_superuser:
if self.instance:
current_restricted = set(
self.instance.groups.filter(
authgroup__restricted=True
).values_list("pk", flat=True)
)
else:
current_restricted = set()
new_restricted = set(
cleaned_data["groups"].filter(
authgroup__restricted=True
).values_list("pk", flat=True)
)
if current_restricted != new_restricted:
restricted_removed = current_restricted - new_restricted
restricted_added = new_restricted - current_restricted
restricted_changed = restricted_removed | restricted_added
restricted_names_qs = Group.objects.filter(
pk__in=restricted_changed
).values_list("name", flat=True)
restricted_names = ",".join(list(restricted_names_qs))
raise ValidationError(
{
"groups": _(
"You are not allowed to add or remove these "
"restricted groups: %s" % restricted_names
)
}
)

View File

@ -1,13 +1,14 @@
from django.conf.urls import url, include
from allianceauth.authentication import views from allianceauth.authentication import views
from django.urls import include, re_path, path
urlpatterns = [ urlpatterns = [
path('activate/complete/', views.activation_complete, name='registration_activation_complete'), url(r'^activate/complete/$', views.activation_complete, name='registration_activation_complete'),
# The activation key can make use of any character from the # The activation key can make use of any character from the
# URL-safe base64 alphabet, plus the colon as a separator. # URL-safe base64 alphabet, plus the colon as a separator.
re_path(r'^activate/(?P<activation_key>[-:\w]+)/$', views.ActivationView.as_view(), name='registration_activate'), url(r'^activate/(?P<activation_key>[-:\w]+)/$', views.ActivationView.as_view(), name='registration_activate'),
path('register/', views.RegistrationView.as_view(), name='registration_register'), url(r'^register/$', views.RegistrationView.as_view(), name='registration_register'),
path('register/complete/', views.registration_complete, name='registration_complete'), url(r'^register/complete/$', views.registration_complete, name='registration_complete'),
path('register/closed/', views.registration_closed, name='registration_disallowed'), url(r'^register/closed/$', views.registration_closed, name='registration_disallowed'),
path('', include('django.contrib.auth.urls')), url(r'', include('django.contrib.auth.urls')),
] ]

View File

@ -11,10 +11,10 @@ class Command(BaseCommand):
if profiles.exists(): if profiles.exists():
for profile in profiles: for profile in profiles:
self.stdout.write(self.style.ERROR( self.stdout.write(self.style.ERROR(
'{} does not have an ownership. Resetting user {} main character.'.format(profile.main_character, '{0} does not have an ownership. Resetting user {1} main character.'.format(profile.main_character,
profile.user))) profile.user)))
profile.main_character = None profile.main_character = None
profile.save() profile.save()
self.stdout.write(self.style.WARNING(f'Reset {profiles.count()} main characters.')) self.stdout.write(self.style.WARNING('Reset {0} main characters.'.format(profiles.count())))
else: else:
self.stdout.write(self.style.SUCCESS('All main characters have active ownership.')) self.stdout.write(self.style.SUCCESS('All main characters have active ownership.'))

4
allianceauth/authentication/managers.py Normal file → Executable file
View File

@ -16,8 +16,6 @@ def available_states_query(character):
query |= Q(member_corporations__corporation_id=character.corporation_id) query |= Q(member_corporations__corporation_id=character.corporation_id)
if character.alliance_id: if character.alliance_id:
query |= Q(member_alliances__alliance_id=character.alliance_id) query |= Q(member_alliances__alliance_id=character.alliance_id)
if character.faction_id:
query |= Q(member_factions__faction_id=character.faction_id)
return query return query
@ -51,7 +49,7 @@ class StateQuerySet(QuerySet):
for state in self: for state in self:
for profile in state.userprofile_set.all(): for profile in state.userprofile_set.all():
profile.assign_state(state=self.model.objects.exclude(pk=state.pk).get_for_user(profile.user)) profile.assign_state(state=self.model.objects.exclude(pk=state.pk).get_for_user(profile.user))
super().delete() super(StateQuerySet, self).delete()
class StateManager(Manager): class StateManager(Manager):

View File

@ -1,55 +0,0 @@
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
import logging
logger = logging.getLogger(__name__)
class UserSettingsMiddleware(MiddlewareMixin):
def process_response(self, request, response):
"""Django Middleware: User Settings."""
# Intercept the built in django /setlang/ view and also save it to Database.
# Note the annoymous user check, only logged in users will ever hit the DB here
if request.path == '/i18n/setlang/' and not request.user.is_anonymous:
try:
request.user.profile.language = request.POST['language']
request.user.profile.save()
except Exception as e:
logger.exception(e)
# Only act during the login flow, _after_ user is activated (step 2: post-sso)
elif request.path == '/sso/login' and not request.user.is_anonymous:
# Set the Language Cookie, if it doesnt match the DB
# Null = hasnt been set by the user ever, dont act.
try:
if request.user.profile.language != request.LANGUAGE_CODE and request.user.profile.language is not None:
response.set_cookie(key=settings.LANGUAGE_COOKIE_NAME,
value=request.user.profile.language,
max_age=settings.LANGUAGE_COOKIE_AGE)
except Exception as e:
logger.exception(e)
# AA v3 NIGHT_MODE
# Set our Night mode flag from the DB
# Null = hasnt been set by the user ever, dont act.
#
# Night mode intercept is not needed in this middleware.
# is saved direct to DB in NightModeRedirectView
try:
if request.user.profile.night_mode is not None:
request.session["NIGHT_MODE"] = request.user.profile.night_mode
except Exception as e:
logger.exception(e)
# AA v4 Themes
# Null = has not been set by the user ever, dont act
# DEFAULT_THEME or DEFAULT_THEME_DARK will be used in get_theme()
try:
if request.user.profile.theme is not None:
request.session["THEME"] = request.user.profile.theme
except Exception as e:
logger.exception(e)
return response

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-05 21:38 # Generated by Django 1.10.1 on 2016-09-05 21:38
from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-07 19:14 # Generated by Django 1.10.1 on 2016-09-07 19:14
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-09 20:29 # Generated by Django 1.10.1 on 2016-09-09 20:29
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-09 23:19 # Generated by Django 1.10.1 on 2016-09-09 23:19
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-09 23:11 # Generated by Django 1.10.1 on 2016-09-09 23:11
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-10 05:42 # Generated by Django 1.10.1 on 2016-09-10 05:42
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-10 21:50 # Generated by Django 1.10.1 on 2016-09-10 21:50
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-12 13:04 # Generated by Django 1.10.1 on 2016-09-12 13:04
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.2 on 2016-10-21 02:28 # Generated by Django 1.10.2 on 2016-10-21 02:28
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2017-01-07 06:47 # Generated by Django 1.10.1 on 2017-01-07 06:47
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations
@ -8,7 +9,7 @@ def count_completed_fields(model):
def forward(apps, schema_editor): def forward(apps, schema_editor):
# this ensures only one model exists per user # this ensures only one model exists per user
AuthServicesInfo = apps.get_model('authentication', 'AuthServicesInfo') AuthServicesInfo = apps.get_model('authentication', 'AuthServicesInfo')
users = {a.user for a in AuthServicesInfo.objects.all()} users = set([a.user for a in AuthServicesInfo.objects.all()])
for u in users: for u in users:
auths = AuthServicesInfo.objects.filter(user=u) auths = AuthServicesInfo.objects.filter(user=u)
if auths.count() > 1: if auths.count() > 1:

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2017-01-07 07:11 # Generated by Django 1.10.1 on 2017-01-07 07:11
from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.5 on 2017-01-12 00:59 # Generated by Django 1.10.5 on 2017-01-12 00:59
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.2 on 2016-12-11 23:14 # Generated by Django 1.10.2 on 2016-12-11 23:14
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.1 on 2016-09-09 23:19 # Generated by Django 1.10.1 on 2016-09-09 23:19
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,4 +1,5 @@
# Generated by Django 1.10.5 on 2017-03-22 23:09 # Generated by Django 1.10.5 on 2017-03-22 23:09
from __future__ import unicode_literals
import allianceauth.authentication.models import allianceauth.authentication.models
import django.db.models.deletion import django.db.models.deletion

View File

@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.db import migrations from django.db import migrations

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-20 05:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0017_remove_fleetup_permission'),
]
operations = [
migrations.AlterField(
model_name='state',
name='name',
field=models.CharField(max_length=32, unique=True),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 3.1.13 on 2021-10-12 20:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0015_factions'),
('authentication', '0017_remove_fleetup_permission'),
]
operations = [
migrations.AddField(
model_name='state',
name='member_factions',
field=models.ManyToManyField(blank=True, help_text='Factions to whose members this state is available.', to='eveonline.EveFactionInfo'),
),
]

View File

@ -1,14 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-26 09:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0018_alter_state_name_length'),
('authentication', '0018_state_member_factions'),
]
operations = [
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.0.2 on 2022-02-26 03:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0019_merge_20211026_0919'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='language',
field=models.CharField(blank=True, choices=[('en', 'English'), ('de', 'German'), ('es', 'Spanish'), ('zh-hans', 'Chinese Simplified'), ('ru', 'Russian'), ('ko', 'Korean'), ('fr', 'French'), ('ja', 'Japanese'), ('it', 'Italian')], default='', max_length=10, verbose_name='Language'),
),
migrations.AddField(
model_name='userprofile',
name='night_mode',
field=models.BooleanField(blank=True, null=True, verbose_name='Night Mode'),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.0.10 on 2023-05-28 15:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentication", "0020_userprofile_language_userprofile_night_mode"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="language",
field=models.CharField(
blank=True,
choices=[
("en", "English"),
("de", "German"),
("es", "Spanish"),
("zh-hans", "Chinese Simplified"),
("ru", "Russian"),
("ko", "Korean"),
("fr", "French"),
("ja", "Japanese"),
("it", "Italian"),
("uk", "Ukrainian"),
],
default="",
max_length=10,
verbose_name="Language",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.0.10 on 2023-10-07 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0021_alter_userprofile_language'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='theme',
field=models.CharField(blank=True, help_text='Bootstrap 5 Themes from https://bootswatch.com/ or Community Apps', max_length=200, null=True, verbose_name='Theme'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.13 on 2024-05-12 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0022_userprofile_theme'),
]
operations = [
migrations.AlterField(
model_name='userprofile',
name='language',
field=models.CharField(blank=True, choices=[('en', 'English'), ('de', 'German'), ('es', 'Spanish'), ('zh-hans', 'Chinese Simplified'), ('ru', 'Russian'), ('ko', 'Korean'), ('fr', 'French'), ('ja', 'Japanese'), ('it', 'Italian'), ('uk', 'Ukrainian'), ('pl', 'Polish')], default='', max_length=10, verbose_name='Language'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2 on 2024-09-13 09:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0023_alter_userprofile_language'),
]
operations = [
migrations.AlterField(
model_name='userprofile',
name='language',
field=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'),
),
]

84
allianceauth/authentication/models.py Normal file → Executable file
View File

@ -1,12 +1,10 @@
import logging import logging
from typing import ClassVar
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import User, Permission
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo
from allianceauth.notifications import notify from allianceauth.notifications import notify
from django.conf import settings
from .managers import CharacterOwnershipManager, StateManager from .managers import CharacterOwnershipManager, StateManager
@ -14,21 +12,16 @@ logger = logging.getLogger(__name__)
class State(models.Model): class State(models.Model):
name = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=20, unique=True)
permissions = models.ManyToManyField(Permission, blank=True) permissions = models.ManyToManyField(Permission, blank=True)
priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.") priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.")
member_characters = models.ManyToManyField(EveCharacter, blank=True, member_characters = models.ManyToManyField(EveCharacter, blank=True, help_text="Characters to which this state is available.")
help_text="Characters to which this state is available.") member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True, help_text="Corporations to whose members this state is available.")
member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True, member_alliances = models.ManyToManyField(EveAllianceInfo, blank=True, help_text="Alliances to whose members this state is available.")
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.") public = models.BooleanField(default=False, help_text="Make this state available to any character.")
objects: ClassVar[StateManager] = StateManager() objects = StateManager()
class Meta: class Meta:
ordering = ['-priority'] ordering = ['-priority']
@ -46,7 +39,7 @@ class State(models.Model):
with transaction.atomic(): with transaction.atomic():
for profile in self.userprofile_set.all(): for profile in self.userprofile_set.all():
profile.assign_state(state=State.objects.exclude(pk=self.pk).get_for_user(profile.user)) profile.assign_state(state=State.objects.exclude(pk=self.pk).get_for_user(profile.user))
super().delete(**kwargs) super(State, self).delete(**kwargs)
def get_guest_state(): def get_guest_state():
@ -64,54 +57,9 @@ class UserProfile(models.Model):
class Meta: class Meta:
default_permissions = ('change',) default_permissions = ('change',)
class Language(models.TextChoices): 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)
Choices for UserProfile.language state = models.ForeignKey(State, on_delete=models.SET_DEFAULT, default=get_guest_state_pk)
"""
# 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(
_("Theme"),
max_length=200,
blank=True,
null=True,
help_text="Bootstrap 5 Themes from https://bootswatch.com/ or Community Apps"
)
def assign_state(self, state=None, commit=True): def assign_state(self, state=None, commit=True):
if not state: if not state:
@ -119,7 +67,7 @@ class UserProfile(models.Model):
if self.state != state: if self.state != state:
self.state = state self.state = state
if commit: if commit:
logger.info(f'Updating {self.user} state to {self.state}') logger.info('Updating {} state to {}'.format(self.user, self.state))
self.save(update_fields=['state']) self.save(update_fields=['state'])
notify( notify(
self.user, self.user,
@ -138,7 +86,7 @@ class UserProfile(models.Model):
sender=self.__class__, user=self.user, state=self.state sender=self.__class__, user=self.user, state=self.state
) )
def __str__(self) -> str: def __str__(self):
return str(self.user) return str(self.user)
@ -151,10 +99,10 @@ class CharacterOwnership(models.Model):
owner_hash = models.CharField(max_length=28, unique=True) owner_hash = models.CharField(max_length=28, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='character_ownerships') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='character_ownerships')
objects: ClassVar[CharacterOwnershipManager] = CharacterOwnershipManager() objects = CharacterOwnershipManager()
def __str__(self): def __str__(self):
return f"{self.user}: {self.character}" return "%s: %s" % (self.user, self.character)
class OwnershipRecord(models.Model): class OwnershipRecord(models.Model):
@ -167,4 +115,4 @@ class OwnershipRecord(models.Model):
ordering = ['-created'] ordering = ['-created']
def __str__(self): def __str__(self):
return f"{self.user}: {self.character} on {self.created}" return "%s: %s on %s" % (self.user, self.character, self.created)

View File

@ -1,11 +1,6 @@
import logging import logging
from .models import ( from .models import CharacterOwnership, UserProfile, get_guest_state, State, OwnershipRecord
CharacterOwnership,
UserProfile,
get_guest_state,
State,
OwnershipRecord)
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed
@ -16,7 +11,7 @@ from allianceauth.eveonline.models import EveCharacter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
state_changed = Signal() state_changed = Signal(providing_args=['user', 'state'])
def trigger_state_check(state): def trigger_state_check(state):
@ -34,32 +29,27 @@ def trigger_state_check(state):
@receiver(m2m_changed, sender=State.member_characters.through) @receiver(m2m_changed, sender=State.member_characters.through)
def state_member_characters_changed(sender, instance, action, *args, **kwargs): def state_member_characters_changed(sender, instance, action, *args, **kwargs):
if action.startswith('post_'): if action.startswith('post_'):
logger.debug(f'State {instance} member characters changed. Re-evaluating membership.') logger.debug('State {} member characters changed. Re-evaluating membership.'.format(instance))
trigger_state_check(instance) trigger_state_check(instance)
@receiver(m2m_changed, sender=State.member_corporations.through) @receiver(m2m_changed, sender=State.member_corporations.through)
def state_member_corporations_changed(sender, instance, action, *args, **kwargs): def state_member_corporations_changed(sender, instance, action, *args, **kwargs):
if action.startswith('post_'): if action.startswith('post_'):
logger.debug(f'State {instance} member corporations changed. Re-evaluating membership.') logger.debug('State {} member corporations changed. Re-evaluating membership.'.format(instance))
trigger_state_check(instance) trigger_state_check(instance)
@receiver(m2m_changed, sender=State.member_alliances.through) @receiver(m2m_changed, sender=State.member_alliances.through)
def state_member_alliances_changed(sender, instance, action, *args, **kwargs): def state_member_alliances_changed(sender, instance, action, *args, **kwargs):
if action.startswith('post_'): if action.startswith('post_'):
logger.debug(f'State {instance} member alliances changed. Re-evaluating membership.') logger.debug('State {} member alliances changed. Re-evaluating membership.'.format(instance))
trigger_state_check(instance) trigger_state_check(instance)
@receiver(m2m_changed, sender=State.member_factions.through)
def state_member_factions_changed(sender, instance, action, *args, **kwargs):
if action.startswith('post_'):
logger.debug(f'State {instance} member factions changed. Re-evaluating membership.')
trigger_state_check(instance)
@receiver(post_save, sender=State) @receiver(post_save, sender=State)
def state_saved(sender, instance, *args, **kwargs): def state_saved(sender, instance, *args, **kwargs):
logger.debug(f'State {instance} saved. Re-evaluating membership.') logger.debug('State {} saved. Re-evaluating membership.'.format(instance))
trigger_state_check(instance) trigger_state_check(instance)
@ -70,22 +60,22 @@ def reassess_on_profile_save(sender, instance, created, *args, **kwargs):
if not created: if not created:
update_fields = kwargs.pop('update_fields', []) or [] update_fields = kwargs.pop('update_fields', []) or []
if 'state' not in update_fields: if 'state' not in update_fields:
logger.debug(f'Profile for {instance.user} saved without state change. Re-evaluating state.') logger.debug('Profile for {} saved without state change. Re-evaluating state.'.format(instance.user))
instance.assign_state() instance.assign_state()
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_required_models(sender, instance, created, *args, **kwargs): def create_required_models(sender, instance, created, *args, **kwargs):
# ensure all users have our Sub-Models # ensure all users have a model
if created: if created:
logger.debug(f'User {instance} created. Creating default UserProfile.') logger.debug('User {} created. Creating default UserProfile.'.format(instance))
UserProfile.objects.get_or_create(user=instance) UserProfile.objects.get_or_create(user=instance)
@receiver(post_save, sender=Token) @receiver(post_save, sender=Token)
def record_character_ownership(sender, instance, created, *args, **kwargs): def record_character_ownership(sender, instance, created, *args, **kwargs):
if created: if created:
logger.debug(f'New token for {instance.user} character {instance.character_name} saved. Evaluating ownership.') logger.debug('New token for {0} character {1} saved. Evaluating ownership.'.format(instance.user, instance.character_name))
if instance.user: if instance.user:
query = Q(owner_hash=instance.character_owner_hash) & Q(user=instance.user) query = Q(owner_hash=instance.character_owner_hash) & Q(user=instance.user)
else: else:
@ -94,13 +84,13 @@ def record_character_ownership(sender, instance, created, *args, **kwargs):
CharacterOwnership.objects.filter(character__character_id=instance.character_id).exclude(query).delete() CharacterOwnership.objects.filter(character__character_id=instance.character_id).exclude(query).delete()
# create character if needed # create character if needed
if EveCharacter.objects.filter(character_id=instance.character_id).exists() is False: if EveCharacter.objects.filter(character_id=instance.character_id).exists() is False:
logger.debug(f'Token is for a new character. Creating model for {instance.character_name} ({instance.character_id})') logger.debug('Token is for a new character. Creating model for {0} ({1})'.format(instance.character_name, instance.character_id))
EveCharacter.objects.create_character(instance.character_id) EveCharacter.objects.create_character(instance.character_id)
char = EveCharacter.objects.get(character_id=instance.character_id) char = EveCharacter.objects.get(character_id=instance.character_id)
# check if we need to create ownership # check if we need to create ownership
if instance.user and not CharacterOwnership.objects.filter( if instance.user and not CharacterOwnership.objects.filter(
character__character_id=instance.character_id).exists(): character__character_id=instance.character_id).exists():
logger.debug(f"Character {instance.character_name} is not yet owned. Assigning ownership to {instance.user}") logger.debug("Character {0} is not yet owned. Assigning ownership to {1}".format(instance.character_name, instance.user))
CharacterOwnership.objects.update_or_create(character=char, defaults={'owner_hash': instance.character_owner_hash, 'user': instance.user}) CharacterOwnership.objects.update_or_create(character=char, defaults={'owner_hash': instance.character_owner_hash, 'user': instance.user})
@ -108,7 +98,7 @@ def record_character_ownership(sender, instance, created, *args, **kwargs):
def validate_main_character(sender, instance, *args, **kwargs): def validate_main_character(sender, instance, *args, **kwargs):
try: try:
if instance.user.profile.main_character == instance.character: if instance.user.profile.main_character == instance.character:
logger.info("Ownership of a main character {} has been revoked. Resetting {} main character.".format( logger.info("Ownership of a main character {0} has been revoked. Resetting {1} main character.".format(
instance.character, instance.user)) instance.character, instance.user))
# clear main character as user no longer owns them # clear main character as user no longer owns them
instance.user.profile.main_character = None instance.user.profile.main_character = None
@ -121,7 +111,7 @@ def validate_main_character(sender, instance, *args, **kwargs):
@receiver(post_delete, sender=Token) @receiver(post_delete, sender=Token)
def validate_ownership(sender, instance, *args, **kwargs): def validate_ownership(sender, instance, *args, **kwargs):
if not Token.objects.filter(character_owner_hash=instance.character_owner_hash).filter(refresh_token__isnull=False).exists(): if not Token.objects.filter(character_owner_hash=instance.character_owner_hash).filter(refresh_token__isnull=False).exists():
logger.info(f"No remaining tokens to validate ownership of character {instance.character_name}. Revoking ownership.") logger.info("No remaining tokens to validate ownership of character {0}. Revoking ownership.".format(instance.character_name))
CharacterOwnership.objects.filter(owner_hash=instance.character_owner_hash).delete() CharacterOwnership.objects.filter(owner_hash=instance.character_owner_hash).delete()
@ -132,11 +122,11 @@ def assign_state_on_active_change(sender, instance, *args, **kwargs):
old_instance = User.objects.get(pk=instance.pk) old_instance = User.objects.get(pk=instance.pk)
if old_instance.is_active != instance.is_active: if old_instance.is_active != instance.is_active:
if instance.is_active: if instance.is_active:
logger.debug(f"User {instance} has been activated. Assigning state.") logger.debug("User {0} has been activated. Assigning state.".format(instance))
instance.profile.assign_state() instance.profile.assign_state()
else: else:
logger.debug( logger.debug(
f"User {instance} has been deactivated. Revoking state and assigning to guest state.") "User {0} has been deactivated. Revoking state and assigning to guest state.".format(instance))
instance.profile.state = get_guest_state() instance.profile.state = get_guest_state()
instance.profile.save(update_fields=['state']) instance.profile.save(update_fields=['state'])
@ -145,10 +135,10 @@ def assign_state_on_active_change(sender, instance, *args, **kwargs):
def check_state_on_character_update(sender, instance, *args, **kwargs): def check_state_on_character_update(sender, instance, *args, **kwargs):
# if this is a main character updating, check that user's state # if this is a main character updating, check that user's state
try: try:
logger.debug(f"Character {instance} has been saved. Assessing owner's state for changes.") logger.debug("Character {0} has been saved. Assessing owner's state for changes.".format(instance))
instance.userprofile.assign_state() instance.userprofile.assign_state()
except UserProfile.DoesNotExist: except UserProfile.DoesNotExist:
logger.debug(f"Character {instance} is not a main character. No state assessment required.") logger.debug("Character {0} is not a main character. No state assessment required.".format(instance))
pass pass
@ -158,7 +148,7 @@ def ownership_record_creation(sender, instance, created, *args, **kwargs):
records = OwnershipRecord.objects.filter(owner_hash=instance.owner_hash).filter(character=instance.character) records = OwnershipRecord.objects.filter(owner_hash=instance.owner_hash).filter(character=instance.character)
if records.exists(): if records.exists():
if records[0].user == instance.user: # most recent record is sorted first if records[0].user == instance.user: # most recent record is sorted first
logger.debug(f"Already have ownership record of {instance.character} by user {instance.user}") logger.debug("Already have ownership record of {0} by user {1}".format(instance.character, instance.user))
return return
logger.info(f"Character {instance.character} has a new owner {instance.user}. Creating ownership record.") logger.info("Character {0} has a new owner {1}. Creating ownership record.".format(instance.character, instance.user))
OwnershipRecord.objects.create(user=instance.user, character=instance.character, owner_hash=instance.owner_hash) OwnershipRecord.objects.create(user=instance.user, character=instance.character, owner_hash=instance.owner_hash)

View File

@ -1,31 +0,0 @@
/*
CSS for allianceauth admin site
*/
/* styling for profile pic */
.img-circle {
border-radius: 50%;
}
.column-user_profile_pic {
white-space: nowrap;
width: 1px;
}
/* tooltip */
.tooltip {
position: relative;
}
.tooltip:hover::after {
background-color: rgb(255 255 204);
border: 1px rgb(128 128 128) solid;
color: rgb(0 0 0);
content: attr(data-tooltip);
left: 1em;
min-width: 200px;
padding: 8px;
position: absolute;
top: 1.1em;
z-index: 1;
}

View File

@ -0,0 +1,29 @@
/*
CSS for allianceauth admin site
*/
/* styling for profile pic */
.img-circle {
border-radius: 50%;
}
.column-user_profile_pic {
width: 1px;
white-space: nowrap;
}
/* tooltip */
.tooltip {
position: relative ;
}
.tooltip:hover::after {
content: attr(data-tooltip) ;
position: absolute ;
top: 1.1em ;
left: 1em ;
min-width: 200px ;
border: 1px #808080 solid ;
padding: 8px ;
color: black ;
background-color: rgb(255, 255, 204) ;
z-index: 1 ;
}

View File

@ -1,45 +0,0 @@
"""Counters for Task Statistics."""
import datetime as dt
from typing import NamedTuple, Optional
from .event_series import EventSeries
# Global series for counting task events.
succeeded_tasks = EventSeries("SUCCEEDED_TASKS")
retried_tasks = EventSeries("RETRIED_TASKS")
failed_tasks = EventSeries("FAILED_TASKS")
class _TaskCounts(NamedTuple):
succeeded: int
retried: int
failed: int
total: int
earliest_task: Optional[dt.datetime]
hours: int
def dashboard_results(hours: int) -> _TaskCounts:
"""Counts of all task events within the given time frame."""
def earliest_if_exists(events: EventSeries, earliest: dt.datetime) -> list:
my_earliest = events.first_event(earliest=earliest)
return [my_earliest] if my_earliest else []
earliest = dt.datetime.utcnow() - dt.timedelta(hours=hours)
earliest_events = []
succeeded_count = succeeded_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(succeeded_tasks, earliest)
retried_count = retried_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(retried_tasks, earliest)
failed_count = failed_tasks.count(earliest=earliest)
earliest_events += earliest_if_exists(failed_tasks, earliest)
return _TaskCounts(
succeeded=succeeded_count,
retried=retried_count,
failed=failed_count,
total=succeeded_count + retried_count + failed_count,
earliest_task=min(earliest_events) if earliest_events else None,
hours=hours,
)

View File

@ -1,100 +0,0 @@
"""Event series for Task Statistics."""
import datetime as dt
import logging
from typing import List, Optional
from pytz import utc
from redis import Redis
from .helpers import get_redis_client_or_stub
logger = logging.getLogger(__name__)
class EventSeries:
"""API for recording and analyzing a series of events."""
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Optional[Redis] = None) -> None:
self._redis = get_redis_client_or_stub() if not redis else redis
self._key_id = str(key_id)
self.clear()
@property
def is_disabled(self):
"""True when this object is disabled, e.g. Redis was not available at startup."""
return hasattr(self._redis, "IS_STUB")
@property
def _key_counter(self):
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"
@property
def _key_sorted_set(self):
return f"{self._ROOT_KEY}_{self._key_id}_SORTED_SET"
def add(self, event_time: dt.datetime = None) -> None:
"""Add event.
Args:
- event_time: timestamp of event. Will use current time if not specified.
"""
if not event_time:
event_time = dt.datetime.utcnow()
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]:
"""List of all known events."""
return [
event[1]
for event in self._redis.zrangebyscore(
self._key_sorted_set,
"-inf",
"+inf",
withscores=True,
score_cast_func=self._cast_scores_to_dt,
)
]
def clear(self) -> None:
"""Clear all events."""
self._redis.delete(self._key_sorted_set)
self._redis.delete(self._key_counter)
def count(self, earliest: dt.datetime = None, latest: dt.datetime = None) -> int:
"""Count of events, can be restricted to given time frame.
Args:
- earliest: Date of first events to count(inclusive), or -infinite if not specified
- latest: Date of last events to count(inclusive), or +infinite if not specified
"""
minimum = "-inf" if not earliest else earliest.timestamp()
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]:
"""Date/Time of first event. Returns `None` if series has no events.
Args:
- earliest: Date of first events to count(inclusive), or any if not specified
"""
minimum = "-inf" if not earliest else earliest.timestamp()
event = self._redis.zrangebyscore(
self._key_sorted_set,
minimum,
"+inf",
withscores=True,
start=0,
num=1,
score_cast_func=self._cast_scores_to_dt,
)
if not event:
return None
return event[0][1]
@staticmethod
def _cast_scores_to_dt(score) -> dt.datetime:
return dt.datetime.fromtimestamp(float(score), tz=utc)

View File

@ -1,49 +0,0 @@
"""Helpers for Task Statistics."""
import logging
from redis import Redis, RedisError
from allianceauth.utils.cache import get_redis_client
logger = logging.getLogger(__name__)
class _RedisStub:
"""Stub of a Redis client.
It's purpose is to prevent EventSeries objects from trying to access Redis
when it is not available. e.g. when the Sphinx docs are rendered by readthedocs.org.
"""
IS_STUB = True
def delete(self, *args, **kwargs):
pass
def incr(self, *args, **kwargs):
return 0
def zadd(self, *args, **kwargs):
pass
def zcount(self, *args, **kwargs):
pass
def zrangebyscore(self, *args, **kwargs):
pass
def get_redis_client_or_stub() -> Redis:
"""Return AA's default cache client or a stub if Redis is not available."""
redis = get_redis_client()
try:
if not redis.ping():
raise RuntimeError()
except (AttributeError, RedisError, RuntimeError):
logger.exception(
"Failed to establish a connection with Redis. "
"This EventSeries object is disabled.",
)
return _RedisStub()
return redis

View File

@ -1,53 +0,0 @@
"""Signals for Task Statistics."""
from celery.signals import (
task_failure, task_internal_error, task_retry, task_success, worker_ready,
)
from django.conf import settings
from .counters import failed_tasks, retried_tasks, succeeded_tasks
def reset_counters():
"""Reset all counters for the celery status."""
succeeded_tasks.clear()
failed_tasks.clear()
retried_tasks.clear()
def is_enabled() -> bool:
"""Return True if task statistics are enabled, else return False."""
return not bool(
getattr(settings, "ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED", False)
)
@worker_ready.connect
def reset_counters_when_celery_restarted(*args, **kwargs):
if is_enabled():
reset_counters()
@task_success.connect
def record_task_succeeded(*args, **kwargs):
if is_enabled():
succeeded_tasks.add()
@task_retry.connect
def record_task_retried(*args, **kwargs):
if is_enabled():
retried_tasks.add()
@task_failure.connect
def record_task_failed(*args, **kwargs):
if is_enabled():
failed_tasks.add()
@task_internal_error.connect
def record_task_internal_error(*args, **kwargs):
if is_enabled():
failed_tasks.add()

View File

@ -1,52 +0,0 @@
import datetime as dt
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,
)
class TestDashboardResults(TestCase):
def test_should_return_counts_for_given_time_frame_only(self):
# given
earliest_task = now() - dt.timedelta(minutes=15)
succeeded_tasks.clear()
succeeded_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
succeeded_tasks.add(earliest_task)
succeeded_tasks.add()
succeeded_tasks.add()
retried_tasks.clear()
retried_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
retried_tasks.add(now() - dt.timedelta(seconds=30))
retried_tasks.add()
failed_tasks.clear()
failed_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
failed_tasks.add()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 3)
self.assertEqual(results.retried, 2)
self.assertEqual(results.failed, 1)
self.assertEqual(results.total, 6)
self.assertEqual(results.earliest_task, earliest_task)
def test_should_work_with_no_data(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 0)
self.assertEqual(results.retried, 0)
self.assertEqual(results.failed, 0)
self.assertEqual(results.total, 0)
self.assertIsNone(results.earliest_task)

View File

@ -1,151 +0,0 @@
import datetime as dt
from pytz import utc
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import (
EventSeries,
)
from allianceauth.authentication.task_statistics.helpers import _RedisStub
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
class TestEventSeries(TestCase):
def test_should_add_event(self):
# given
events = EventSeries("dummy")
# when
events.add()
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], now(), delta=dt.timedelta(seconds=30))
def test_should_add_event_with_specified_time(self):
# given
events = EventSeries("dummy")
my_time = dt.datetime(2021, 11, 1, 12, 15, tzinfo=utc)
# when
events.add(my_time)
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], my_time, delta=dt.timedelta(seconds=30))
def test_should_count_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
result = events.count()
# then
self.assertEqual(result, 2)
def test_should_count_zero(self):
# given
events = EventSeries("dummy")
# when
result = events.count()
# then
self.assertEqual(result, 0)
def test_should_count_events_within_timeframe_1(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc),
latest=dt.datetime(2021, 12, 1, 12, 17, tzinfo=utc),
)
# then
self.assertEqual(result, 2)
def test_should_count_events_within_timeframe_2(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(earliest=dt.datetime(2021, 12, 1, 12, 8))
# then
self.assertEqual(result, 3)
def test_should_count_events_within_timeframe_3(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(latest=dt.datetime(2021, 12, 1, 12, 12))
# then
self.assertEqual(result, 2)
def test_should_clear_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
events.clear()
# then
self.assertEqual(events.count(), 0)
def test_should_return_date_of_first_event(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event()
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
def test_should_return_date_of_first_event_with_range(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc)
)
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
def test_should_return_all_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
results = events.all()
# then
self.assertEqual(len(results), 2)
def test_should_not_report_as_disabled_when_initialized_normally(self):
# given
events = EventSeries("dummy")
# when/then
self.assertFalse(events.is_disabled)
def test_should_report_as_disabled_when_initialized_with_redis_stub(self):
# given
events = EventSeries("dummy", redis=_RedisStub())
# when/then
self.assertTrue(events.is_disabled)

View File

@ -1,28 +0,0 @@
from unittest import TestCase
from unittest.mock import patch
from redis import RedisError
from allianceauth.authentication.task_statistics.helpers import (
_RedisStub, get_redis_client_or_stub,
)
MODULE_PATH = "allianceauth.authentication.task_statistics.helpers"
class TestGetRedisClient(TestCase):
def test_should_return_mock_if_redis_not_available_1(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.side_effect = RedisError
result = get_redis_client_or_stub()
# then
self.assertIsInstance(result, _RedisStub)
def test_should_return_mock_if_redis_not_available_2(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.return_value = False
result = get_redis_client_or_stub()
# then
self.assertIsInstance(result, _RedisStub)

View File

@ -1,86 +0,0 @@
from unittest.mock import patch
from celery.exceptions import Retry
from django.test import TestCase, override_settings
from allianceauth.authentication.task_statistics.counters import (
failed_tasks,
retried_tasks,
succeeded_tasks,
)
from allianceauth.authentication.task_statistics.signals import (
reset_counters,
is_enabled,
)
from allianceauth.eveonline.tasks import update_character
@override_settings(
CELERY_ALWAYS_EAGER=True, ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False
)
class TestTaskSignals(TestCase):
fixtures = ["disable_analytics"]
def setUp(self) -> None:
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
def test_should_record_successful_task(self):
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
mock_update.return_value = None
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 1)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
def test_should_record_retried_task(self):
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
mock_update.side_effect = Retry
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 1)
def test_should_record_failed_task(self):
# when
with patch(
"allianceauth.eveonline.tasks.EveCharacter.objects.update_character"
) as mock_update:
mock_update.side_effect = RuntimeError
update_character.delay(1)
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 1)
def test_should_reset_counters(self):
# given
succeeded_tasks.add()
retried_tasks.add()
failed_tasks.add()
# when
reset_counters()
# then
self.assertEqual(succeeded_tasks.count(), 0)
self.assertEqual(retried_tasks.count(), 0)
self.assertEqual(failed_tasks.count(), 0)
class TestIsEnabled(TestCase):
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False)
def test_enabled(self):
self.assertTrue(is_enabled())
@override_settings(ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=True)
def test_disabled(self):
self.assertFalse(is_enabled())

View File

@ -22,13 +22,13 @@ def check_character_ownership(owner_hash):
continue continue
except (KeyError, IncompleteResponseError): except (KeyError, IncompleteResponseError):
# We can't validate the hash hasn't changed but also can't assume it has. Abort for now. # We can't validate the hash hasn't changed but also can't assume it has. Abort for now.
logger.warning("Failed to validate owner hash of {} due to problems contacting SSO servers.".format( logger.warning("Failed to validate owner hash of {0} due to problems contacting SSO servers.".format(
tokens[0].character_name)) tokens[0].character_name))
break break
if not t.character_owner_hash == old_hash: if not t.character_owner_hash == old_hash:
logger.info( logger.info(
f'Character {t.character_name} has changed ownership. Revoking {tokens.count()} tokens.') 'Character %s has changed ownership. Revoking %s tokens.' % (t.character_name, tokens.count()))
tokens.delete() tokens.delete()
break break

View File

@ -1,15 +1,165 @@
{% extends "allianceauth/base-bs5.html" %} {% extends "allianceauth/base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Dashboard" %}{% endblock %} {% block page_title %}{% trans "Dashboard" %}{% endblock %}
{% block header_nav_brand %}
{% translate "Dashboard" %}
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <h1 class="page-header text-center">{% trans "Dashboard" %}</h1>
{% for dash in views %} {% if user.is_staff %}
{{ dash | safe }} {% include 'allianceauth/admin-status/include.html' %}
{% endfor %} {% endif %}
<div class="col-sm-12">
<div class="row vertical-flexbox-row2">
<div class="col-sm-6 text-center">
<div class="panel panel-primary" style="height:100%">
<div class="panel-heading">
<h3 class="panel-title">
{% blocktrans with state=request.user.profile.state %}
Main Character (State: {{ state }})
{% endblocktrans %}
</h3>
</div>
<div class="panel-body">
{% if request.user.profile.main_character %}
{% with request.user.profile.main_character as main %}
<div class="hidden-xs">
<div class="col-lg-4 col-sm-2">
<table class="table">
<tr>
<td class="text-center">
<img class="ra-avatar"src="{{ main.portrait_url_128 }}">
</td>
</tr>
<tr>
<td class="text-center">{{ main.character_name }}</td>
</tr>
</table>
</div>
<div class="col-lg-4 col-sm-2">
<table class="table">
<tr>
<td class="text-center">
<img class="ra-avatar"src="{{ main.corporation_logo_url_128 }}">
</td>
</tr>
<tr>
<td class="text-center">{{ main.corporation_name }}</td>
</tr>
</table>
</div>
<div class="col-lg-4 col-sm-2">
{% if main.alliance_id %}
<table class="table">
<tr>
<td class="text-center">
<img class="ra-avatar"src="{{ main.alliance_logo_url_128 }}">
</td>
</tr>
<tr>
<td class="text-center">{{ main.alliance_name }}</td>
<tr>
</table>
{% endif %}
</div>
</div>
<div class="table visible-xs-block">
<p>
<img class="ra-avatar" src="{{ main.portrait_url_64 }}">
<img class="ra-avatar" src="{{ main.corporation_logo_url_64 }}">
<img class="ra-avatar" src="{{ main.alliance_logo_url_64 }}">
</p>
<p>
<strong>{{ main.character_name }}</strong><br>
{{ main.corporation_name }}<br>
{{ main.alliance_name }}
</p>
</div>
{% endwith %}
{% else %}
<div class="alert alert-danger" role="alert">
{% trans "No main character set." %}
</div>
{% endif %}
<div class="clearfix"></div>
<div class="row">
<div class="col-sm-6 button-wrapper">
<a href="{% url 'authentication:add_character' %}" class="btn btn-block btn-info"
title="Add Character">{% trans 'Add Character' %}</a>
</div>
<div class="col-sm-6 button-wrapper">
<a href="{% url 'authentication:change_main_character' %}" class="btn btn-block btn-info"
title="Change Main Character">{% trans "Change Main" %}</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 text-center">
<div class="panel panel-success" style="height:100%">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Group Memberships" %}</h3>
</div>
<div class="panel-body">
<div style="height: 240px;overflow:-moz-scrollbars-vertical;overflow-y:auto;">
<table class="table table-aa">
{% for group in groups %}
<tr>
<td>{{ group.name }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</div>
</div>
<div class="clearfix"></div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title text-center" style="text-align: center">
{% trans 'Characters' %}
</h3>
</div>
<div class="panel-body">
<table class="table table-aa hidden-xs">
<thead>
<tr>
<th class="text-center"></th>
<th class="text-center">{% trans 'Name' %}</th>
<th class="text-center">{% trans 'Corp' %}</th>
<th class="text-center">{% trans 'Alliance' %}</th>
</tr>
</thead>
<tbody>
{% for char in characters %}
<tr>
<td class="text-center"><img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}">
</td>
<td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td>
<td class="text-center">{{ char.alliance_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="table table-aa visible-xs-block" style="width: 100%">
<tbody>
{% for char in characters %}
<tr>
<td class="text-center" style="vertical-align: middle">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}">
</td>
<td class="text-center" style="vertical-align: middle; width: 100%">
<strong>{{ char.character_name }}</strong><br>
{{ char.corporation_name }}<br>
{{ char.alliance_name|default:"" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@ -1,44 +0,0 @@
{% load i18n %}
<div id="aa-dashboard-panel-characters" class="col-12 col-xl-8 mb-3">
<div class="card h-100">
<div class="card-body">
{% translate "Characters" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<div style="height: 300px; overflow-y:auto;">
<div class="d-flex">
<a href="{% url 'authentication:add_character' %}" class="btn btn-primary flex-fill m-1" title="{% translate 'Add Character' %}">
<span class="d-md-inline m-2">{% translate 'Add Character' %}</span>
</a>
<a href="{% url 'authentication:change_main_character' %}" class="btn btn-primary flex-fill m-1" title="{% translate 'Change Main' %}">
<span class="d-md-inline m-2">{% translate 'Change Main' %}</span>
</a>
</div>
<table class="table">
<thead>
<tr>
<th class="text-center"></th>
<th class="text-center">{% translate "Name" %}</th>
<th class="text-center">{% translate "Corp" %}</th>
<th class="text-center">{% translate "Alliance" %}</th>
</tr>
</thead>
<tbody>
{% for char in characters %}
<tr>
<td class="text-center">
<img class="ra-avatar rounded-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td>
<td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td>
<td class="text-center">{{ char.alliance_name|default_if_none:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,22 +0,0 @@
{% load i18n %}
<div id="aa-dashboard-panel-membership" class="col-12 col-xl-4 mb-3">
<div class="card h-100">
<div class="card-body">
{% translate "Membership" as widget_title %}
{% include "framework/dashboard/widget-title.html" with title=widget_title %}
<div>
<div style="height: 300px; overflow-y:auto;">
<h5 class="text-center">{% translate "State:" %} {{ request.user.profile.state }}</h5>
<table class="table">
{% for group in groups %}
<tr>
<td class="text-center">{{ group.name }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,89 +0,0 @@
{% extends "allianceauth/base-bs5.html" %}
{% load aa_i18n %}
{% load i18n %}
{% block page_title %}
{% translate "Token Management" %}
{% endblock page_title %}
{% block header_nav_brand %}
{% translate "Token Management" %}
{% endblock header_nav_brand %}
{% block content %}
<div>
<p class="mb-3">
{% translate "This page is a best attempt, but backups or database logs can still contain your tokens. Always revoke tokens on https://developers.eveonline.com/authorized-apps where possible."|urlize %}
</p>
<table class="table w-100" id="table_tokens">
<thead>
<tr>
<th>{% translate "Scopes" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
<th>{% translate "Character" %}</th>
</tr>
</thead>
<tbody>
{% for t in tokens %}
<tr>
<td style="white-space:initial;">
{% for s in t.scopes.all %}
<span class="badge text-bg-secondary">{{ s.name }}</span>
{% endfor %}
</td>
<td nowrap class="text-end">
<a href="{% url 'authentication:token_delete' t.id %}" class="btn btn-danger"><i class="fa-solid fa-trash-can"></i></a>
<a href="{% url 'authentication:token_refresh' t.id %}" class="btn btn-success"><i class="fa-solid fa-rotate"></i></a>
</td>
<td>{{ t.character_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}
{% block extra_javascript %}
{% include "bundles/datatables-js-bs5.html" %}
{% get_datatables_language_static LANGUAGE_CODE as DT_LANG_PATH %}
<script>
$(document).ready(() => {
let grp = 2;
$('#table_tokens').DataTable({
"language": {"url": '{{ DT_LANG_PATH }}'},
'columnDefs': [{orderable: false, targets: [0, 1]}, {
'visible': false,
'targets': grp
}],
'order': [[grp, 'asc']],
'drawCallback': function (settings) {
const api = this.api();
const rows = api.rows({page: 'current'}).nodes();
let last = null;
api.column(grp, {page: 'current'})
.data()
.each((group, i) => {
if (last !== group) {
$(rows).eq(i).before(`<tr class="h5 table-primary"><td colspan="3">${group}</td></tr>`);
last = group;
}
});
},
'stateSave': true
});
});
</script>
{% endblock extra_javascript %}
{% block extra_css %}
{% include "bundles/datatables-css-bs5.html" %}
{% endblock extra_css %}

View File

@ -1,41 +1,40 @@
{% load theme_tags %}
{% load static %} {% load static %}
<!DOCTYPE html> <html lang="en">
<html lang="en" {% theme_html_tags %}>
<head> <head>
<!-- Required meta tags -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- End Required meta tags --> <meta name="description" content="">
<meta name="author" content="">
<meta property="og:title" content="{{ SITE_NAME }}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'icons/apple-touch-icon.png' %}">
<meta property="og:description" content="Alliance Auth - An auth system for EVE Online to help in-game organizations manage online service access.">
<!-- Meta tags -->
{% include 'allianceauth/opengraph.html' %}
{% include 'allianceauth/icons.html' %} {% include 'allianceauth/icons.html' %}
<!-- Meta tags -->
<title>{% block title %}{% block page_title %}{% endblock page_title %} - {{ SITE_NAME }}{% endblock title %}</title> <title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
{% theme_css %}
{% include 'bundles/bootstrap-css.html' %}
{% include 'bundles/fontawesome.html' %} {% include 'bundles/fontawesome.html' %}
{% include 'bundles/auth-framework-css.html' %}
{% block extra_include %} {% block extra_include %}
{% endblock %} {% endblock %}
<style> <style>
body { body {
background: url('{% static 'allianceauth/authentication/img/background.jpg' %}') no-repeat center center fixed; background: url('{% static 'authentication/img/background.jpg' %}') no-repeat center center fixed;
-webkit-background-size: cover; -webkit-background-size: cover;
-moz-background-size: cover; -moz-background-size: cover;
-o-background-size: cover; -o-background-size: cover;
background-size: cover; background-size: cover;
} }
.card-login { .panel-transparent {
background: rgba(48 48 48 / 0.7); background: rgba(48, 48, 48, 0.7);
color: rgb(255 255 255); color: #ffffff;
padding-bottom: 21px; }
.panel-body {
} }
#lang-select { #lang-select {
@ -43,14 +42,12 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
{% block extra_style %} {% block extra_style %}
{% endblock %} {% endblock %}
</style> </style>
</head> </head>
<body> <body>
<div class="container" style="margin-top:150px;"> <div class="container" style="margin-top:150px">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@ -1,15 +1,14 @@
{% load i18n %} {% load i18n %}
<div class="dropdown">
<form class="dropdown-item" action="{% url 'set_language' %}" method="post"> <form action="{% url 'set_language' %}" method="post">
{% csrf_token %} {% csrf_token %}
<select onchange="this.form.submit()" class="form-control" id="lang-select" name="language">
<select class="form-select" onchange="this.form.submit()" class="form-control" id="lang-select" name="language"> {% get_language_info_list for LANGUAGES as languages %}
{% get_available_languages as LANGUAGES %} {% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected="selected"{% endif %}>
{% for lang_code, lang_name in LANGUAGES %} {{ language.name_local }} ({{ language.code }})
<option lang="{{ lang_code }}" value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}> </option>
{{ lang_code|language_name_local|capfirst }} ({{ lang_code }}) {% endfor %}
</option> </select>
{% endfor %} </form>
</select> </div>
</form>

View File

@ -1,12 +1,8 @@
{% extends 'public/middle_box.html' %} {% extends 'public/middle_box.html' %}
{% load i18n %}
{% load static %} {% load static %}
{% block page_title %}Login{% endblock %}
{% block page_title %}{% translate "Login" %}{% endblock %}
{% block middle_box_content %} {% block middle_box_content %}
<a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next | urlencode}}{%endif%}"> <a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next}}{%endif%}">
<img class="img-responsive center-block" src="{% static 'allianceauth/authentication/img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}" alt="{% translate 'Login with Eve SSO' %}"> <img class="img-responsive center-block" src="{% static 'img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}" border=0>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -1,43 +1,24 @@
{% extends 'public/base.html' %} {% extends 'public/base.html' %}
{% load static %}
{% load i18n %} {% block title %}Login{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="col-md-4 col-md-offset-4">
<div class="col-md-6"> {% if messages %}
{% if messages %} {% for message in messages %}
{% for message in messages %} <div class="alert alert-{{ message.level_tag}}">{{ message }}</div>
<div class="alert alert-{{ message.level_tag}}">{{ message }}</div> {% endfor %}
{% endfor %} {% endif %}
{% endif %} <div class="panel panel-default panel-transparent">
<div class="panel-body">
<div class="card card-login border-secondary p-3"> <div class="col-md-12">
<div class="card-body"> {% block middle_box_content %}
<div class="text-center"> {% endblock %}
{% block middle_box_content %}
{% endblock %}
</div>
</div> </div>
{% include 'public/lang_select.html' %}
<p class="text-center mt-3">
{% translate "For information on SSO, ESI and security read the CCP Dev Blog" %}<br>
<a class="text-reset" href="https://www.eveonline.com/news/view/introducing-esi" target="_blank" rel="noopener noreferrer">
{% translate "Introducing ESI - A New API For EVE Online" %}
</a>
</p>
<p class="text-center">
<a class="text-reset" href="https://developers.eveonline.com/authorized-apps" target="_blank" rel="noopener noreferrer">
{% translate "Manage ESI Applications" %}
</a>
</p>
</div> </div>
{% include 'public/lang_select.html' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_include %} {% block extra_include %}
{% include 'bundles/bootstrap-js-bs5.html' %} {% include 'bundles/bootstrap-js.html' %}
{% endblock %} {% endblock %}

View File

@ -1,31 +1,24 @@
{% extends 'public/base.html' %} {% extends 'public/base.html' %}
{% load static %}
{% load django_bootstrap5 %} {% load bootstrap %}
{% load i18n %} {% load i18n %}
{% block page_title %}Registration{% endblock %}
{% block page_title %}{% translate "Registration" %}{% endblock %}
{% block extra_include %} {% block extra_include %}
{% include 'bundles/bootstrap-css-bs5.html' %} {% include 'bundles/bootstrap-css.html' %}
{% include 'bundles/fontawesome.html' %} {% include 'bundles/fontawesome.html' %}
{% include 'bundles/bootstrap-js-bs5.html' %} {% include 'bundles/bootstrap-js.html' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="col-md-4 col-md-offset-4">
<div class="col-md-6"> <div class="panel panel-default panel-transparent">
<div class="card card-login border-secondary p-3"> <div class="panel-body">
<div class="card-body"> <form method="POST">
<form method="POST"> {% csrf_token %}
{% csrf_token %} {{ form|bootstrap }}
{% bootstrap_form form %} <button class="btn btn-lg btn-primary btn-block" type="submit">{% trans "Register" %}</button>
</form>
<button class="btn btn-primary btn-block" type="submit">{% translate "Register" %}</button>
</form>
{% include 'public/lang_select.html' %}
</div>
</div> </div>
</div> </div>
{% include 'public/lang_select.html' %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,5 @@
{% extends 'public/middle_box.html' %} {% extends 'public/middle_box.html' %}
{% load i18n %} {% load i18n %}
{% block middle_box_content %} {% block middle_box_content %}
<div class="alert alert-danger">{% translate 'Invalid or expired activation link.' %}</div> <div class="alert alert-danger">{% trans 'Invalid or expired activation link.' %}</div>
{% endblock %} {% endblock %}

View File

@ -2,6 +2,10 @@ You're receiving this email because someone has entered this email address while
If this was you, please click on the link below to confirm your email address: If this was you, please click on the link below to confirm your email address:
<a href="{{ scheme }}://{{ url }}">Confirm email address</a>
Link not working? Try copy/pasting this URL into your browser:
{{ scheme }}://{{ url }} {{ scheme }}://{{ url }}
This link will expire in {{ expiration_days }} day(s). This link will expire in {{ expiration_days }} day(s).

View File

@ -1,19 +0,0 @@
<p>
You're receiving this email because someone has entered this email address while registering for an account on {{ site.domain }}
</p>
<p>
If this was you, please click on the link below to confirm your email address:
<p>
<p>
<a href="{{ scheme }}://{{ url }}">Confirm email address</a>
</p>
<p>
This link will expire in {{ expiration_days }} day(s).
</p>
<p>
If this was not you, it is safe to ignore this email.
</p>

View File

@ -0,0 +1,15 @@
{% load i18n %}{% autoescape off %}
{% blocktrans trimmed %}You're receiving this email because you requested a password reset for your
user account.{% endblocktrans %}
{% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %}
{{domain}}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% endblock %}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
{% trans "Thanks for using our site!" %}
{% blocktrans %}Your IT Team{% endblocktrans %}
{% endautoescape %}

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