diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 631650d6..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[settings] -profile=django -sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,ESI,FIRSTPARTY,LOCALFOLDER -known_esi=esi -known_django=django -skip_gitignore=true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e9e3fde..f7b92bac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,12 +7,33 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - - id: check-case-conflict - - id: check-json - - id: check-xml + # 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 + + # Python checks +# - id: check-docstring-first + - id: debug-statements +# - id: requirements-txt-fixer + - id: fix-encoding-pragma + args: [ --remove ] - id: fix-byte-order-marker + + # General quality checks + - id: mixed-line-ending + args: [ --fix=lf ] - id: trailing-whitespace + args: [ --markdown-linebreak-ext=md ] exclude: | (?x)( \.min\.css| @@ -21,6 +42,7 @@ repos: \.mo| swagger\.json ) + - id: check-executables-have-shebangs - id: end-of-file-fixer exclude: | (?x)( @@ -30,13 +52,9 @@ repos: \.mo| swagger\.json ) - - id: mixed-line-ending - args: [ '--fix=lf' ] - - id: fix-encoding-pragma - args: [ '--remove' ] - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 2.7.1 + rev: 2.7.2 hooks: - id: editorconfig-checker exclude: | @@ -49,13 +67,13 @@ repos: ) - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [ --py38-plus ] - repo: https://github.com/adamchainz/django-upgrade - rev: 1.13.0 + rev: 1.14.0 hooks: - id: django-upgrade args: [--target-version=4.2] diff --git a/.tx/config_20230406134150.bak b/.tx/config_20230406134150.bak old mode 100755 new mode 100644 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d46a6471..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -include LICENSE -include README.md -include MANIFEST.in -graft allianceauth - -global-exclude __pycache__ -global-exclude *.py[co] diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/allianceauth/__init__.py b/allianceauth/__init__.py index 417f9297..c846863b 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -1,7 +1,11 @@ +"""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 # Django starts so that shared_task will use this app. -__version__ = '3.4.0' +__version__ = '3.6.1' __title__ = 'Alliance Auth' __url__ = 'https://gitlab.com/allianceauth/allianceauth' NAME = f'{__title__} v{__version__}' diff --git a/allianceauth/authentication/decorators.py b/allianceauth/authentication/decorators.py index 33bbf2e3..d9d93faa 100644 --- a/allianceauth/authentication/decorators.py +++ b/allianceauth/authentication/decorators.py @@ -2,17 +2,30 @@ from django.urls import include from django.contrib.auth.decorators import user_passes_test from django.core.exceptions import PermissionDenied from functools import wraps -from django.shortcuts import redirect +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.utils.translation import gettext_lazy as _ -from django.contrib.auth.decorators import login_required def user_has_main_character(user): return bool(user.profile.main_character) -def decorate_url_patterns(urls, decorator): +def decorate_url_patterns( + 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) def process_patterns(url_patterns): @@ -22,6 +35,8 @@ def decorate_url_patterns(urls, decorator): process_patterns(pattern.url_patterns) else: # this is a pattern + if excluded_views and pattern.lookup_str in excluded_views: + return pattern.callback = decorator(pattern.callback) process_patterns(url_list) diff --git a/allianceauth/authentication/hmac_urls.py b/allianceauth/authentication/hmac_urls.py index 23c48ac1..61bcd99b 100644 --- a/allianceauth/authentication/hmac_urls.py +++ b/allianceauth/authentication/hmac_urls.py @@ -1,7 +1,5 @@ from allianceauth.authentication import views -from django.urls import include -from django.urls import re_path -from django.urls import path +from django.urls import include, re_path, path urlpatterns = [ path('activate/complete/', views.activation_complete, name='registration_activation_complete'), diff --git a/allianceauth/authentication/managers.py b/allianceauth/authentication/managers.py old mode 100755 new mode 100644 diff --git a/allianceauth/authentication/migrations/0021_alter_userprofile_language.py b/allianceauth/authentication/migrations/0021_alter_userprofile_language.py new file mode 100644 index 00000000..be9ab240 --- /dev/null +++ b/allianceauth/authentication/migrations/0021_alter_userprofile_language.py @@ -0,0 +1,34 @@ +# 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", + ), + ), + ] diff --git a/allianceauth/authentication/models.py b/allianceauth/authentication/models.py old mode 100755 new mode 100644 index 80fdcd4a..6d7a06b8 --- a/allianceauth/authentication/models.py +++ b/allianceauth/authentication/models.py @@ -63,6 +63,22 @@ class UserProfile(models.Model): class Meta: default_permissions = ('change',) + class Language(models.TextChoices): + """ + Choices for UserProfile.language + """ + + ENGLISH = 'en', _('English') + GERMAN = 'de', _('German') + SPANISH = 'es', _('Spanish') + CHINESE = 'zh-hans', _('Chinese Simplified') + RUSSIAN = 'ru', _('Russian') + KOREAN = 'ko', _('Korean') + FRENCH = 'fr', _('French') + JAPANESE = 'ja', _('Japanese') + ITALIAN = 'it', _('Italian') + UKRAINIAN = 'uk', _('Ukrainian') + user = models.OneToOneField( User, related_name='profile', @@ -76,20 +92,9 @@ class UserProfile(models.Model): State, on_delete=models.SET_DEFAULT, default=get_guest_state_pk) - LANGUAGE_CHOICES = [ - ('en', _('English')), - ('de', _('German')), - ('es', _('Spanish')), - ('zh-hans', _('Chinese Simplified')), - ('ru', _('Russian')), - ('ko', _('Korean')), - ('fr', _('French')), - ('ja', _('Japanese')), - ('it', _('Italian')), - ] language = models.CharField( _("Language"), max_length=10, - choices=LANGUAGE_CHOICES, + choices=Language.choices, blank=True, default='') night_mode = models.BooleanField( diff --git a/allianceauth/authentication/task_statistics/counters.py b/allianceauth/authentication/task_statistics/counters.py index bd9eaabd..06e2af83 100644 --- a/allianceauth/authentication/task_statistics/counters.py +++ b/allianceauth/authentication/task_statistics/counters.py @@ -1,35 +1,44 @@ -from collections import namedtuple +"""Counters for Task Statistics.""" + import datetime as dt +from typing import NamedTuple, Optional from .event_series import EventSeries +from .helpers import ItemCounter - -"""Global series for counting task events.""" +# Global series for counting task events. succeeded_tasks = EventSeries("SUCCEEDED_TASKS") retried_tasks = EventSeries("RETRIED_TASKS") failed_tasks = EventSeries("FAILED_TASKS") +running_tasks = ItemCounter("running_tasks") -_TaskCounts = namedtuple( - "_TaskCounts", ["succeeded", "retried", "failed", "total", "earliest_task", "hours"] -) +class _TaskCounts(NamedTuple): + succeeded: int + retried: int + failed: int + total: int + earliest_task: Optional[dt.datetime] + hours: int + running: int def dashboard_results(hours: int) -> _TaskCounts: - """Counts of all task events within the given timeframe.""" + """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 = list() + 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) + running_count = running_tasks.value() return _TaskCounts( succeeded=succeeded_count, retried=retried_count, @@ -37,4 +46,5 @@ def dashboard_results(hours: int) -> _TaskCounts: total=succeeded_count + retried_count + failed_count, earliest_task=min(earliest_events) if earliest_events else None, hours=hours, + running=running_count, ) diff --git a/allianceauth/authentication/task_statistics/event_series.py b/allianceauth/authentication/task_statistics/event_series.py index aead7c6a..9ac1f15d 100644 --- a/allianceauth/authentication/task_statistics/event_series.py +++ b/allianceauth/authentication/task_statistics/event_series.py @@ -1,61 +1,31 @@ +"""Event series for Task Statistics.""" + import datetime as dt import logging from typing import List, Optional from pytz import utc -from redis import Redis, RedisError +from redis import Redis -from allianceauth.utils.cache import get_redis_client +from .helpers import get_redis_client_or_stub 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. - """ - - 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 - - class EventSeries: """API for recording and analyzing a series of events.""" _ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES" - def __init__(self, key_id: str, redis: Redis = None) -> None: - self._redis = get_redis_client() if not redis else redis - try: - if not self._redis.ping(): - raise RuntimeError() - except (AttributeError, RedisError, RuntimeError): - logger.exception( - "Failed to establish a connection with Redis. " - "This EventSeries object is disabled.", - ) - self._redis = _RedisStub() + 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 isinstance(self._redis, _RedisStub) + return hasattr(self._redis, "IS_STUB") @property def _key_counter(self): @@ -73,8 +43,8 @@ class EventSeries: """ if not event_time: event_time = dt.datetime.utcnow() - id = self._redis.incr(self._key_counter) - self._redis.zadd(self._key_sorted_set, {id: event_time.timestamp()}) + 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.""" @@ -95,15 +65,15 @@ class EventSeries: 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 timeframe. + """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 """ - min = "-inf" if not earliest else earliest.timestamp() - max = "+inf" if not latest else latest.timestamp() - return self._redis.zcount(self._key_sorted_set, min=min, max=max) + 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. @@ -111,10 +81,10 @@ class EventSeries: Args: - earliest: Date of first events to count(inclusive), or any if not specified """ - min = "-inf" if not earliest else earliest.timestamp() + minimum = "-inf" if not earliest else earliest.timestamp() event = self._redis.zrangebyscore( self._key_sorted_set, - min, + minimum, "+inf", withscores=True, start=0, diff --git a/allianceauth/authentication/task_statistics/helpers.py b/allianceauth/authentication/task_statistics/helpers.py new file mode 100644 index 00000000..b75fb39c --- /dev/null +++ b/allianceauth/authentication/task_statistics/helpers.py @@ -0,0 +1,108 @@ +"""Helpers for Task Statistics.""" + +import logging +from typing import Optional + +from redis import Redis, RedisError + +from django.core.cache import cache + +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 + + +class ItemCounter: + """A process safe item counter. + + Args: + - name: Unique name for the counter + - minimum: Counter can not go below the minimum, when set + - redis: A Redis client. Will use AA's cache client by default + """ + + CACHE_KEY_BASE = "allianceauth-item-counter" + DEFAULT_CACHE_TIMEOUT = 24 * 3600 + + def __init__( + self, name: str, minimum: Optional[int] = None, redis: Optional[Redis] = None + ) -> None: + if not name: + raise ValueError("Must define a name") + + self._name = str(name) + self._minimum = minimum + self._redis = get_redis_client_or_stub() if not redis else redis + + @property + def _cache_key(self) -> str: + return f"{self.CACHE_KEY_BASE}-{self._name}" + + def reset(self, init_value: int = 0): + """Reset counter to initial value.""" + with self._redis.lock(f"{self.CACHE_KEY_BASE}-reset"): + if self._minimum is not None and init_value < self._minimum: + raise ValueError("Can not reset below minimum") + + cache.set(self._cache_key, init_value, self.DEFAULT_CACHE_TIMEOUT) + + def incr(self, delta: int = 1): + """Increment counter by delta.""" + try: + cache.incr(self._cache_key, delta) + except ValueError: + pass + + def decr(self, delta: int = 1): + """Decrement counter by delta.""" + with self._redis.lock(f"{self.CACHE_KEY_BASE}-decr"): + if self._minimum is not None and self.value() == self._minimum: + return + try: + cache.decr(self._cache_key, delta) + except ValueError: + pass + + def value(self) -> Optional[int]: + """Return current value or None if not yet initialized.""" + return cache.get(self._cache_key) + + +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 diff --git a/allianceauth/authentication/task_statistics/signals.py b/allianceauth/authentication/task_statistics/signals.py index 9c54520a..17665d65 100644 --- a/allianceauth/authentication/task_statistics/signals.py +++ b/allianceauth/authentication/task_statistics/signals.py @@ -1,14 +1,15 @@ +"""Signals for Task Statistics.""" + from celery.signals import ( - task_failure, - task_internal_error, - task_retry, - task_success, - worker_ready + task_failure, task_internal_error, task_postrun, task_prerun, task_retry, + task_success, worker_ready, ) from django.conf import settings -from .counters import failed_tasks, retried_tasks, succeeded_tasks +from .counters import ( + failed_tasks, retried_tasks, running_tasks, succeeded_tasks, +) def reset_counters(): @@ -16,9 +17,11 @@ def reset_counters(): succeeded_tasks.clear() failed_tasks.clear() retried_tasks.clear() + running_tasks.reset() 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) ) @@ -52,3 +55,15 @@ def record_task_failed(*args, **kwargs): def record_task_internal_error(*args, **kwargs): if is_enabled(): failed_tasks.add() + + +@task_prerun.connect +def record_task_prerun(*args, **kwargs): + if is_enabled(): + running_tasks.incr() + + +@task_postrun.connect +def record_task_postrun(*args, **kwargs): + if is_enabled(): + running_tasks.decr() diff --git a/allianceauth/authentication/task_statistics/tests/test_counters.py b/allianceauth/authentication/task_statistics/tests/test_counters.py index 2625d49d..284f86ca 100644 --- a/allianceauth/authentication/task_statistics/tests/test_counters.py +++ b/allianceauth/authentication/task_statistics/tests/test_counters.py @@ -8,25 +8,31 @@ from allianceauth.authentication.task_statistics.counters import ( succeeded_tasks, retried_tasks, failed_tasks, + running_tasks, ) class TestDashboardResults(TestCase): - def test_should_return_counts_for_given_timeframe_only(self): + 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() + + running_tasks.reset(8) # when results = dashboard_results(hours=1) # then @@ -35,12 +41,14 @@ class TestDashboardResults(TestCase): self.assertEqual(results.failed, 1) self.assertEqual(results.total, 6) self.assertEqual(results.earliest_task, earliest_task) + self.assertEqual(results.running, 8) def test_should_work_with_no_data(self): # given succeeded_tasks.clear() retried_tasks.clear() failed_tasks.clear() + running_tasks.reset() # when results = dashboard_results(hours=1) # then @@ -49,3 +57,4 @@ class TestDashboardResults(TestCase): self.assertEqual(results.failed, 0) self.assertEqual(results.total, 0) self.assertIsNone(results.earliest_task) + self.assertEqual(results.running, 0) diff --git a/allianceauth/authentication/task_statistics/tests/test_event_series.py b/allianceauth/authentication/task_statistics/tests/test_event_series.py index 70804cf4..17889180 100644 --- a/allianceauth/authentication/task_statistics/tests/test_event_series.py +++ b/allianceauth/authentication/task_statistics/tests/test_event_series.py @@ -1,48 +1,19 @@ import datetime as dt -from unittest.mock import patch from pytz import utc -from redis import RedisError from django.test import TestCase from django.utils.timezone import now from allianceauth.authentication.task_statistics.event_series import ( EventSeries, - _RedisStub, ) +from allianceauth.authentication.task_statistics.helpers import _RedisStub MODULE_PATH = "allianceauth.authentication.task_statistics.event_series" class TestEventSeries(TestCase): - def test_should_abort_without_redis_client(self): - # when - with patch(MODULE_PATH + ".get_redis_client") as mock: - mock.return_value = None - events = EventSeries("dummy") - # then - self.assertTrue(events._redis, _RedisStub) - self.assertTrue(events.is_disabled) - - def test_should_disable_itself_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 - events = EventSeries("dummy") - # then - self.assertIsInstance(events._redis, _RedisStub) - self.assertTrue(events.is_disabled) - - def test_should_disable_itself_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 - events = EventSeries("dummy") - # then - self.assertIsInstance(events._redis, _RedisStub) - self.assertTrue(events.is_disabled) - def test_should_add_event(self): # given events = EventSeries("dummy") @@ -166,3 +137,15 @@ class TestEventSeries(TestCase): 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) diff --git a/allianceauth/authentication/task_statistics/tests/test_helpers.py b/allianceauth/authentication/task_statistics/tests/test_helpers.py new file mode 100644 index 00000000..757ae38e --- /dev/null +++ b/allianceauth/authentication/task_statistics/tests/test_helpers.py @@ -0,0 +1,142 @@ +from unittest import TestCase +from unittest.mock import patch + +from redis import RedisError + +from allianceauth.authentication.task_statistics.helpers import ( + ItemCounter, _RedisStub, get_redis_client_or_stub, +) + +MODULE_PATH = "allianceauth.authentication.task_statistics.helpers" + +COUNTER_NAME = "test-counter" + + +class TestItemCounter(TestCase): + def test_can_create_counter(self): + # when + counter = ItemCounter(COUNTER_NAME) + # then + self.assertIsInstance(counter, ItemCounter) + + def test_can_reset_counter_to_default(self): + # given + counter = ItemCounter(COUNTER_NAME) + # when + counter.reset() + # then + self.assertEqual(counter.value(), 0) + + def test_can_reset_counter_to_custom_value(self): + # given + counter = ItemCounter(COUNTER_NAME) + # when + counter.reset(42) + # then + self.assertEqual(counter.value(), 42) + + def test_can_increment_counter_by_default(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(0) + # when + counter.incr() + # then + self.assertEqual(counter.value(), 1) + + def test_can_increment_counter_by_custom_value(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(0) + # when + counter.incr(8) + # then + self.assertEqual(counter.value(), 8) + + def test_can_decrement_counter_by_default(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(9) + # when + counter.decr() + # then + self.assertEqual(counter.value(), 8) + + def test_can_decrement_counter_by_custom_value(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(9) + # when + counter.decr(8) + # then + self.assertEqual(counter.value(), 1) + + def test_can_decrement_counter_below_zero(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(0) + # when + counter.decr(1) + # then + self.assertEqual(counter.value(), -1) + + def test_can_not_decrement_counter_below_minimum(self): + # given + counter = ItemCounter(COUNTER_NAME, minimum=0) + counter.reset(0) + # when + counter.decr(1) + # then + self.assertEqual(counter.value(), 0) + + def test_can_not_reset_counter_below_minimum(self): + # given + counter = ItemCounter(COUNTER_NAME, minimum=0) + # when/then + with self.assertRaises(ValueError): + counter.reset(-1) + + def test_can_not_init_without_name(self): + # when/then + with self.assertRaises(ValueError): + ItemCounter(name="") + + def test_can_ignore_invalid_values_when_incrementing(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(0) + # when + with patch(MODULE_PATH + ".cache.incr") as m: + m.side_effect = ValueError + counter.incr() + # then + self.assertEqual(counter.value(), 0) + + def test_can_ignore_invalid_values_when_decrementing(self): + # given + counter = ItemCounter(COUNTER_NAME) + counter.reset(1) + # when + with patch(MODULE_PATH + ".cache.decr") as m: + m.side_effect = ValueError + counter.decr() + # then + self.assertEqual(counter.value(), 1) + + +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) diff --git a/allianceauth/authentication/task_statistics/tests/test_signals.py b/allianceauth/authentication/task_statistics/tests/test_signals.py index fd1530fa..1e1634bd 100644 --- a/allianceauth/authentication/task_statistics/tests/test_signals.py +++ b/allianceauth/authentication/task_statistics/tests/test_signals.py @@ -22,11 +22,12 @@ from allianceauth.eveonline.tasks import update_character class TestTaskSignals(TestCase): fixtures = ["disable_analytics"] - def test_should_record_successful_task(self): - # given + 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" @@ -39,10 +40,6 @@ class TestTaskSignals(TestCase): self.assertEqual(failed_tasks.count(), 0) def test_should_record_retried_task(self): - # given - succeeded_tasks.clear() - retried_tasks.clear() - failed_tasks.clear() # when with patch( "allianceauth.eveonline.tasks.EveCharacter.objects.update_character" @@ -55,10 +52,6 @@ class TestTaskSignals(TestCase): self.assertEqual(retried_tasks.count(), 1) def test_should_record_failed_task(self): - # given - succeeded_tasks.clear() - retried_tasks.clear() - failed_tasks.clear() # when with patch( "allianceauth.eveonline.tasks.EveCharacter.objects.update_character" diff --git a/allianceauth/authentication/templates/public/login.html b/allianceauth/authentication/templates/public/login.html index 11d4b792..11134f27 100644 --- a/allianceauth/authentication/templates/public/login.html +++ b/allianceauth/authentication/templates/public/login.html @@ -6,7 +6,7 @@ {% block page_title %}{% translate "Login" %}{% endblock %} {% block middle_box_content %} - + {% translate 'Login with Eve SSO' %} {% endblock %} diff --git a/allianceauth/authentication/tests/test_decorators.py b/allianceauth/authentication/tests/test_decorators.py index 69c3949e..5b729d01 100644 --- a/allianceauth/authentication/tests/test_decorators.py +++ b/allianceauth/authentication/tests/test_decorators.py @@ -4,16 +4,16 @@ from urllib import parse from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.http.response import HttpResponse -from django.shortcuts import reverse from django.test import TestCase from django.test.client import RequestFactory +from django.urls import reverse, URLPattern from allianceauth.eveonline.models import EveCharacter from allianceauth.tests.auth_utils import AuthUtils -from ..decorators import main_character_required -from ..models import CharacterOwnership +from ..decorators import decorate_url_patterns, main_character_required +from ..models import CharacterOwnership MODULE_PATH = 'allianceauth.authentication' @@ -66,3 +66,33 @@ class DecoratorTestCase(TestCase): setattr(self.request, 'user', self.main_user) response = self.dummy_view(self.request) self.assertEqual(response.status_code, 200) + + +class TestDecorateUrlPatterns(TestCase): + def test_should_add_decorator_by_default(self): + # given + decorator = mock.MagicMock(name="decorator") + view = mock.MagicMock(name="view") + path = mock.MagicMock(spec=URLPattern, name="path") + path.callback = view + path.lookup_str = "my_lookup_str" + urls = [path] + urlconf_module = urls + # when + decorate_url_patterns(urlconf_module, decorator) + # then + self.assertEqual(path.callback, decorator(view)) + + def test_should_not_add_decorator_when_excluded(self): + # given + decorator = mock.MagicMock(name="decorator") + view = mock.MagicMock(name="view") + path = mock.MagicMock(spec=URLPattern, name="path") + path.callback = view + path.lookup_str = "my_lookup_str" + urls = [path] + urlconf_module = urls + # when + decorate_url_patterns(urlconf_module, decorator, excluded_views=["my_lookup_str"]) + # then + self.assertEqual(path.callback, view) diff --git a/allianceauth/eveonline/views.py b/allianceauth/eveonline/views.py old mode 100755 new mode 100644 diff --git a/allianceauth/groupmanagement/views.py b/allianceauth/groupmanagement/views.py old mode 100755 new mode 100644 diff --git a/allianceauth/hrapplications/admin.py b/allianceauth/hrapplications/admin.py old mode 100755 new mode 100644 diff --git a/allianceauth/hrapplications/forms.py b/allianceauth/hrapplications/forms.py old mode 100755 new mode 100644 diff --git a/allianceauth/hrapplications/models.py b/allianceauth/hrapplications/models.py old mode 100755 new mode 100644 diff --git a/allianceauth/hrapplications/views.py b/allianceauth/hrapplications/views.py old mode 100755 new mode 100644 index fe7c93f5..789428e2 --- a/allianceauth/hrapplications/views.py +++ b/allianceauth/hrapplications/views.py @@ -57,7 +57,7 @@ def hr_application_create_view(request, form_id=None): app_form = get_object_or_404(ApplicationForm, id=form_id) if request.method == "POST": if Application.objects.filter(user=request.user).filter(form=app_form).exists(): - logger.warn(f"User {request.user} attempting to duplicate application to {app_form.corp}") + logger.warning(f"User {request.user} attempting to duplicate application to {app_form.corp}") else: application = Application(user=request.user, form=app_form) application.save() @@ -92,7 +92,7 @@ def hr_application_personal_view(request, app_id): } return render(request, 'hrapplications/view.html', context=context) else: - logger.warn(f"User {request.user} not authorized to view {app}") + logger.warning(f"User {request.user} not authorized to view {app}") return redirect('hrapplications:personal_view') @@ -105,9 +105,9 @@ def hr_application_personal_removal(request, app_id): logger.info(f"User {request.user} deleting {app}") app.delete() else: - logger.warn(f"User {request.user} attempting to delete reviewed app {app}") + logger.warning(f"User {request.user} attempting to delete reviewed app {app}") else: - logger.warn(f"User {request.user} not authorized to delete {app}") + logger.warning(f"User {request.user} not authorized to delete {app}") return redirect('hrapplications:index') @@ -132,7 +132,7 @@ def hr_application_view(request, app_id): logger.info(f"Saved comment by user {request.user} to {app}") return redirect('hrapplications:view', app_id) else: - logger.warn("User %s does not have permission to add ApplicationComments" % request.user) + logger.warning("User %s does not have permission to add ApplicationComments" % request.user) return redirect('hrapplications:view', app_id) else: logger.debug("Returning blank HRApplication comment form.") @@ -171,7 +171,7 @@ def hr_application_approve(request, app_id): app.save() notify(app.user, "Application Accepted", message="Your application to %s has been approved." % app.form.corp, level="success") else: - logger.warn(f"User {request.user} not authorized to approve {app}") + logger.warning(f"User {request.user} not authorized to approve {app}") return redirect('hrapplications:index') @@ -187,7 +187,7 @@ def hr_application_reject(request, app_id): app.save() notify(app.user, "Application Rejected", message="Your application to %s has been rejected." % app.form.corp, level="danger") else: - logger.warn(f"User {request.user} not authorized to reject {app}") + logger.warning(f"User {request.user} not authorized to reject {app}") return redirect('hrapplications:index') @@ -208,7 +208,7 @@ def hr_application_search(request): app_list = app_list.filter( form__corp__corporation_id=request.user.profile.main_character.corporation_id) except AttributeError: - logger.warn( + logger.warning( "User %s missing main character model: unable to filter applications to search" % request.user) applications = app_list.filter( @@ -246,6 +246,6 @@ def hr_application_mark_in_progress(request, app_id): app.save() notify(app.user, "Application In Progress", message=f"Your application to {app.form.corp} is being reviewed by {app.reviewer_str}") else: - logger.warn( + logger.warning( f"User {request.user} unable to mark {app} in progress: already being reviewed by {app.reviewer}") return redirect("hrapplications:view", app_id) diff --git a/allianceauth/notifications/views.py b/allianceauth/notifications/views.py index e23bf03f..40bc88f5 100644 --- a/allianceauth/notifications/views.py +++ b/allianceauth/notifications/views.py @@ -44,7 +44,7 @@ def notification_view(request, notif_id): notif.mark_viewed() return render(request, 'notifications/view.html', context) else: - logger.warn( + logger.warning( "User %s not authorized to view notif_id %s belonging to user %s", request.user, notif_id, notif.user diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 79c34d14..f777aed4 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -41,23 +41,23 @@ CELERYBEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" CELERYBEAT_SCHEDULE = { 'esi_cleanup_callbackredirect': { 'task': 'esi.tasks.cleanup_callbackredirect', - 'schedule': crontab(minute=0, hour='*/4'), + 'schedule': crontab(minute='0', hour='*/4'), }, 'esi_cleanup_token': { 'task': 'esi.tasks.cleanup_token', - 'schedule': crontab(minute=0, hour=0), + 'schedule': crontab(minute='0', hour='0'), }, 'run_model_update': { 'task': 'allianceauth.eveonline.tasks.run_model_update', - 'schedule': crontab(minute=0, hour="*/6"), + 'schedule': crontab(minute='0', hour="*/6"), }, 'check_all_character_ownership': { 'task': 'allianceauth.authentication.tasks.check_all_character_ownership', - 'schedule': crontab(minute=0, hour='*/4'), + 'schedule': crontab(minute='0', hour='*/4'), }, 'analytics_daily_stats': { 'task': 'allianceauth.analytics.tasks.analytics_daily_stats', - 'schedule': crontab(minute=0, hour=2), + 'schedule': crontab(minute='0', hour='2'), } } diff --git a/allianceauth/project_template/project_name/settings/local.py b/allianceauth/project_template/project_name/settings/local.py index 296bee02..d83fd718 100644 --- a/allianceauth/project_template/project_name/settings/local.py +++ b/allianceauth/project_template/project_name/settings/local.py @@ -32,6 +32,13 @@ INSTALLED_APPS += [ # To change the logging level for extensions, uncomment the following line. # LOGGING['handlers']['extension_file']['level'] = 'DEBUG' +# By default, apps are prevented from having public views for security reasons. +# To allow specific apps to have public views, add them to APPS_WITH_PUBLIC_VIEWS +# » The format is the same as in INSTALLED_APPS +# » The app developer must also explicitly allow public views for their app +APPS_WITH_PUBLIC_VIEWS = [ + +] # Enter credentials to use MySQL/MariaDB. Comment out to use sqlite3 DATABASES['default'] = { diff --git a/allianceauth/services/hooks.py b/allianceauth/services/hooks.py index 789bc6bc..862c7339 100644 --- a/allianceauth/services/hooks.py +++ b/allianceauth/services/hooks.py @@ -1,15 +1,18 @@ -from django.urls import include -from django.urls import re_path +from django.urls import include, re_path +from string import Formatter +from typing import Iterable, Optional + +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.template.loader import render_to_string +from django.urls import include, re_path from django.utils.functional import cached_property -from django.conf import settings -from string import Formatter from allianceauth.hooks import get_hooks from .models import NameFormatConfig + def get_extension_logger(name): """ Takes the name of a plugin/extension and generates a child logger of the extensions logger @@ -156,8 +159,32 @@ class MenuItemHook: class UrlHook: - def __init__(self, urls, namespace, base_url): + """A hook for registering the URLs of a Django app. + + Args: + - urls: The urls module to include + - namespace: The URL namespace to apply. This is usually just the app name. + - base_url: The URL prefix to match against in regex form. + Example ``r'^app_name/'``. + This prefix will be applied in front of all URL patterns included. + It is possible to use the same prefix as existing apps (or no prefix at all), + but standard URL resolution ordering applies + (hook URLs are the last ones registered). + - excluded_views: Optional list of views to be excluded + from auto-decorating them with the + default ``main_character_required`` decorator, e.g. to make them public. + Views must be specified by their qualified name, + e.g. ``["example.views.my_public_view"]`` + """ + def __init__( + self, + urls, + namespace: str, + base_url: str, + excluded_views : Optional[Iterable[str]] = None + ): self.include_pattern = re_path(base_url, include(urls, namespace=namespace)) + self.excluded_views = set(excluded_views or []) class NameFormatter: diff --git a/allianceauth/services/modules/discord/discord_client/client.py b/allianceauth/services/modules/discord/discord_client/client.py index 65da9e0c..048088e3 100644 --- a/allianceauth/services/modules/discord/discord_client/client.py +++ b/allianceauth/services/modules/discord/discord_client/client.py @@ -588,16 +588,17 @@ class DiscordClient: return None # User is no longer a member guild_roles = RolesSet(self.guild_roles(guild_id=guild_id)) logger.debug('Current guild roles: %s', guild_roles.ids()) + _roles = set(member_info.roles) if not guild_roles.has_roles(member_info.roles): guild_roles = RolesSet( self.guild_roles(guild_id=guild_id, use_cache=False) ) if not guild_roles.has_roles(member_info.roles): role_ids = set(member_info.roles).difference(guild_roles.ids()) - raise RuntimeError( - f'Discord user {user_id} has unknown roles: {role_ids}' - ) - return guild_roles.subset(member_info.roles) + logger.warning(f'Discord user {user_id} has unknown roles: {role_ids}') + for _r in role_ids: + _roles.remove(_r) + return guild_roles.subset(_roles) @classmethod def _is_member_unknown_error(cls, r: requests.Response) -> bool: diff --git a/allianceauth/services/modules/discord/discord_client/tests/test_client.py b/allianceauth/services/modules/discord/discord_client/tests/test_client.py index adf22323..5c7a5bf4 100644 --- a/allianceauth/services/modules/discord/discord_client/tests/test_client.py +++ b/allianceauth/services/modules/discord/discord_client/tests/test_client.py @@ -899,8 +899,8 @@ class TestGuildMemberRoles(NoSocketsTestCase): mock_guild_roles.return_value = {role_a, role_b} client = DiscordClientStub(TEST_BOT_TOKEN, mock_redis) # when/then - with self.assertRaises(RuntimeError): - client.guild_member_roles(TEST_GUILD_ID, TEST_USER_ID) + roles = client.guild_member_roles(TEST_GUILD_ID, TEST_USER_ID) + self.assertEqual(roles, RolesSet([role_a])) # TODO: Re-enable after adding Discord general error handling # def test_should_raise_exception_if_member_info_is_invalid( diff --git a/allianceauth/services/modules/discord/tests/piloting_tasks.py b/allianceauth/services/modules/discord/tests/piloting_tasks.py old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/discord/urls.py b/allianceauth/services/modules/discord/urls.py index de82a91c..4685a3c5 100644 --- a/allianceauth/services/modules/discord/urls.py +++ b/allianceauth/services/modules/discord/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/discourse/tasks.py b/allianceauth/services/modules/discourse/tasks.py index d3a542c8..d115211a 100644 --- a/allianceauth/services/modules/discourse/tasks.py +++ b/allianceauth/services/modules/discourse/tasks.py @@ -49,7 +49,7 @@ class DiscourseTasks: DiscourseManager.update_groups(user) except Exception as e: logger.exception(e) - logger.warn("Discourse group sync failed for %s, retrying in 10 mins" % user) + logger.warning("Discourse group sync failed for %s, retrying in 10 mins" % user) raise self.retry(countdown=60 * 10) logger.debug("Updated user %s discourse groups." % user) diff --git a/allianceauth/services/modules/example/urls.py b/allianceauth/services/modules/example/urls.py index c8a427e5..a08be65f 100644 --- a/allianceauth/services/modules/example/urls.py +++ b/allianceauth/services/modules/example/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path app_name = 'example' diff --git a/allianceauth/services/modules/ips4/urls.py b/allianceauth/services/modules/ips4/urls.py index a11bbd1b..cb8aaa4b 100644 --- a/allianceauth/services/modules/ips4/urls.py +++ b/allianceauth/services/modules/ips4/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/mumble/urls.py b/allianceauth/services/modules/mumble/urls.py index dfa77c79..ef7ff699 100644 --- a/allianceauth/services/modules/mumble/urls.py +++ b/allianceauth/services/modules/mumble/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/openfire/manager.py b/allianceauth/services/modules/openfire/manager.py old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html b/allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/openfire/urls.py b/allianceauth/services/modules/openfire/urls.py index 99ce6713..17da5b33 100644 --- a/allianceauth/services/modules/openfire/urls.py +++ b/allianceauth/services/modules/openfire/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/phpbb3/manager.py b/allianceauth/services/modules/phpbb3/manager.py old mode 100755 new mode 100644 index c62417b1..7e6120a6 --- a/allianceauth/services/modules/phpbb3/manager.py +++ b/allianceauth/services/modules/phpbb3/manager.py @@ -176,7 +176,7 @@ class Phpbb3Manager: logger.debug(f"Proceeding to add phpbb user {username_clean} and pwhash starting with {pwhash[0:5]}") # check if the username was simply revoked if Phpbb3Manager.check_user(username_clean): - logger.warn("Unable to add phpbb user with username %s - already exists. Updating user instead." % username) + logger.warning("Unable to add phpbb user with username %s - already exists. Updating user instead." % username) Phpbb3Manager.__update_user_info(username_clean, email, pwhash) else: try: diff --git a/allianceauth/services/modules/phpbb3/urls.py b/allianceauth/services/modules/phpbb3/urls.py index 5fcaba55..e9dcbd2a 100644 --- a/allianceauth/services/modules/phpbb3/urls.py +++ b/allianceauth/services/modules/phpbb3/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/smf/urls.py b/allianceauth/services/modules/smf/urls.py index 1dedc8d2..3f8308f5 100644 --- a/allianceauth/services/modules/smf/urls.py +++ b/allianceauth/services/modules/smf/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/teamspeak3/manager.py b/allianceauth/services/modules/teamspeak3/manager.py old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/teamspeak3/urls.py b/allianceauth/services/modules/teamspeak3/urls.py index 92c3093e..83cc4287 100644 --- a/allianceauth/services/modules/teamspeak3/urls.py +++ b/allianceauth/services/modules/teamspeak3/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/teamspeak3/util/__init__.py b/allianceauth/services/modules/teamspeak3/util/__init__.py old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/teamspeak3/util/ts3.py b/allianceauth/services/modules/teamspeak3/util/ts3.py old mode 100755 new mode 100644 diff --git a/allianceauth/services/modules/teamspeak3/views.py b/allianceauth/services/modules/teamspeak3/views.py index 3d1ff5e2..e54bd84b 100644 --- a/allianceauth/services/modules/teamspeak3/views.py +++ b/allianceauth/services/modules/teamspeak3/views.py @@ -44,7 +44,7 @@ def activate_teamspeak3(request): def verify_teamspeak3(request): logger.debug("verify_teamspeak3 called by user %s" % request.user) if not Teamspeak3Tasks.has_account(request.user): - logger.warn("Unable to validate user %s teamspeak: no teamspeak data" % request.user) + logger.warning("Unable to validate user %s teamspeak: no teamspeak data" % request.user) return redirect("services:services") if request.method == "POST": form = TeamspeakJoinForm(request.POST) diff --git a/allianceauth/services/modules/xenforo/urls.py b/allianceauth/services/modules/xenforo/urls.py index fe684e9f..bec8e6ef 100644 --- a/allianceauth/services/modules/xenforo/urls.py +++ b/allianceauth/services/modules/xenforo/urls.py @@ -1,5 +1,4 @@ -from django.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/templates/services/services.html b/allianceauth/services/templates/services/services.html old mode 100755 new mode 100644 diff --git a/allianceauth/services/tests/test_hooks.py b/allianceauth/services/tests/test_hooks.py new file mode 100644 index 00000000..dd3d787b --- /dev/null +++ b/allianceauth/services/tests/test_hooks.py @@ -0,0 +1,30 @@ +from unittest import TestCase + +from allianceauth.services.hooks import UrlHook +from allianceauth.groupmanagement import urls + + +class TestUrlHook(TestCase): + def test_can_create_simple_hook(self): + # when + obj = UrlHook(urls, "groupmanagement", r"^groupmanagement/") + # then + self.assertEqual(obj.include_pattern.app_name, "groupmanagement") + self.assertFalse(obj.excluded_views) + + def test_can_create_hook_with_excluded_views(self): + # when + obj = UrlHook( + urls, + "groupmanagement", + r"^groupmanagement/", + ["groupmanagement.views.group_management"], + ) + # then + self.assertEqual(obj.include_pattern.app_name, "groupmanagement") + self.assertIn("groupmanagement.views.group_management", obj.excluded_views) + + def test_should_raise_error_when_called_with_invalid_excluded_views(self): + # when/then + with self.assertRaises(TypeError): + UrlHook(urls, "groupmanagement", r"^groupmanagement/", 99) diff --git a/allianceauth/services/views.py b/allianceauth/services/views.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/__init__.py b/allianceauth/srp/__init__.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/admin.py b/allianceauth/srp/admin.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/form.py b/allianceauth/srp/form.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/models.py b/allianceauth/srp/models.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/tests/__init__.py b/allianceauth/srp/tests/__init__.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/tests/test_managers.py b/allianceauth/srp/tests/test_managers.py old mode 100755 new mode 100644 diff --git a/allianceauth/srp/views.py b/allianceauth/srp/views.py old mode 100755 new mode 100644 diff --git a/allianceauth/static/allianceauth/css/auth-base.css b/allianceauth/static/allianceauth/css/auth-base.css index 6eb51d8e..b2b18aa5 100644 --- a/allianceauth/static/allianceauth/css/auth-base.css +++ b/allianceauth/static/allianceauth/css/auth-base.css @@ -122,7 +122,7 @@ ul.list-group.list-group-horizontal > li.list-group-item { padding-top: 0.5rem; } - .navbar-nav > li.top-user-menu.with-main-character a { + .navbar-nav > li.top-user-menu a { padding: 14px; } diff --git a/allianceauth/static/allianceauth/icons/android-chrome-192x192.png b/allianceauth/static/allianceauth/icons/android-chrome-192x192.png new file mode 100644 index 00000000..a83b4d06 Binary files /dev/null and b/allianceauth/static/allianceauth/icons/android-chrome-192x192.png differ diff --git a/allianceauth/static/allianceauth/icons/android-chrome-512x512.png b/allianceauth/static/allianceauth/icons/android-chrome-512x512.png new file mode 100644 index 00000000..2cdf71f0 Binary files /dev/null and b/allianceauth/static/allianceauth/icons/android-chrome-512x512.png differ diff --git a/allianceauth/static/allianceauth/icons/apple-touch-icon.png b/allianceauth/static/allianceauth/icons/apple-touch-icon.png old mode 100755 new mode 100644 index f1d16af3..7c7ea0f6 Binary files a/allianceauth/static/allianceauth/icons/apple-touch-icon.png and b/allianceauth/static/allianceauth/icons/apple-touch-icon.png differ diff --git a/allianceauth/static/allianceauth/icons/browserconfig.xml b/allianceauth/static/allianceauth/icons/browserconfig.xml new file mode 100644 index 00000000..1677cc2c --- /dev/null +++ b/allianceauth/static/allianceauth/icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2d89ef + + + diff --git a/allianceauth/static/allianceauth/icons/favicon-16x16.png b/allianceauth/static/allianceauth/icons/favicon-16x16.png old mode 100755 new mode 100644 index 2e17b905..454c33a0 Binary files a/allianceauth/static/allianceauth/icons/favicon-16x16.png and b/allianceauth/static/allianceauth/icons/favicon-16x16.png differ diff --git a/allianceauth/static/allianceauth/icons/favicon-32x32.png b/allianceauth/static/allianceauth/icons/favicon-32x32.png old mode 100755 new mode 100644 index fcd164fd..1aa26b78 Binary files a/allianceauth/static/allianceauth/icons/favicon-32x32.png and b/allianceauth/static/allianceauth/icons/favicon-32x32.png differ diff --git a/allianceauth/static/allianceauth/icons/favicon.ico b/allianceauth/static/allianceauth/icons/favicon.ico new file mode 100644 index 00000000..34c9d60d Binary files /dev/null and b/allianceauth/static/allianceauth/icons/favicon.ico differ diff --git a/allianceauth/static/allianceauth/icons/mstile-150x150.png b/allianceauth/static/allianceauth/icons/mstile-150x150.png new file mode 100644 index 00000000..df59dcc9 Binary files /dev/null and b/allianceauth/static/allianceauth/icons/mstile-150x150.png differ diff --git a/allianceauth/static/allianceauth/icons/safari-pinned-tab.svg b/allianceauth/static/allianceauth/icons/safari-pinned-tab.svg new file mode 100644 index 00000000..eb448142 --- /dev/null +++ b/allianceauth/static/allianceauth/icons/safari-pinned-tab.svg @@ -0,0 +1,41 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + diff --git a/allianceauth/static/allianceauth/icons/site.webmanifest b/allianceauth/static/allianceauth/icons/site.webmanifest new file mode 100644 index 00000000..5e0c19c8 --- /dev/null +++ b/allianceauth/static/allianceauth/icons/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/static/allianceauth/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/allianceauth/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/allianceauth/static/allianceauth/js/timerboard.js b/allianceauth/static/allianceauth/js/timerboard.js new file mode 100644 index 00000000..26ceb226 --- /dev/null +++ b/allianceauth/static/allianceauth/js/timerboard.js @@ -0,0 +1,30 @@ +$(document).ready(() => { + 'use strict'; + const inputAbsoluteTime = $('input#id_absolute_time'); + const inputCountdown = $('#id_days_left, #id_hours_left, #id_minutes_left'); + + //inputAbsoluteTime.prop('disabled', true); + inputAbsoluteTime.parent().hide() + inputAbsoluteTime.parent().prev('label').hide() + inputCountdown.prop('required', true); + + $('input#id_absolute_checkbox').change(function () { + if ($(this).prop("checked")) { + // check box enabled + inputAbsoluteTime.parent().show() + inputAbsoluteTime.parent().prev('label').show() + inputCountdown.parent().hide() + inputCountdown.parent().prev('label').hide() + inputAbsoluteTime.prop('required', true); + inputCountdown.prop('required', false); + } else { + // Checkbox is not checked + inputAbsoluteTime.parent().hide() + inputAbsoluteTime.parent().prev('label').hide() + inputCountdown.parent().show() + inputCountdown.parent().prev('label').show() + inputAbsoluteTime.prop('required', false); + inputCountdown.prop('required', true); + } + }); +}); diff --git a/allianceauth/templates/allianceauth/admin-status/overview.html b/allianceauth/templates/allianceauth/admin-status/overview.html index b0ce104b..6f04af23 100644 --- a/allianceauth/templates/allianceauth/admin-status/overview.html +++ b/allianceauth/templates/allianceauth/admin-status/overview.html @@ -92,8 +92,11 @@ {% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}

+ {% blocktranslate with running_count=tasks_running|default_if_none:"?"|intcomma %} + {{ running_count }} running | + {% endblocktranslate %} {% blocktranslate with queue_length=task_queue_length|default_if_none:"?"|intcomma %} - {{ queue_length }} queued tasks + {{ queue_length }} queued {% endblocktranslate %}

diff --git a/allianceauth/templates/allianceauth/base.html b/allianceauth/templates/allianceauth/base.html index 2becf3c2..f82e7124 100644 --- a/allianceauth/templates/allianceauth/base.html +++ b/allianceauth/templates/allianceauth/base.html @@ -21,33 +21,37 @@ - {% if user.is_authenticated %} -
- - {% include 'allianceauth/top-menu.html' %} -
+
+ + {% include 'allianceauth/top-menu.html' %} + +
+ {% if user.is_authenticated %} {% include 'allianceauth/side-menu.html' %} -
- {% include 'allianceauth/messages.html' %} - {% block content %} - {% endblock content %} -
-
+ {% endif %} + +
+ {% include 'allianceauth/messages.html' %} + + {% block content %} + {% endblock content %}
- {% endif %} +
{% include 'bundles/bootstrap-js.html' %} {% include 'bundles/jquery-visibility-js.html' %} - - {% include 'bundles/refresh-notifications-js.html' %} + {% if user.is_authenticated %} + + {% include 'bundles/refresh-notifications-js.html' %} + {% endif %} {% include 'bundles/evetime-js.html' %} {% block extra_javascript %} diff --git a/allianceauth/templates/allianceauth/icons.html b/allianceauth/templates/allianceauth/icons.html index 9a661dc7..10b058a2 100644 --- a/allianceauth/templates/allianceauth/icons.html +++ b/allianceauth/templates/allianceauth/icons.html @@ -1,5 +1,12 @@ {% load static %} + + - + + + + + + diff --git a/allianceauth/templates/allianceauth/top-menu-user-dropdown.html b/allianceauth/templates/allianceauth/top-menu-user-dropdown.html index 2c9760be..99d29e5d 100644 --- a/allianceauth/templates/allianceauth/top-menu-user-dropdown.html +++ b/allianceauth/templates/allianceauth/top-menu-user-dropdown.html @@ -11,7 +11,10 @@ {% endwith %} {% else %} - {% translate "User Menu" %} + {{ main.character_name }} + {% endif %} diff --git a/allianceauth/templates/allianceauth/top-menu.html b/allianceauth/templates/allianceauth/top-menu.html index 2b501336..b09a1091 100644 --- a/allianceauth/templates/allianceauth/top-menu.html +++ b/allianceauth/templates/allianceauth/top-menu.html @@ -22,9 +22,12 @@ - + + {% if user.is_authenticated %} + + {% endif %} {% include 'allianceauth/top-menu-user-dropdown.html' %} diff --git a/allianceauth/templates/bundles/timerboard-js.html b/allianceauth/templates/bundles/timerboard-js.html new file mode 100644 index 00000000..8cf6f353 --- /dev/null +++ b/allianceauth/templates/bundles/timerboard-js.html @@ -0,0 +1,3 @@ +{% load static %} + + diff --git a/allianceauth/templatetags/admin_status.py b/allianceauth/templatetags/admin_status.py index efdafec9..9a896926 100644 --- a/allianceauth/templatetags/admin_status.py +++ b/allianceauth/templatetags/admin_status.py @@ -40,7 +40,7 @@ def decimal_widthratio(this_value, max_value, max_width) -> str: if max_value == 0: return str(0) - return str(round(this_value/max_value * max_width, 2)) + return str(round(this_value / max_value * max_width, 2)) @register.inclusion_tag('allianceauth/admin-status/overview.html') @@ -54,7 +54,8 @@ def status_overview() -> dict: "tasks_failed": 0, "tasks_total": 0, "tasks_hours": 0, - "earliest_task": None + "earliest_task": None, + "tasks_running": 0 } response.update(_current_notifications()) response.update(_current_version_summary()) @@ -72,7 +73,8 @@ def _celery_stats() -> dict: "tasks_failed": results.failed, "tasks_total": results.total, "tasks_hours": results.hours, - "earliest_task": results.earliest_task + "earliest_task": results.earliest_task, + "tasks_running": results.running, } diff --git a/allianceauth/tests/test_urls.py b/allianceauth/tests/test_urls.py new file mode 100644 index 00000000..95315b8d --- /dev/null +++ b/allianceauth/tests/test_urls.py @@ -0,0 +1,72 @@ +from unittest import TestCase +from unittest.mock import patch, MagicMock +from django.urls import URLPattern + +from allianceauth.services.hooks import UrlHook +from allianceauth.urls import urls_from_apps + +MODULE_PATH = "allianceauth.urls" + + +@patch(MODULE_PATH + ".main_character_required") +@patch(MODULE_PATH + ".decorate_url_patterns") +class TestUrlsFromApps(TestCase): + def test_should_decorate_url_by_default(self, mock_decorate_url_patterns, mock_main_character_required): + # given + def hook_function(): + return UrlHook(urlconf_module, "my_namespace", r"^my_app/") + + view = MagicMock(name="view") + path = MagicMock(spec=URLPattern, name="path") + path.callback = view + urlconf_module = [patch], "my_app" + # when + result = urls_from_apps([hook_function], []) + # then + self.assertIsInstance(result[0], URLPattern) + self.assertTrue(mock_decorate_url_patterns.called) + args, _ = mock_decorate_url_patterns.call_args + decorator = args[1] + self.assertEqual(decorator, mock_main_character_required) + excluded_views = args[2] + self.assertIsNone(excluded_views) + + def test_should_not_decorate_when_excluded(self, mock_decorate_url_patterns, mock_main_character_required): + # given + def hook_function(): + return UrlHook(urlconf_module, "my_namespace", r"^my_app/", ["excluded_view"]) + + view = MagicMock(name="view") + path = MagicMock(spec=URLPattern, name="path") + path.callback = view + urlconf_module = [patch], "my_app" + # when + result = urls_from_apps([hook_function], ["my_app"]) + # then + self.assertIsInstance(result[0], URLPattern) + self.assertTrue(mock_decorate_url_patterns.called) + args, _ = mock_decorate_url_patterns.call_args + decorator = args[1] + self.assertEqual(decorator, mock_main_character_required) + excluded_views = args[2] + self.assertSetEqual(excluded_views, {"excluded_view"}) + + def test_should_decorate_when_app_has_no_permission(self, mock_decorate_url_patterns, mock_main_character_required): + # given + def hook_function(): + return UrlHook(urlconf_module, "my_namespace", r"^my_app/", ["excluded_view"]) + + view = MagicMock(name="view") + path = MagicMock(spec=URLPattern, name="path") + path.callback = view + urlconf_module = [patch], "my_app" + # when + result = urls_from_apps([hook_function], ["other_app"]) + # then + self.assertIsInstance(result[0], URLPattern) + self.assertTrue(mock_decorate_url_patterns.called) + args, _ = mock_decorate_url_patterns.call_args + decorator = args[1] + self.assertEqual(decorator, mock_main_character_required) + excluded_views = args[2] + self.assertIsNone(excluded_views) diff --git a/allianceauth/timerboard/__init__.py b/allianceauth/timerboard/__init__.py old mode 100755 new mode 100644 diff --git a/allianceauth/timerboard/admin.py b/allianceauth/timerboard/admin.py old mode 100755 new mode 100644 diff --git a/allianceauth/timerboard/form.py b/allianceauth/timerboard/form.py old mode 100755 new mode 100644 index 121d4ff3..e42cc10c --- a/allianceauth/timerboard/form.py +++ b/allianceauth/timerboard/form.py @@ -61,14 +61,17 @@ class TimerForm(forms.ModelForm): structure = forms.ChoiceField(choices=structure_choices, required=True, label=_("Structure Type")) timer_type = forms.ChoiceField(choices=TimerType.choices, label=_("Timer Type")) objective = forms.ChoiceField(choices=objective_choices, required=True, label=_("Objective")) - days_left = forms.IntegerField(required=True, label=_("Days Remaining"), validators=[MinValueValidator(0)]) - hours_left = forms.IntegerField(required=True, label=_("Hours Remaining"), + absolute_checkbox = forms.BooleanField(label=_("Absolute Timer"), required=False, initial=False) + absolute_time = forms.CharField(required=False,label=_("Date and Time")) + days_left = forms.IntegerField(required=False, label=_("Days Remaining"), validators=[MinValueValidator(0)]) + hours_left = forms.IntegerField(required=False, label=_("Hours Remaining"), validators=[MinValueValidator(0), MaxValueValidator(23)]) - minutes_left = forms.IntegerField(required=True, label=_("Minutes Remaining"), + minutes_left = forms.IntegerField(required=False, label=_("Minutes Remaining"), validators=[MinValueValidator(0), MaxValueValidator(59)]) important = forms.BooleanField(label=_("Important"), required=False) corp_timer = forms.BooleanField(label=_("Corp-Restricted"), required=False) + def save(self, commit=True): timer = super().save(commit=False) @@ -77,18 +80,30 @@ class TimerForm(forms.ModelForm): corporation = character.corporation logger.debug("Determined timer save request on behalf " "of character {} corporation {}".format(character, corporation)) - # calculate future time - future_time = datetime.timedelta(days=self.cleaned_data['days_left'], hours=self.cleaned_data['hours_left'], - minutes=self.cleaned_data['minutes_left']) - current_time = timezone.now() - eve_time = current_time + future_time - logger.debug( - f"Determined timer eve time is {eve_time} - current time {current_time}, adding {future_time}") + + days_left = self.cleaned_data['days_left'] + hours_left = self.cleaned_data['hours_left'] + minutes_left = self.cleaned_data['minutes_left'] + absolute_time = self.cleaned_data['absolute_time'] + + if days_left or hours_left or minutes_left: + # Calculate future time + future_time = datetime.timedelta(days=days_left, hours=hours_left, minutes=minutes_left) + current_time = timezone.now() + eve_time = current_time + future_time + logger.debug(f"Determined timer eve time is {eve_time} - current time {current_time}, adding {future_time}") + elif absolute_time: + # Use absolute time + eve_time = absolute_time + else: + raise ValueError("Either future time or absolute time must be provided.") timer.eve_time = eve_time timer.eve_character = character timer.eve_corp = corporation timer.user = self.user + if commit: timer.save() + return timer diff --git a/allianceauth/timerboard/models.py b/allianceauth/timerboard/models.py old mode 100755 new mode 100644 diff --git a/allianceauth/timerboard/templates/timerboard/form.html b/allianceauth/timerboard/templates/timerboard/form.html index e52727c3..35faab0b 100644 --- a/allianceauth/timerboard/templates/timerboard/form.html +++ b/allianceauth/timerboard/templates/timerboard/form.html @@ -30,3 +30,21 @@
{% endblock content %} + +{% block extra_javascript %} +{% include 'bundles/jquery-datetimepicker-js.html' %} +{% endblock %} + +{% block extra_script %} +$('#id_start').datetimepicker({ +setlocale: '{{ LANGUAGE_CODE }}', +{% if NIGHT_MODE %} +theme: 'dark', +{% else %} +theme: 'default', +{% endif %} +mask: true, +format: 'Y-m-d H:i', +minDate: 0 +}); +{% endblock extra_script %} diff --git a/allianceauth/timerboard/templates/timerboard/timer_create_form.html b/allianceauth/timerboard/templates/timerboard/timer_create_form.html index 9ff5dc75..c8d2d0d1 100644 --- a/allianceauth/timerboard/templates/timerboard/timer_create_form.html +++ b/allianceauth/timerboard/templates/timerboard/timer_create_form.html @@ -12,3 +12,19 @@ {% block submit_button_text %} {% translate "Create Timer" %} {% endblock %} + +{% block extra_javascript %} +{% include 'bundles/timerboard-js.html' %} +{% include 'bundles/jquery-datetimepicker-js.html' %} +{% include 'bundles/jquery-datetimepicker-css.html' %} +{% endblock %} + +{% block extra_script %} +$('input#id_absolute_time').datetimepicker({ + setlocale: '{{ LANGUAGE_CODE }}', + theme: {% if NIGHT_MODE %}'dark'{% else %}'default'{% endif %}, + format: 'Y-m-d H:i', + minDate: 0, + defaultDate: null +}); +{% endblock extra_script %} diff --git a/allianceauth/timerboard/templates/timerboard/timer_update_form.html b/allianceauth/timerboard/templates/timerboard/timer_update_form.html index 7b2b0f48..b668ae3e 100644 --- a/allianceauth/timerboard/templates/timerboard/timer_update_form.html +++ b/allianceauth/timerboard/templates/timerboard/timer_update_form.html @@ -12,3 +12,23 @@ {% block submit_button_text %} {% translate "Update Structure Timer" %} {% endblock %} + +{% block extra_javascript %} +{% include 'bundles/timerboard-js.html' %} +{% include 'bundles/jquery-datetimepicker-js.html' %} +{% include 'bundles/jquery-datetimepicker-css.html' %} +{% endblock %} + +{% block extra_script %} +$('input#id_absolute_time').datetimepicker({ + setlocale: '{{ LANGUAGE_CODE }}', + {% if NIGHT_MODE %} + theme: 'dark', + {% else %} + theme: 'default', + {% endif %} + mask: true, + format: 'Y-m-d H:i', + defaultDate: null +}); +{% endblock extra_script %} diff --git a/allianceauth/timerboard/views.py b/allianceauth/timerboard/views.py old mode 100755 new mode 100644 diff --git a/allianceauth/urls.py b/allianceauth/urls.py old mode 100755 new mode 100644 index e9361cdf..20189356 --- a/allianceauth/urls.py +++ b/allianceauth/urls.py @@ -1,24 +1,55 @@ -from django.urls import path -import esi.urls +from typing import List, Iterable, Callable from django.urls import include +import esi.urls +from django.conf import settings from django.contrib import admin +from django.urls import URLPattern, include, path from django.views.generic.base import TemplateView -import allianceauth.authentication.views import allianceauth.authentication.urls -import allianceauth.notifications.urls +import allianceauth.authentication.views import allianceauth.groupmanagement.urls +import allianceauth.notifications.urls import allianceauth.services.urls -from allianceauth.authentication.decorators import main_character_required, decorate_url_patterns -from allianceauth import NAME -from allianceauth import views +from allianceauth import NAME, views from allianceauth.authentication import hmac_urls +from allianceauth.authentication.decorators import ( + decorate_url_patterns, + main_character_required +) from allianceauth.hooks import get_hooks admin.site.site_header = NAME +def urls_from_apps( + apps_hook_functions: Iterable[Callable], public_views_allowed: List[str] +) -> List[URLPattern]: + """Return urls from apps and add default decorators.""" + + url_patterns = [] + allowed_apps = set(public_views_allowed) + for app_hook_function in apps_hook_functions: + url_hook = app_hook_function() + app_pattern = url_hook.include_pattern + excluded_views = ( + url_hook.excluded_views + if app_pattern.app_name in allowed_apps + else None + ) + url_patterns += [ + path( + "", + decorate_url_patterns( + [app_pattern], main_character_required, excluded_views + ) + ) + ] + + return url_patterns + + # Functional/Untranslated URL's urlpatterns = [ # Locale @@ -49,8 +80,6 @@ urlpatterns = [ path('night/', views.NightModeRedirectView.as_view(), name='nightmode') ] - -# Append app urls -app_urls = get_hooks('url_hook') -for app in app_urls: - urlpatterns += [path('', decorate_url_patterns([app().include_pattern], main_character_required))] +url_hooks = get_hooks("url_hook") +public_views_allows = getattr(settings, "APPS_WITH_PUBLIC_VIEWS", []) +urlpatterns += urls_from_apps(url_hooks, public_views_allows) diff --git a/docker/.env.example b/docker/.env.example index a66850fd..e8e2d55f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,7 +1,7 @@ PROTOCOL=https:// AUTH_SUBDOMAIN=%AUTH_SUBDOMAIN% DOMAIN=%DOMAIN% -AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.4.0 +AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.6.1 # Nginx Proxy Manager PROXY_HTTP_PORT=80 @@ -21,6 +21,7 @@ AA_DB_NAME=alliance_auth AA_DB_USER=aauth AA_DB_PASSWORD=%AA_DB_PASSWORD% AA_DB_ROOT_PASSWORD=%AA_DB_ROOT_PASSWORD% +AA_DB_CHARSET=utf8mb4 AA_EMAIL_HOST='' AA_EMAIL_PORT=587 AA_EMAIL_HOST_USER='' diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d80eac9..b526e803 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,7 +18,7 @@ RUN mkdir -p ${STATIC_BASE} \ # Install build dependencies RUN apt-get update && apt-get upgrade -y && apt-get install -y \ - libmariadb-dev gcc git + libmariadb-dev gcc git pkg-config # Install python dependencies RUN pip install --upgrade pip diff --git a/docker/conf/local.py b/docker/conf/local.py index 2d14eeac..c1b3c646 100644 --- a/docker/conf/local.py +++ b/docker/conf/local.py @@ -17,6 +17,9 @@ DATABASES["default"] = { "PASSWORD": os.environ.get("AA_DB_PASSWORD"), "HOST": os.environ.get("AA_DB_HOST"), "PORT": os.environ.get("AA_DB_PORT", "3306"), + "OPTIONS": { + "charset": os.environ.get("AA_DB_CHARSET", "utf8mb4") + } } # Register an application at https://developers.eveonline.com for Authentication diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ffb0440a..24d4acbd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -115,7 +115,13 @@ services: depends_on: - auth_mysql volumes: + - ./grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yaml + - ./grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/datasource.yaml + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana + environment: + GF_INSTALL_PLUGINS: grafana-piechart-panel,grafana-clock-panel,grafana-simple-json-datasource + GF_AUTH_DATABASE_PASSWORD: ${GRAFANA_DB_PASSWORD} proxy: image: 'jc21/nginx-proxy-manager:latest' diff --git a/docker/grafana-dashboards.yml b/docker/grafana-dashboards.yml new file mode 100644 index 00000000..3e0aa5c7 --- /dev/null +++ b/docker/grafana-dashboards.yml @@ -0,0 +1,25 @@ +apiVersion: 1 + +providers: + # an unique provider name +- name: 'auth dashboards' + # org id. will default to orgId 1 if not specified + orgId: 1 + # name of the dashboard folder. Required + folder: '' + # folder UID. will be automatically generated if not specified + folderUid: '' + # provider type. Required + type: file + # disable dashboard deletion + disableDeletion: false + # enable dashboard editing + editable: true + # how often Grafana will scan for changed dashboards + updateIntervalSeconds: 10 + # allow updating provisioned dashboards from the UI + allowUiUpdates: false + options: + # path to dashboard files on disk. Required + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/docker/grafana-datasource.yml b/docker/grafana-datasource.yml new file mode 100644 index 00000000..bc5b2115 --- /dev/null +++ b/docker/grafana-datasource.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +datasources: + +- name: MySQL + type: mysql + url: auth_mysql + database: alliance_auth + user: grafana + editable: true + secureJsonData: + password: ${GF_AUTH_DATABASE_PASSWORD} diff --git a/docs/customizing/index.md b/docs/customizing/index.md index f7cf5971..3ff85913 100644 --- a/docs/customizing/index.md +++ b/docs/customizing/index.md @@ -71,3 +71,51 @@ urlpatterns = [ re_path(r'', include(allianceauth.urls)), ] ``` + +## Example: Adding an external link to the sidebar + +As an example we are adding an external links to the Alliance Auth sidebar using the template overrides feature. For our example let's add a link to Google's start page. + +### Step 1 - Create the template override folder + +First you need to create the folder for the template on your server. For AA to pick it up it has to match a specific structure. + +If you have a default installation you can create the folder like this: + +```sh +mkdir -p /home/allianceserver/myauth/myauth/templates/allianceauth +``` + +### Step 2 - Download the original template + +Next you need to download a copy of the original template file we want to change. For that let's move into the above folder and then download the file into the current folder with: + +```sh +cd /home/allianceserver/myauth/myauth/templates/allianceauth +wget +``` + +### Step 3 - Modify the template + +Now you can modify the template to add your custom link. To create the google link we can add this snippet *between* the `{% menu_items %}` and the `` tag: + +```sh +nano /home/allianceserver/myauth/myauth/templates/allianceauth/side-menu.html +``` + +```jinja +
  • + + Google + +
  • +``` + +```eval_rst +.. hint:: + You can find other icons with a matching style on the `Font Awesome site `_ . AA currently uses Font Awesome version 5. You also want to keep the ``fa-fw`` tag to ensure all icons have the same width. +``` + +### Step 4 - Restart your AA services + +Finally, restart your AA services and your custom link should appear in the sidebar. diff --git a/docs/development/custom/url-hooks.md b/docs/development/custom/url-hooks.md index f9d76d56..1c43544a 100644 --- a/docs/development/custom/url-hooks.md +++ b/docs/development/custom/url-hooks.md @@ -1,54 +1,65 @@ # URL Hooks -```eval_rst -.. note:: - URLs added through URL Hooks are protected by a decorator which ensures the requesting user is logged in and has a main character set. -``` +## Base functionality The URL hooks allow you to dynamically specify URL patterns from your plugin app or service. To achieve this you should subclass or instantiate the `services.hooks.UrlHook` class and then register the URL patterns with the hook. To register a UrlHook class you would do the following: - @hooks.register('url_hook') - def register_urls(): - return UrlHook(app_name.urls, 'app_name', r^'app_name/') +```python +@hooks.register('url_hook') +def register_urls(): + return UrlHook(app_name.urls, 'app_name', r^'app_name/') +``` +### Public views -The `UrlHook` class specifies some parameters/instance variables required for URL pattern inclusion. +In addition is it possible to make views public. Normally, all views are automatically decorated with the `main_character_required` decorator. That decorator ensures a user needs to be logged in and have a main before he can access that view. This feature protects against a community app sneaking in a public view without the administrator knowing about it. -`UrlHook(urls, app_name, base_url)` +An app can opt-out of this feature by adding a list of views to be excluded when registering the URLs. See the `excluded_views` parameter for details. -#### urls -The urls module to include. See [the Django docs](https://docs.djangoproject.com/en/dev/topics/http/urls/#example) for designing urlpatterns. -#### namespace -The URL namespace to apply. This is usually just the app name. -#### base_url -The URL prefix to match against in regex form. Example `r'^app_name/'`. This prefix will be applied in front of all URL patterns included. It is possible to use the same prefix as existing apps (or no prefix at all) but [standard URL resolution](https://docs.djangoproject.com/en/dev/topics/http/urls/#how-django-processes-a-request) ordering applies (hook URLs are the last ones registered). +```eval_rst +.. note:: + Note that for a public view to work, administrators need to also explicitly allow apps to have public views in their AA installation, by adding the apps label to ``APPS_WITH_PUBLIC_VIEWS`` setting. +``` -### Example +## Examples An app called `plugin` provides a single view: - def index(request): - return render(request, 'plugin/index.html') +```python +def index(request): + return render(request, 'plugin/index.html') +``` The app's `urls.py` would look like so: - from django.urls import path - import plugin.views +```python +from django.urls import path +import plugin.views - urlpatterns = [ - path('index/', plugins.views.index, name='index'), - ] +urlpatterns = [ + path('index/', plugins.views.index, name='index'), +] +``` Subsequently it would implement the UrlHook in a dedicated `auth_hooks.py` file like so: - from alliance_auth import hooks - from services.hooks import UrlHook - import plugin.urls +```python +from alliance_auth import hooks +from services.hooks import UrlHook +import plugin.urls - @hooks.register('url_hook') - def register_urls(): - return UrlHook(plugin.urls, 'plugin', r^'plugin/') +@hooks.register('url_hook') +def register_urls(): + return UrlHook(plugin.urls, 'plugin', r^'plugin/') +``` When this app is included in the project's `settings.INSTALLED_APPS` users would access the index view by navigating to `https://example.com/plugin/index`. + +## API + +```eval_rst +.. autoclass:: allianceauth.services.hooks.UrlHook + :members: +``` diff --git a/docs/development/dev_setup/aa-dev-setup-wsl-vsc-v2.md b/docs/development/dev_setup/aa-dev-setup-wsl-vsc-v2.md index 22a12456..ab3b8217 100644 --- a/docs/development/dev_setup/aa-dev-setup-wsl-vsc-v2.md +++ b/docs/development/dev_setup/aa-dev-setup-wsl-vsc-v2.md @@ -82,6 +82,10 @@ Next we need to install Python and related development tools. .. note:: Should your Ubuntu come with a newer version of Python we recommend to still setup your dev environment with the oldest Python 3 version currently supported by AA (e.g Python 3.8 at this time of writing) to ensure your apps are compatible with all current AA installations You an check out this `page `_ on how to install additional Python versions on Ubuntu. + + If you install a different python version from the default you need to adjust some of the commands below to install appopriate versions of those packages for example using Python 3.8 you might need to run the following after using the setup steps for the repository mentioned in the AskUbuntu post above: + + `sudo apt-get install python3.8 python3.8-dev python3.8-venv python3-setuptools python3-pip python-pip` ``` Use the following command to install Python 3 with all required libraries with the default version: @@ -93,7 +97,7 @@ sudo apt-get install python3 python3-dev python3-venv python3-setuptools python3 ### Install redis and other tools ```bash -sudo apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev +sudo apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev pkg-config ``` Start redis diff --git a/docs/features/apps/corpstats.md b/docs/features/apps/corpstats.md index 2e4d82ff..b25f1f3c 100644 --- a/docs/features/apps/corpstats.md +++ b/docs/features/apps/corpstats.md @@ -113,7 +113,7 @@ By default Corp Stats are only updated on demand. If you want to automatically r ```python CELERYBEAT_SCHEDULE['update_all_corpstats'] = { 'task': 'allianceauth.corputils.tasks.update_all_corpstats', - 'schedule': crontab(minute=0, hour="*/6"), + 'schedule': crontab(minute="0", hour="*/6"), } ``` diff --git a/docs/features/services/discord.md b/docs/features/services/discord.md index 9c1011bc..72159c56 100644 --- a/docs/features/services/discord.md +++ b/docs/features/services/discord.md @@ -29,7 +29,7 @@ DISCORD_SYNC_NAMES = False CELERYBEAT_SCHEDULE['discord.update_all_usernames'] = { 'task': 'discord.update_all_usernames', - 'schedule': crontab(minute=0, hour='*/12'), + 'schedule': crontab(minute='0', hour='*/12'), } ``` diff --git a/docs/installation/allianceauth.md b/docs/installation/allianceauth.md index cfd1cb8d..ba51681d 100644 --- a/docs/installation/allianceauth.md +++ b/docs/installation/allianceauth.md @@ -220,13 +220,13 @@ A few extra utilities are also required for installation of packages. Ubuntu 1804, 2004, 2204: ```bash -sudo apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev build-essential +sudo apt-get install unzip git redis-server curl libssl-dev libbz2-dev libffi-dev build-essential pkg-config ``` CentOS 7: ```bash -sudo yum install gcc gcc-c++ unzip git redis curl bzip2-devel openssl-devel libffi-devel wget +sudo yum install gcc gcc-c++ unzip git redis curl bzip2-devel openssl-devel libffi-devel wget pkg-config ``` ```bash diff --git a/docs/installation/apache.md b/docs/installation/apache.md index 4b268509..79f8c615 100644 --- a/docs/installation/apache.md +++ b/docs/installation/apache.md @@ -75,6 +75,7 @@ Place your virtual host configuration in the appropriate section within `/etc/ht ProxyPassMatch ^/static ! ProxyPassMatch ^/robots.txt ! + ProxyPassMatch ^/favicon.ico ! ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ @@ -82,6 +83,7 @@ Place your virtual host configuration in the appropriate section within `/etc/ht Alias "/static" "/var/www/myauth/static" Alias "/robots.txt" "/var/www/myauth/static/robots.txt" + Alias "/favicon.ico" "/var/www/myauth/static/allianceauth/icons/favicon.ico" Require all granted @@ -91,6 +93,11 @@ Place your virtual host configuration in the appropriate section within `/etc/ht SetHandler None Require all granted + + + SetHandler None + Require all granted + ``` diff --git a/docs/installation/nginx.md b/docs/installation/nginx.md index 61a250ea..a79a86ae 100644 --- a/docs/installation/nginx.md +++ b/docs/installation/nginx.md @@ -79,9 +79,9 @@ Copy this basic config into your config file. Make whatever changes you feel are ``` server { listen 80; - server_name example.com; + listen [::]:80; - location = /favicon.ico { access_log off; log_not_found off; } + server_name example.com; location /static { alias /var/www/myauth/static; @@ -92,6 +92,10 @@ server { alias /var/www/myauth/static/robots.txt; } + location /favicon.ico { + alias /var/www/myauth/static/allianceauth/icons/favicon.ico; + } + # Gunicorn config goes below location / { include proxy_params; @@ -110,6 +114,7 @@ Your config will need a few additions once you've got your certificate. ``` listen 443 ssl http2; # Replace listen 80; with this + listen [::]:443 ssl http2; # Replace listen [::]:80; with this ssl_certificate /path/to/your/cert.crt; ssl_certificate_key /path/to/your/cert.key; @@ -126,6 +131,8 @@ If you want to redirect all your non-SSL visitors to your secure site, below you ``` server { listen 80; + listen [::]:80; + server_name example.com; # Redirect all HTTP requests to HTTPS with a 301 Moved Permanently response. diff --git a/docs/maintenance/apps.md b/docs/maintenance/apps.md index 9707777a..59fafcf1 100644 --- a/docs/maintenance/apps.md +++ b/docs/maintenance/apps.md @@ -1,15 +1,95 @@ # App Maintenance -## Adding and Removing Apps +## Adding Apps Your auth project is just a regular Django project - you can add in [other Django apps](https://djangopackages.org/) as desired. Most come with dedicated setup guides, but here is the general procedure: 1. add `'appname',` to your `INSTALLED_APPS` setting in `local.py` 2. run `python manage.py migrate` -3. run `python manage.py collectstatic` +3. run `python manage.py collectstatic --noinput` 4. restart AA with `supervisorctl restart myauth:` -If you ever want to remove an app, you should first clear it from the database to avoid dangling foreign keys: `python manage.py migrate appname zero`. Then you can remove it from your auth project's `INSTALLED_APPS` list. +## Removing Apps + +The following instructions will explain how you can remove an app properly fom your Alliance Auth installation. + +```eval_rst +.. note:: + We recommend following these instructions to avoid dangling foreign keys or orphaned Python packages on your system, which might cause conflicts with other apps down the road. + +``` + +### Step 1 - Removing database tables + +First, we want to remove the app related tables from the database. + +#### Automatic table removal + +Let's first try the automatic approach by running the following command: + +```sh +python manage.py migrate appname zero +``` + +If that worked you'll get a confirmation message. + +If that did not work and you got error messages, you will need to remove the tables manually. This is pretty common btw, because many apps use sophisticated table setups, which can not be removed automatically by Django. + +#### Manual table removal + +First, tell Django that these migrations are no longer in effect (note the additional `--fake`): + +```sh +python manage.py migrate appname zero --fake +``` + +Then, open the mysql tool and connect to your Alliance Auth database: + +```sh +sudo mysql -u root +use alliance_auth; +``` + +Next disable foreign key check. This makes it much easier to drop tables in any order. + +```sh +SET FOREIGN_KEY_CHECKS=0; +``` + +Then get a list of all tables. All tables belonging to the app in question will start with `appname_`. + +```sh +show tables; +``` + +Now, drop the tables from the app one by one like so: + +```sh +drop table appname_model_1; +drop table appname_model_2; +... +``` + +And finally, but very importantly, re-enable foreign key checks again and then exit: + +```sh +SET FOREIGN_KEY_CHECKS=1; +exit; +``` + +### Step 2 - Remove the app from Alliance Auth + +Once the tables have been removed, you you can remove the app from Alliance Auth. This is done by removing the applabel from the `INSTALLED_APPS` list in your local settings file. + +### Step 3 - Remove the Python package + +Finally, we want to remove the app's Python package. For that run the following command: + +```sh +pip uninstall app-package-name +``` + +Congrats, you have now removed this app from your Alliance Auth installation. ## Permission Cleanup diff --git a/pyproject.toml b/pyproject.toml index 374b58cb..db5b176f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,91 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel" +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "allianceauth" +dynamic = ["version", "description"] +readme = "README.md" +license = {file = "LICENSE"} +requires-python = ">=3.8" +authors = [ + { name = "Alliance Auth", email = "adarnof@gmail.com" }, ] -build-backend = "setuptools.build_meta" +keywords = [ + "allianceauth", + "eveonline", +] +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.0", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] +dependencies = [ + "bcrypt", + "beautifulsoup4", + "celery-once>=3.0.1", + "celery>=5.2.0,<6", + "django-bootstrap-form", + "django-celery-beat>=2.3.0", + "django-esi>=4.0.1", + "django-redis>=5.2.0", + "django-registration>=3.3,<3.4", + "django-sortedm2m", + "django>=4.0.9,<4.1.0", + "dnspython", + "mysqlclient>=2.1.0", + "openfire-restapi", + "packaging>=21.0", + "passlib", + "pydiscourse", + "python-slugify>=1.2", + "redis>=4.0.0", + "requests-oauthlib", + "requests>=2.9.1", + "semantic-version", + "slixmpp", +] + +[project.optional-dependencies] +test = [ + "coverage>=4.3.1", + "django-webtest", + "requests-mock>=1.2.0", +] + +[project.scripts] +allianceauth = "allianceauth.bin.allianceauth:main" + +[project.urls] +Homepage = "https://gitlab.com/allianceauth/allianceauth" +Documentation = "https://allianceauth.readthedocs.io/" +Source = "https://gitlab.com/allianceauth/allianceauth" +Tracker = "https://gitlab.com/allianceauth/allianceauth/-/issues" + +[tool.flit.module] +name = "allianceauth" + +[tool.isort] +profile = "django" +sections = [ + "FUTURE", + "STDLIB", + "THIRDPARTY", + "DJANGO", + "ESI", + "FIRSTPARTY", + "LOCALFOLDER" +] +known_esi = ["esi"] +known_django = ["django"] +skip_gitignore = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 44c9dc14..00000000 --- a/setup.cfg +++ /dev/null @@ -1,80 +0,0 @@ -[metadata] -name = allianceauth -version = attr: allianceauth.__version__ -description = An auth system for EVE Online to help in-game organizations manage online service access. -long_description = file: README.md -long_description_content_type = text/markdown -author = Alliance Auth -author_email = adarnof@gmail.com -license = GPL-2.0 -license_files = LICENSE -classifiers = - Environment :: Web Environment - Framework :: Django - Framework :: Django :: 4 - Framework :: Django :: 4.2 - Intended Audience :: Developers - License :: OSI Approved :: GNU General Public License v2 (GPLv2) - Operating System :: POSIX :: Linux - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Internet :: WWW/HTTP - Topic :: Internet :: WWW/HTTP :: Dynamic Content -home_page = https://gitlab.com/allianceauth/allianceauth -keywords = - allianceauth - eveonline -project_urls = - Issue / Bug Reports = https://gitlab.com/allianceauth/allianceauth/-/issues - Documentation = https://allianceauth.readthedocs.io/ - -[options] -packages = find_namespace: -install_requires = - bcrypt - beautifulsoup4 - celery>=5.2.0,<6 - celery-once>=3.0.1 - django>=4.2,<5 - django-bootstrap-form - django-celery-beat>=2.3.0 - django-esi>=4.0.1 - django-redis>=5.2.0 - django-registration>=3.3 - django-sortedm2m - dnspython - mysqlclient>=2.1.0 - openfire-restapi - packaging>=21.0 - passlib - pydiscourse - python-slugify>=1.2 - redis>=4.0.0 - requests>=2.9.1 - requests-oauthlib - semantic-version - slixmpp -python_requires = >=3.8 -include_package_data = True -zip_safe = False - -[options.packages.find] -include = allianceauth* - -[options.entry_points] -console_scripts = - allianceauth = allianceauth.bin.allianceauth:main - -[options.extras_require] -test = - coverage>=4.3.1 - django-webtest - requests-mock>=1.2.0 - -[options.package_data] -* = *