From 2d34422e2da7568a4c55f890c2e3e4a3aa2cc113 Mon Sep 17 00:00:00 2001 From: MillerUk Date: Sun, 30 Apr 2023 20:32:40 +0000 Subject: [PATCH 01/41] add grafana datasoure and dashboard provisioning --- docker/docker-compose.yml | 7 +++++++ docker/grafana-dashboards.yml | 25 +++++++++++++++++++++++++ docker/grafana-datasource.yml | 12 ++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 docker/grafana-dashboards.yml create mode 100644 docker/grafana-datasource.yml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e296a8f4..f797e073 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -57,7 +57,14 @@ 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' restart: always diff --git a/docker/grafana-dashboards.yml b/docker/grafana-dashboards.yml new file mode 100644 index 00000000..11651744 --- /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 \ No newline at end of file diff --git a/docker/grafana-datasource.yml b/docker/grafana-datasource.yml new file mode 100644 index 00000000..d72162f1 --- /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} \ No newline at end of file From 79637020f3a284c982e1634c79336255bc4456c5 Mon Sep 17 00:00:00 2001 From: MillerUk Date: Tue, 16 May 2023 22:14:11 +0000 Subject: [PATCH 02/41] add grafana datasoure and dashboard provisioning --- docker/grafana-dashboards.yml | 2 +- docker/grafana-datasource.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/grafana-dashboards.yml b/docker/grafana-dashboards.yml index 11651744..3e0aa5c7 100644 --- a/docker/grafana-dashboards.yml +++ b/docker/grafana-dashboards.yml @@ -22,4 +22,4 @@ providers: options: # path to dashboard files on disk. Required path: /var/lib/grafana/dashboards - foldersFromFilesStructure: true \ No newline at end of file + foldersFromFilesStructure: true diff --git a/docker/grafana-datasource.yml b/docker/grafana-datasource.yml index d72162f1..bc5b2115 100644 --- a/docker/grafana-datasource.yml +++ b/docker/grafana-datasource.yml @@ -9,4 +9,4 @@ datasources: user: grafana editable: true secureJsonData: - password: ${GF_AUTH_DATABASE_PASSWORD} \ No newline at end of file + password: ${GF_AUTH_DATABASE_PASSWORD} From 1cae20fe5f239dd7cef0bca2b28d63502191475f Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Fri, 19 May 2023 05:00:39 +0800 Subject: [PATCH 03/41] Dont fail on unknown groups, simply remove them. --- .../services/modules/discord/discord_client/client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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: From 3044f18900fca137ba7b152112fc9d3d4b9ad46f Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Sun, 28 May 2023 17:38:51 +0200 Subject: [PATCH 04/41] [ADDED] Ukrainian to `UserProfile.LANGUAGE_CHOICES` --- .../0021_alter_userprofile_language.py | 34 +++++++++++++++++++ allianceauth/authentication/models.py | 1 + 2 files changed, 35 insertions(+) create mode 100644 allianceauth/authentication/migrations/0021_alter_userprofile_language.py 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 index 80fdcd4a..92fe5e71 100755 --- a/allianceauth/authentication/models.py +++ b/allianceauth/authentication/models.py @@ -86,6 +86,7 @@ class UserProfile(models.Model): ('fr', _('French')), ('ja', _('Japanese')), ('it', _('Italian')), + ('uk', _('Ukrainian')), ] language = models.CharField( _("Language"), max_length=10, From e8f508cecbd6b58cb2e3b5b8684aa690b63cff4e Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Sun, 28 May 2023 18:31:50 +0200 Subject: [PATCH 05/41] [CHANGE] Switched to more modern `models.TextChoices` class for languages --- allianceauth/authentication/models.py | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/allianceauth/authentication/models.py b/allianceauth/authentication/models.py index 92fe5e71..6d7a06b8 100755 --- 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,21 +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')), - ('uk', _('Ukrainian')), - ] language = models.CharField( _("Language"), max_length=10, - choices=LANGUAGE_CHOICES, + choices=Language.choices, blank=True, default='') night_mode = models.BooleanField( From 165ee44a632eb319ce8546dc8590d2f4998eb712 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 31 May 2023 09:29:04 +0200 Subject: [PATCH 06/41] [ADDED] IPv6 to Nginx config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IPv6 is almost 25 years old, time to add it to our config … --- docs/installation/nginx.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/installation/nginx.md b/docs/installation/nginx.md index 61a250ea..189bd3cd 100644 --- a/docs/installation/nginx.md +++ b/docs/installation/nginx.md @@ -79,6 +79,8 @@ Copy this basic config into your config file. Make whatever changes you feel are ``` server { listen 80; + listen [::]:80; + server_name example.com; location = /favicon.ico { access_log off; log_not_found off; } @@ -110,6 +112,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 +129,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. From eba5b80cdefde394c81a6451698bed852f8295df Mon Sep 17 00:00:00 2001 From: Valiantiam Date: Sat, 3 Jun 2023 07:04:52 +0000 Subject: [PATCH 07/41] Add utf8mb4 unicode option to mysql config in local.py --- docker/.env.example | 1 + docker/conf/local.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docker/.env.example b/docker/.env.example index a66850fd..40120c86 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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/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 From 99945b0146d80d43c8e477cee999c3c0be2130c7 Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Tue, 20 Jun 2023 14:34:32 +0000 Subject: [PATCH 08/41] Improve documentation for app removal --- .gitlab-ci.yml | 2 +- docs/maintenance/apps.md | 86 ++++++++++++++++++++++++++++++++++++++-- setup.cfg | 2 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d527f1cc..e5a2d353 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,7 +25,7 @@ before_script: pre-commit-check: <<: *only-default stage: pre-commit - image: python:3.8-bullseye + image: python:3.10-bullseye variables: PRE_COMMIT_HOME: ${CI_PROJECT_DIR}/.cache/pre-commit cache: 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/setup.cfg b/setup.cfg index f151c92c..3d6b0c66 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ long_description_content_type = text/markdown author = Alliance Auth author_email = adarnof@gmail.com license = GPL-2.0 -license_file = LICENSE +license_files = LICENSE classifiers = Environment :: Web Environment Framework :: Django From f8fefd92a579b521b86952bdebdd5f8e894178f6 Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Tue, 27 Jun 2023 11:08:59 +1000 Subject: [PATCH 09/41] add pkg-config to our system package dependencies, for mysqlclient 2.2.0 --- docs/installation/allianceauth.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 557a52e3c86474728742757bde3bad25c87fae1b Mon Sep 17 00:00:00 2001 From: Max Tsero Date: Sun, 2 Jul 2023 10:39:23 +0000 Subject: [PATCH 10/41] Add pkg-config install to dev setup and expand python downgrade note. --- docs/development/dev_setup/aa-dev-setup-wsl-vsc-v2.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 91b62bbe9d133aadbe97bd782ec6d7659f103243 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Tue, 4 Jul 2023 12:51:12 +0200 Subject: [PATCH 11/41] [COMPATIBILITY] Limit `django-registration` to <3.4 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3d6b0c66..fd51251d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ install_requires = django-celery-beat>=2.3.0 django-esi>=4.0.1 django-redis>=5.2.0 - django-registration>=3.3 + django-registration>=3.3,<3.4 django-sortedm2m dnspython mysqlclient>=2.1.0 From d57ab01ff3f6c33733a9c68a8ed62425557f44d6 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Fri, 7 Jul 2023 17:51:55 +0200 Subject: [PATCH 12/41] [FIX] Use of deprecated `logger.warn()` function calls --- allianceauth/hrapplications/views.py | 18 +++++++++--------- allianceauth/notifications/views.py | 2 +- .../services/modules/discourse/tasks.py | 2 +- .../services/modules/phpbb3/manager.py | 2 +- .../services/modules/teamspeak3/views.py | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/allianceauth/hrapplications/views.py b/allianceauth/hrapplications/views.py index fe7c93f5..789428e2 100755 --- 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/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/phpbb3/manager.py b/allianceauth/services/modules/phpbb3/manager.py index c62417b1..7e6120a6 100755 --- 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/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) From 939df08b95f1ccb1370229ce268874487582517b Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Fri, 7 Jul 2023 23:56:07 +0000 Subject: [PATCH 13/41] Add customization example to docs --- docs/customizing/index.md | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) 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. From 5e14ea4573ba3cf300405d2e4ab84de93f49a166 Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Tue, 11 Jul 2023 13:01:50 +1000 Subject: [PATCH 14/41] Version Bump 3.5.0 --- allianceauth/__init__.py | 2 +- docker/.env.example | 2 +- docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/allianceauth/__init__.py b/allianceauth/__init__.py index 417f9297..23e1a13d 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -1,7 +1,7 @@ # 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.5.0' __title__ = 'Alliance Auth' __url__ = 'https://gitlab.com/allianceauth/allianceauth' NAME = f'{__title__} v{__version__}' diff --git a/docker/.env.example b/docker/.env.example index 40120c86..46e66e16 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.5.0 # Nginx Proxy Manager PROXY_HTTP_PORT=80 diff --git a/docker/Dockerfile b/docker/Dockerfile index 07cb91c4..b39f94e2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.9-slim -ARG AUTH_VERSION=v3.4.0 +ARG AUTH_VERSION=v3.5.0 ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ENV VIRTUAL_ENV=/opt/venv ENV AUTH_USER=allianceauth From 56d70e6c740e38b76e0551454ec0c397de56ab5b Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Tue, 11 Jul 2023 22:36:15 +1000 Subject: [PATCH 15/41] Add pkg-config to dockerfile --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b39f94e2..bb5d4260 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,7 +21,7 @@ RUN mkdir -p ${VIRTUAL_ENV} \ # Install build dependencies RUN apt-get update && apt-get upgrade -y && apt-get install -y \ - libmariadb-dev gcc supervisor git htop + libmariadb-dev gcc supervisor git htop pkg-config # Switch to non-root user USER ${AUTH_USER} From 6f4dffe9305205f069478c144c3ad575d9a3ebad Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Tue, 11 Jul 2023 22:38:26 +1000 Subject: [PATCH 16/41] Version Bump 3.5.1 --- allianceauth/__init__.py | 2 +- docker/.env.example | 2 +- docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/allianceauth/__init__.py b/allianceauth/__init__.py index 23e1a13d..42103a88 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -1,7 +1,7 @@ # This will make sure the app is always imported when # Django starts so that shared_task will use this app. -__version__ = '3.5.0' +__version__ = '3.5.1' __title__ = 'Alliance Auth' __url__ = 'https://gitlab.com/allianceauth/allianceauth' NAME = f'{__title__} v{__version__}' diff --git a/docker/.env.example b/docker/.env.example index 46e66e16..14b6e136 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.5.0 +AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.5.1 # Nginx Proxy Manager PROXY_HTTP_PORT=80 diff --git a/docker/Dockerfile b/docker/Dockerfile index bb5d4260..86f06f00 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.9-slim -ARG AUTH_VERSION=v3.5.0 +ARG AUTH_VERSION=v3.5.1 ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ENV VIRTUAL_ENV=/opt/venv ENV AUTH_USER=allianceauth From dcd6bd1b36aa67d5b3dc61f814df9ba8f64a56e9 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 12 Jul 2023 13:11:18 +0200 Subject: [PATCH 17/41] [FIX] `crontab` arguments are of type `string`, not `int` --- docs/features/apps/corpstats.md | 2 +- docs/features/services/discord.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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'), } ``` From 5f80259d57dbd1f62ace19aede4610fc05267f27 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Thu, 13 Jul 2023 20:28:35 +0800 Subject: [PATCH 18/41] fix test --- .../modules/discord/discord_client/tests/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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( From 5d4c7b90301df256086801b1124f67b3fee3d51d Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Fri, 14 Jul 2023 19:22:29 +0200 Subject: [PATCH 19/41] [FIX] `crontab` arguments here as well --- .../project_template/project_name/settings/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index b2eb0257..e15183ab 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'), } } From 9a77175bf358ef96ba196a44ad85ffb331d77656 Mon Sep 17 00:00:00 2001 From: Aaron Kable Date: Mon, 17 Jul 2023 10:25:36 +0800 Subject: [PATCH 20/41] Allow get params from next at login --- allianceauth/authentication/templates/public/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 %} From b04c8873d0ebaf231110cdc27555effab791183b Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 19 Jul 2023 11:37:07 +0200 Subject: [PATCH 21/41] [ADD] Directive for default favicon --- .../static/allianceauth/icons/favicon.png | Bin 0 -> 868 bytes allianceauth/templates/allianceauth/icons.html | 2 ++ 2 files changed, 2 insertions(+) create mode 100755 allianceauth/static/allianceauth/icons/favicon.png diff --git a/allianceauth/static/allianceauth/icons/favicon.png b/allianceauth/static/allianceauth/icons/favicon.png new file mode 100755 index 0000000000000000000000000000000000000000..fcd164fd897d1a06b1ab45b1660aa04d0f70b065 GIT binary patch literal 868 zcmV-q1DpJbP){%sBaIOHOaQP9DDCxJCY7Go zCjz9>g}_o^#9vE|edWL`V2RD|^Q6+Hc@-d)E&vvqv;P|dfQEcKFcw&sV9g0EkV=ow zivX##0A`ybNQ7~~#(X(21gJ|AQv|rB(nIqhU^1{cg}72+c|HW}Nil91uv98tlrsTR zX$#nphS(~TFFA9d0T5|c%Oa_CzpMpFr3V9BGiX@rf#O^^u-#NZx|j~kl}bCZ6|jrU zu`QW+M-uyM&2^h8<^wabaiAV3%t8QhL_NE2IMNm%m0k=?%Yrx(6XMCF$k$J;&n~zU z#TEQ#kBxvUePW0RXS*xb-BQ=l)>;w%xZ;O!HUfL?;zfeDF9WUsQ_~@j3DFftTwMF@ z+3T9`&r3ucPfUm*KqpXa1^i!9bzn6xB^~mxD7aP;{&2mnqiv2W7TyvSj*^}~uQv$T z0US>%Kq@`Kv{I4cS-w+IcXJup+`?*8#nD0_>8o5=d#l>_IfSyIxgY zdUNa8m}uAj9z;nT61CrvA-6~USAbO71*}Y=JX?UWJH8!_2QD`~br7$P!{a3X>2JDf z;v|$xR|2&s==1vQ7(#B(X5fgKjGQ(hpMhE{;J^8g1Lpo-m4G}1oB?Jh zA_uSn{_{XNa0+;96EYmAwROM@lMjJWz1BY&M@|NP-UsPaMab=`1a<-|fZik8b6~v{ z@IU@t1?I+D){8t2+yKr6ecr2?bV4iOzaMgY9sxDLCgU!^M4%dzGr|by53~d0fi|ES zxEAzzKavB4+@8_qW2V7O)enIUR>1$_R}NGg^227JJew From 749ece45e286052b2e9ffeb78d2c8435be6fa141 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 19 Jul 2023 11:39:04 +0200 Subject: [PATCH 22/41] [ADD] Favicon redirect to Apache2 docs --- docs/installation/apache.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/installation/apache.md b/docs/installation/apache.md index 4b268509..5e8be71f 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.png" 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 + ``` From ec536c66a0b1900f689fd43b654ad67d65c73da2 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 19 Jul 2023 11:40:14 +0200 Subject: [PATCH 23/41] [ADD] Favicon redirect to Nginx docs --- docs/installation/nginx.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/installation/nginx.md b/docs/installation/nginx.md index 189bd3cd..f05a01db 100644 --- a/docs/installation/nginx.md +++ b/docs/installation/nginx.md @@ -83,8 +83,6 @@ server { server_name example.com; - location = /favicon.ico { access_log off; log_not_found off; } - location /static { alias /var/www/myauth/static; autoindex off; @@ -94,6 +92,10 @@ server { alias /var/www/myauth/static/robots.txt; } + location /favicon.ico { + alias /var/www/myauth/static/allianceauth/icons/favicon.png; + } + # Gunicorn config goes below location / { include proxy_params; From fc29d7e80d2ee76135216523b411a6aa1c8e244a Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 19 Jul 2023 12:10:02 +0200 Subject: [PATCH 24/41] [ADD] All modern favicon versions generated by `realfavicongenerator.net` --- .../icons/android-chrome-192x192.png | Bin 0 -> 7686 bytes .../icons/android-chrome-512x512.png | Bin 0 -> 26346 bytes .../allianceauth/icons/apple-touch-icon.png | Bin 3508 -> 7160 bytes .../allianceauth/icons/browserconfig.xml | 9 ++++ .../allianceauth/icons/favicon-16x16.png | Bin 483 -> 941 bytes .../allianceauth/icons/favicon-32x32.png | Bin 868 -> 1565 bytes .../static/allianceauth/icons/favicon.ico | Bin 0 -> 15086 bytes .../static/allianceauth/icons/favicon.png | Bin 868 -> 0 bytes .../allianceauth/icons/mstile-150x150.png | Bin 0 -> 4681 bytes .../allianceauth/icons/safari-pinned-tab.svg | 41 ++++++++++++++++++ .../allianceauth/icons/site.webmanifest | 19 ++++++++ .../templates/allianceauth/icons.html | 9 +++- docs/installation/apache.md | 2 +- docs/installation/nginx.md | 2 +- 14 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 allianceauth/static/allianceauth/icons/android-chrome-192x192.png create mode 100644 allianceauth/static/allianceauth/icons/android-chrome-512x512.png mode change 100755 => 100644 allianceauth/static/allianceauth/icons/apple-touch-icon.png create mode 100644 allianceauth/static/allianceauth/icons/browserconfig.xml mode change 100755 => 100644 allianceauth/static/allianceauth/icons/favicon-16x16.png mode change 100755 => 100644 allianceauth/static/allianceauth/icons/favicon-32x32.png create mode 100644 allianceauth/static/allianceauth/icons/favicon.ico delete mode 100755 allianceauth/static/allianceauth/icons/favicon.png create mode 100644 allianceauth/static/allianceauth/icons/mstile-150x150.png create mode 100644 allianceauth/static/allianceauth/icons/safari-pinned-tab.svg create mode 100644 allianceauth/static/allianceauth/icons/site.webmanifest 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 0000000000000000000000000000000000000000..a83b4d061f4c6667acb2b33e6b8862867adde9eb GIT binary patch literal 7686 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 iOk2aJX(+#Z*Z%<5fXR#5+_r%L0000 + + + + + #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 2e17b9054434c857e34c71a11b9568a3c1c89192..454c33a00cf27e2f1d61b67676ed8beab93a2f78 GIT binary patch literal 941 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>`Z@Ck7RDsV}f=9o17 zkw=ho(zHh&!9XsJkaN;>hvXR$!<~|*r^R&a^iTQkU<(le$_6J)SRR!9z$5svMSp3zp%@9 zP%y=o1o?r290>UL@8b3N`q`Of(Hv&O7f*HD8sx5Dmd^b9C;8wW-*2BB*_OZASH^1k z`umAZ_n#Vc|2seJ{O=aUTTL=joiDD*=6+dX%*152{OiATvBQ2GoPo#o3Oklbl-qj! zO`8NXoiWMV-6i4Z_8L%ZvzK`Ky0SlK7v{7y`}wUc94NHg)5S4F;&O5V1EZT+8K0jU zn?*v(lSj`Urn+<(eEN9&7#D{UCkN;8V_h9aN+lgfnHnu(_!qaRcsO{?5?tosCZ?jG z&L4W{#4-VHwl0C$l9E%WC^BiS@=0JcEj2Lra}M^NEvd3#G54d_FQp}UNX0^vat7L5oTcp zmj;u=Da^{7LljQmxN_pinIkes*iScjEb!7}cqJ}a^2y0`D$oiBPgg&ebxsLQ0LVsP ArvLx| delta 468 zcmV;_0W1Ek2jc^f8Gi-<001BJ|6u?C010qNS#tmYE+YT{E+YYWr9XB600EUrL_t(I z%azkRXcSQt2HMO*jfu417c%mHRTqH zorHi8Q*31?SOkj*f`aP!+3%VwQA+X zT0ME|<2JV9URFbX9rqy|NUvwYC_ePR39eyhdQNa>(*j`>AI2bZX(@+d7{$GtO>hPM zt7S)5m>z4)1!tE&om$QpPAumF80tux1DMUH?s=3`krtjMjQ3c)SzP)! z^&=m=ZvI=$KAf(XMhdx9U<=-2FW%q*rfb#8LW@x^jZJjnJZ@nCuQ1#i;^mY)fe??{ z$!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+081Cv#NPlzi}flJafxA-Y8 zNze*cq_v z3BItO-W=-}GTXdATOiIjILik+#izWnc?K=iNI zeKsq9fAh0tW_923a|N3tH(&Cl*Jd*x)wDe8(VDBrWU}PTZKXY)Y#jcFeivN0mLVXV zf9v>DMb!yU!#GP^m|477Phaa<40JnVlDE6dUfZjeDuEpK5>H=O_UG)voR(%kzqN$} zrMG#yIEGl9PEK%O>gjPhq_n7~hi3=F;u$~Q-4&W-A!=ac<@x@euJY*Of_iyI&2X&4x# z9Z75G)bwebX{pS;jEVd7-GYSF-b5Dd#k~ejjca9 zJDkWC6BC{OhOzOIk=_i(r=kWgPpx`2>z1uRKsdi?Q(zF2R_?3&7j9g+bBQbT;wIb_bv zynOg!;%U?5XKQXcuogcnYRs9jqq_X)o}b(kn&-{Wez@qU_qQoJtX7i)t8c!0svWjS zT|jSL=<4flB6oAGaHyYqtBO%)fRZCnWN>UO_QmvAUQh^kMk%5u1u7SC(p=F4HrIo32I`Q&6e6=(&6r>mdKI;Vst07Zg=z5oCK delta 856 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 H+9>}Ap5{%sBaIOHOaQP9DDCxJCY7Go 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 literal 0 HcmV?d00001 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/templates/allianceauth/icons.html b/allianceauth/templates/allianceauth/icons.html index 9e9042ed..10b058a2 100644 --- a/allianceauth/templates/allianceauth/icons.html +++ b/allianceauth/templates/allianceauth/icons.html @@ -1,7 +1,12 @@ {% load static %} - + - + + + + + + diff --git a/docs/installation/apache.md b/docs/installation/apache.md index 5e8be71f..79f8c615 100644 --- a/docs/installation/apache.md +++ b/docs/installation/apache.md @@ -83,7 +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.png" + Alias "/favicon.ico" "/var/www/myauth/static/allianceauth/icons/favicon.ico" Require all granted diff --git a/docs/installation/nginx.md b/docs/installation/nginx.md index f05a01db..a79a86ae 100644 --- a/docs/installation/nginx.md +++ b/docs/installation/nginx.md @@ -93,7 +93,7 @@ server { } location /favicon.ico { - alias /var/www/myauth/static/allianceauth/icons/favicon.png; + alias /var/www/myauth/static/allianceauth/icons/favicon.ico; } # Gunicorn config goes below From 951c4135c219012bd05e4d04ff854dd8559f582f Mon Sep 17 00:00:00 2001 From: Hamish W Date: Tue, 25 Jul 2023 09:26:10 +0000 Subject: [PATCH 25/41] Adding Absolute Timers to base timerboard --- .../static/allianceauth/js/timerboard.js | 30 ++++++++++++++++ .../templates/bundles/timerboard-js.html | 3 ++ allianceauth/timerboard/form.py | 35 +++++++++++++------ .../timerboard/templates/timerboard/form.html | 18 ++++++++++ .../timerboard/timer_create_form.html | 16 +++++++++ .../timerboard/timer_update_form.html | 20 +++++++++++ 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 allianceauth/static/allianceauth/js/timerboard.js create mode 100644 allianceauth/templates/bundles/timerboard-js.html 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/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/timerboard/form.py b/allianceauth/timerboard/form.py index 121d4ff3..e42cc10c 100755 --- 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/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 %} From c4193c15fc16fdca25040f3b3a8bde01f273ab41 Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Tue, 25 Jul 2023 09:32:28 +0000 Subject: [PATCH 26/41] Migrate to PEP 621 --- .isort.cfg | 6 --- .pre-commit-config.yaml | 6 --- MANIFEST.in | 7 --- allianceauth/__init__.py | 4 ++ pyproject.toml | 93 ++++++++++++++++++++++++++++++++++++++-- setup.cfg | 79 ---------------------------------- 6 files changed, 93 insertions(+), 102 deletions(-) delete mode 100644 .isort.cfg delete mode 100644 MANIFEST.in delete mode 100644 setup.cfg 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 b37ec7c7..2692002c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,9 +53,3 @@ repos: hooks: - id: pyupgrade args: [ --py38-plus ] - - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.2.0 - hooks: - - id: setup-cfg-fmt - args: [ --include-version-classifiers ] 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/allianceauth/__init__.py b/allianceauth/__init__.py index 417f9297..8e27b404 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -1,3 +1,7 @@ +"""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. 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 fd51251d..00000000 --- a/setup.cfg +++ /dev/null @@ -1,79 +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 - 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.0.9,<4.1.0 - 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 - 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] -* = * From 1122d617bd1dc7e9bd98ccf11e0eb53197a3b12b Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Tue, 1 Aug 2023 10:15:42 +0000 Subject: [PATCH 27/41] Show running tasks on dashboard --- .../task_statistics/counters.py | 26 +++++-- .../task_statistics/event_series.py | 16 ++-- .../authentication/task_statistics/helpers.py | 44 +++++++++++ .../authentication/task_statistics/signals.py | 27 +++++-- .../task_statistics/tests/test_counters.py | 11 ++- .../tests/test_item_counter.py | 74 +++++++++++++++++++ .../task_statistics/tests/test_signals.py | 15 +--- .../allianceauth/admin-status/overview.html | 5 +- allianceauth/templatetags/admin_status.py | 8 +- 9 files changed, 189 insertions(+), 37 deletions(-) create mode 100644 allianceauth/authentication/task_statistics/helpers.py create mode 100644 allianceauth/authentication/task_statistics/tests/test_item_counter.py 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..03865103 100644 --- a/allianceauth/authentication/task_statistics/event_series.py +++ b/allianceauth/authentication/task_statistics/event_series.py @@ -1,3 +1,5 @@ +"""Event series for Task Statistics.""" + import datetime as dt import logging from typing import List, Optional @@ -73,8 +75,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.""" @@ -101,9 +103,9 @@ class EventSeries: - 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 +113,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..7b887be7 --- /dev/null +++ b/allianceauth/authentication/task_statistics/helpers.py @@ -0,0 +1,44 @@ +"""Helpers for Task Statistics.""" + +from typing import Optional + +from django.core.cache import cache + + +class ItemCounter: + """A process safe item counter.""" + + CACHE_KEY_BASE = "allianceauth-item-counter" + DEFAULT_CACHE_TIMEOUT = 24 * 3600 + + def __init__(self, name: str) -> None: + if not name: + raise ValueError("Must define a name") + + self._name = str(name) + + @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.""" + 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.""" + 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) 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_item_counter.py b/allianceauth/authentication/task_statistics/tests/test_item_counter.py new file mode 100644 index 00000000..da6f49c1 --- /dev/null +++ b/allianceauth/authentication/task_statistics/tests/test_item_counter.py @@ -0,0 +1,74 @@ +from unittest import TestCase + +from allianceauth.authentication.task_statistics.helpers import ItemCounter + +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) diff --git a/allianceauth/authentication/task_statistics/tests/test_signals.py b/allianceauth/authentication/task_statistics/tests/test_signals.py index aeeb8d60..1e1634bd 100644 --- a/allianceauth/authentication/task_statistics/tests/test_signals.py +++ b/allianceauth/authentication/task_statistics/tests/test_signals.py @@ -17,16 +17,17 @@ from allianceauth.eveonline.tasks import update_character @override_settings( - CELERY_ALWAYS_EAGER=True,ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False + CELERY_ALWAYS_EAGER=True, ALLIANCEAUTH_DASHBOARD_TASK_STATISTICS_DISABLED=False ) 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/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/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, } From 7cb7e2c77b53853868fdc8f253966584f64d2763 Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Tue, 1 Aug 2023 10:20:13 +0000 Subject: [PATCH 28/41] Add public routes feature --- allianceauth/authentication/decorators.py | 24 +++++-- .../authentication/tests/test_decorators.py | 36 +++++++++- .../project_name/settings/local.py | 4 ++ allianceauth/services/hooks.py | 35 +++++++-- allianceauth/services/tests/test_hooks.py | 30 ++++++++ allianceauth/tests/test_urls.py | 72 +++++++++++++++++++ allianceauth/urls.py | 54 ++++++++++---- docs/development/custom/url-hooks.md | 69 ++++++++++-------- 8 files changed, 269 insertions(+), 55 deletions(-) create mode 100644 allianceauth/services/tests/test_hooks.py create mode 100644 allianceauth/tests/test_urls.py diff --git a/allianceauth/authentication/decorators.py b/allianceauth/authentication/decorators.py index 88742fd7..6a5d0b91 100644 --- a/allianceauth/authentication/decorators.py +++ b/allianceauth/authentication/decorators.py @@ -1,18 +1,28 @@ -from django.conf.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.conf.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 +32,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/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/project_template/project_name/settings/local.py b/allianceauth/project_template/project_name/settings/local.py index 296bee02..1405fbd4 100644 --- a/allianceauth/project_template/project_name/settings/local.py +++ b/allianceauth/project_template/project_name/settings/local.py @@ -32,6 +32,10 @@ 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. +# If you want to allow specific apps to have public views +# you can put there names here (same name as in INSTALLED_APPS): +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 59595a84..986dc6f5 100644 --- a/allianceauth/services/hooks.py +++ b/allianceauth/services/hooks.py @@ -1,15 +1,18 @@ +from string import Formatter +from typing import Iterable, Optional + +from django.conf import settings from django.conf.urls import include -from django.urls import re_path from django.core.exceptions import ObjectDoesNotExist from django.template.loader import render_to_string +from django.urls import 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/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/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/urls.py b/allianceauth/urls.py index 3a8ea22c..995b94ca 100755 --- a/allianceauth/urls.py +++ b/allianceauth/urls.py @@ -1,24 +1,54 @@ -from django.urls import path -import esi.urls +from typing import List, Iterable, Callable -from django.conf.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 +79,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/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: +``` From 7075ccdf7aa58d22c062787098595c25457e0e9d Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Tue, 1 Aug 2023 12:51:58 +0200 Subject: [PATCH 29/41] [CHANGE] Django Upgrade checks applied --- .pre-commit-config.yaml | 42 +++++++++++++++---- allianceauth/authentication/decorators.py | 2 +- allianceauth/authentication/hmac_urls.py | 5 +-- allianceauth/eveonline/autogroups/admin.py | 2 +- allianceauth/hrapplications/admin.py | 2 +- allianceauth/notifications/__init__.py | 2 - allianceauth/notifications/admin.py | 8 +++- .../project_template/project_name/urls.py | 5 +-- allianceauth/services/admin.py | 4 +- allianceauth/services/hooks.py | 3 +- allianceauth/services/modules/discord/urls.py | 3 +- allianceauth/services/modules/example/urls.py | 3 +- allianceauth/services/modules/ips4/admin.py | 3 +- allianceauth/services/modules/ips4/urls.py | 3 +- allianceauth/services/modules/mumble/urls.py | 3 +- .../services/modules/openfire/urls.py | 3 +- allianceauth/services/modules/phpbb3/urls.py | 3 +- allianceauth/services/modules/smf/urls.py | 3 +- .../services/modules/teamspeak3/admin.py | 4 +- .../services/modules/teamspeak3/urls.py | 3 +- allianceauth/services/modules/xenforo/urls.py | 3 +- allianceauth/services/urls.py | 2 +- 22 files changed, 63 insertions(+), 48 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2692002c..0300e17f 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: | @@ -48,8 +66,14 @@ repos: swagger\.json ) + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.14.0 + hooks: + - id: django-upgrade + args: [ --target-version=4.0 ] + - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.10.1 hooks: - id: pyupgrade args: [ --py38-plus ] diff --git a/allianceauth/authentication/decorators.py b/allianceauth/authentication/decorators.py index 6a5d0b91..13b4ed0a 100644 --- a/allianceauth/authentication/decorators.py +++ b/allianceauth/authentication/decorators.py @@ -1,7 +1,7 @@ from functools import wraps from typing import Callable, Iterable, Optional -from django.conf.urls import include +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 diff --git a/allianceauth/authentication/hmac_urls.py b/allianceauth/authentication/hmac_urls.py index e7c03d11..61bcd99b 100644 --- a/allianceauth/authentication/hmac_urls.py +++ b/allianceauth/authentication/hmac_urls.py @@ -1,8 +1,5 @@ -from django.conf.urls import include - from allianceauth.authentication import views -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/eveonline/autogroups/admin.py b/allianceauth/eveonline/autogroups/admin.py index 7f2ebabe..240d4578 100644 --- a/allianceauth/eveonline/autogroups/admin.py +++ b/allianceauth/eveonline/autogroups/admin.py @@ -14,6 +14,7 @@ def sync_user_groups(modeladmin, request, queryset): agc.update_all_states_group_membership() +@admin.register(AutogroupsConfig) class AutogroupsConfigAdmin(admin.ModelAdmin): formfield_overrides = { models.CharField: {'strip': False} @@ -36,6 +37,5 @@ class AutogroupsConfigAdmin(admin.ModelAdmin): return actions -admin.site.register(AutogroupsConfig, AutogroupsConfigAdmin) admin.site.register(ManagedCorpGroup) admin.site.register(ManagedAllianceGroup) diff --git a/allianceauth/hrapplications/admin.py b/allianceauth/hrapplications/admin.py index de3cff38..538d3ffa 100755 --- a/allianceauth/hrapplications/admin.py +++ b/allianceauth/hrapplications/admin.py @@ -10,6 +10,7 @@ class ChoiceInline(admin.TabularInline): verbose_name_plural = 'Choices (optional)' verbose_name= 'Choice' +@admin.register(ApplicationQuestion) class QuestionAdmin(admin.ModelAdmin): fieldsets = [ (None, {'fields': ['title', 'help_text', 'multi_select']}), @@ -18,6 +19,5 @@ class QuestionAdmin(admin.ModelAdmin): admin.site.register(Application) admin.site.register(ApplicationComment) -admin.site.register(ApplicationQuestion, QuestionAdmin) admin.site.register(ApplicationForm) admin.site.register(ApplicationResponse) diff --git a/allianceauth/notifications/__init__.py b/allianceauth/notifications/__init__.py index e22e6ad7..1a7c9e01 100644 --- a/allianceauth/notifications/__init__.py +++ b/allianceauth/notifications/__init__.py @@ -1,3 +1 @@ from .core import notify # noqa: F401 - -default_app_config = 'allianceauth.notifications.apps.NotificationsConfig' diff --git a/allianceauth/notifications/admin.py b/allianceauth/notifications/admin.py index 03f1dd4e..3fb3242a 100644 --- a/allianceauth/notifications/admin.py +++ b/allianceauth/notifications/admin.py @@ -15,18 +15,22 @@ class NotificationAdmin(admin.ModelAdmin): ordering = ("-timestamp", ) search_fields = ["user__username", "user__profile__main_character__character_name"] + @admin.display( + ordering="user__profile__main_character__character_name" + ) def _main(self, obj): try: return obj.user.profile.main_character except AttributeError: return obj.user - _main.admin_order_field = "user__profile__main_character__character_name" + @admin.display( + ordering="user__profile__state__name" + ) def _state(self, obj): return obj.user.profile.state - _state.admin_order_field = "user__profile__state__name" def has_change_permission(self, request, obj=None): return False diff --git a/allianceauth/project_template/project_name/urls.py b/allianceauth/project_template/project_name/urls.py index 803ef102..58d40642 100644 --- a/allianceauth/project_template/project_name/urls.py +++ b/allianceauth/project_template/project_name/urls.py @@ -1,9 +1,8 @@ -from django.conf.urls import include from allianceauth import urls -from django.urls import re_path +from django.urls import include, path urlpatterns = [ - re_path(r'', include(urls)), + path('', include(urls)), ] handler500 = 'allianceauth.views.Generic500Redirect' diff --git a/allianceauth/services/admin.py b/allianceauth/services/admin.py index b204938b..f2c64573 100644 --- a/allianceauth/services/admin.py +++ b/allianceauth/services/admin.py @@ -66,6 +66,8 @@ class NameFormatConfigAdmin(admin.ModelAdmin): form = NameFormatConfigForm list_display = ('service_name', 'get_state_display_string') + @admin.display( + description='States' + ) def get_state_display_string(self, obj): return ', '.join([state.name for state in obj.states.all()]) - get_state_display_string.short_description = 'States' diff --git a/allianceauth/services/hooks.py b/allianceauth/services/hooks.py index 986dc6f5..2547b78a 100644 --- a/allianceauth/services/hooks.py +++ b/allianceauth/services/hooks.py @@ -2,10 +2,9 @@ from string import Formatter from typing import Iterable, Optional from django.conf import settings -from django.conf.urls import include from django.core.exceptions import ObjectDoesNotExist from django.template.loader import render_to_string -from django.urls import re_path +from django.urls import include, re_path from django.utils.functional import cached_property from allianceauth.hooks import get_hooks diff --git a/allianceauth/services/modules/discord/urls.py b/allianceauth/services/modules/discord/urls.py index 749a8090..4685a3c5 100644 --- a/allianceauth/services/modules/discord/urls.py +++ b/allianceauth/services/modules/discord/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/example/urls.py b/allianceauth/services/modules/example/urls.py index cefb0650..a08be65f 100644 --- a/allianceauth/services/modules/example/urls.py +++ b/allianceauth/services/modules/example/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path app_name = 'example' diff --git a/allianceauth/services/modules/ips4/admin.py b/allianceauth/services/modules/ips4/admin.py index d70aeede..38ad5f50 100644 --- a/allianceauth/services/modules/ips4/admin.py +++ b/allianceauth/services/modules/ips4/admin.py @@ -2,8 +2,7 @@ from django.contrib import admin from .models import Ips4User +@admin.register(Ips4User) class Ips4UserAdmin(admin.ModelAdmin): list_display = ('user', 'username', 'id') search_fields = ('user__username', 'username', 'id') - -admin.site.register(Ips4User, Ips4UserAdmin) diff --git a/allianceauth/services/modules/ips4/urls.py b/allianceauth/services/modules/ips4/urls.py index 87c9ca31..cb8aaa4b 100644 --- a/allianceauth/services/modules/ips4/urls.py +++ b/allianceauth/services/modules/ips4/urls.py @@ -1,5 +1,4 @@ -from django.conf.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 6c403fc2..ef7ff699 100644 --- a/allianceauth/services/modules/mumble/urls.py +++ b/allianceauth/services/modules/mumble/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/openfire/urls.py b/allianceauth/services/modules/openfire/urls.py index 8823ad18..17da5b33 100644 --- a/allianceauth/services/modules/openfire/urls.py +++ b/allianceauth/services/modules/openfire/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/phpbb3/urls.py b/allianceauth/services/modules/phpbb3/urls.py index d7d7bce9..e9dcbd2a 100644 --- a/allianceauth/services/modules/phpbb3/urls.py +++ b/allianceauth/services/modules/phpbb3/urls.py @@ -1,5 +1,4 @@ -from django.conf.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 92aba143..3f8308f5 100644 --- a/allianceauth/services/modules/smf/urls.py +++ b/allianceauth/services/modules/smf/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/teamspeak3/admin.py b/allianceauth/services/modules/teamspeak3/admin.py index dbba1cb1..92a1fcfe 100644 --- a/allianceauth/services/modules/teamspeak3/admin.py +++ b/allianceauth/services/modules/teamspeak3/admin.py @@ -36,10 +36,12 @@ class AuthTSgroupAdmin(admin.ModelAdmin): kwargs['queryset'] = TSgroup.objects.exclude(ts_group_name__in=ReservedGroupName.objects.values_list('name', flat=True)) return super().formfield_for_manytomany(db_field, request, **kwargs) + @admin.display( + description='ts groups' + ) def _ts_group(self, obj): return [x for x in obj.ts_group.all().order_by('ts_group_id')] - _ts_group.short_description = 'ts groups' # _ts_group.admin_order_field = 'profile__state' diff --git a/allianceauth/services/modules/teamspeak3/urls.py b/allianceauth/services/modules/teamspeak3/urls.py index 4c6831e3..83cc4287 100644 --- a/allianceauth/services/modules/teamspeak3/urls.py +++ b/allianceauth/services/modules/teamspeak3/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/modules/xenforo/urls.py b/allianceauth/services/modules/xenforo/urls.py index 9bad105e..bec8e6ef 100644 --- a/allianceauth/services/modules/xenforo/urls.py +++ b/allianceauth/services/modules/xenforo/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path from . import views diff --git a/allianceauth/services/urls.py b/allianceauth/services/urls.py index e43b0e30..9c8a8141 100644 --- a/allianceauth/services/urls.py +++ b/allianceauth/services/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import include +from django.urls import include from allianceauth.hooks import get_hooks from django.urls import path From d0118e6c0b1a80ebdda1c430887d2e240292a400 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Tue, 1 Aug 2023 12:55:19 +0200 Subject: [PATCH 30/41] [FIX] File permissions --- .pre-commit-config.yaml | 2 +- .tx/config_20230406134150.bak | 0 README.md | 0 allianceauth/authentication/managers.py | 0 allianceauth/authentication/models.py | 0 allianceauth/eveonline/views.py | 0 allianceauth/groupmanagement/views.py | 0 allianceauth/hrapplications/admin.py | 0 allianceauth/hrapplications/forms.py | 0 allianceauth/hrapplications/models.py | 0 allianceauth/hrapplications/views.py | 0 allianceauth/services/modules/discord/tests/piloting_tasks.py | 0 allianceauth/services/modules/openfire/manager.py | 0 .../modules/openfire/templates/services/openfire/broadcast.html | 0 allianceauth/services/modules/phpbb3/manager.py | 0 allianceauth/services/modules/teamspeak3/manager.py | 0 allianceauth/services/modules/teamspeak3/util/__init__.py | 0 allianceauth/services/modules/teamspeak3/util/ts3.py | 0 allianceauth/services/templates/services/services.html | 0 allianceauth/services/views.py | 0 allianceauth/srp/__init__.py | 0 allianceauth/srp/admin.py | 0 allianceauth/srp/form.py | 0 allianceauth/srp/models.py | 0 allianceauth/srp/tests/__init__.py | 0 allianceauth/srp/tests/test_managers.py | 0 allianceauth/srp/views.py | 0 allianceauth/timerboard/__init__.py | 0 allianceauth/timerboard/admin.py | 0 allianceauth/timerboard/form.py | 0 allianceauth/timerboard/models.py | 0 allianceauth/timerboard/views.py | 0 allianceauth/urls.py | 0 33 files changed, 1 insertion(+), 1 deletion(-) mode change 100755 => 100644 .tx/config_20230406134150.bak mode change 100755 => 100644 README.md mode change 100755 => 100644 allianceauth/authentication/managers.py mode change 100755 => 100644 allianceauth/authentication/models.py mode change 100755 => 100644 allianceauth/eveonline/views.py mode change 100755 => 100644 allianceauth/groupmanagement/views.py mode change 100755 => 100644 allianceauth/hrapplications/admin.py mode change 100755 => 100644 allianceauth/hrapplications/forms.py mode change 100755 => 100644 allianceauth/hrapplications/models.py mode change 100755 => 100644 allianceauth/hrapplications/views.py mode change 100755 => 100644 allianceauth/services/modules/discord/tests/piloting_tasks.py mode change 100755 => 100644 allianceauth/services/modules/openfire/manager.py mode change 100755 => 100644 allianceauth/services/modules/openfire/templates/services/openfire/broadcast.html mode change 100755 => 100644 allianceauth/services/modules/phpbb3/manager.py mode change 100755 => 100644 allianceauth/services/modules/teamspeak3/manager.py mode change 100755 => 100644 allianceauth/services/modules/teamspeak3/util/__init__.py mode change 100755 => 100644 allianceauth/services/modules/teamspeak3/util/ts3.py mode change 100755 => 100644 allianceauth/services/templates/services/services.html mode change 100755 => 100644 allianceauth/services/views.py mode change 100755 => 100644 allianceauth/srp/__init__.py mode change 100755 => 100644 allianceauth/srp/admin.py mode change 100755 => 100644 allianceauth/srp/form.py mode change 100755 => 100644 allianceauth/srp/models.py mode change 100755 => 100644 allianceauth/srp/tests/__init__.py mode change 100755 => 100644 allianceauth/srp/tests/test_managers.py mode change 100755 => 100644 allianceauth/srp/views.py mode change 100755 => 100644 allianceauth/timerboard/__init__.py mode change 100755 => 100644 allianceauth/timerboard/admin.py mode change 100755 => 100644 allianceauth/timerboard/form.py mode change 100755 => 100644 allianceauth/timerboard/models.py mode change 100755 => 100644 allianceauth/timerboard/views.py mode change 100755 => 100644 allianceauth/urls.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0300e17f..56f9d676 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: \.mo| swagger\.json ) -# - id: check-executables-have-shebangs + - id: check-executables-have-shebangs - id: end-of-file-fixer exclude: | (?x)( diff --git a/.tx/config_20230406134150.bak b/.tx/config_20230406134150.bak old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/allianceauth/authentication/managers.py b/allianceauth/authentication/managers.py old mode 100755 new mode 100644 diff --git a/allianceauth/authentication/models.py b/allianceauth/authentication/models.py old mode 100755 new mode 100644 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 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/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/phpbb3/manager.py b/allianceauth/services/modules/phpbb3/manager.py old mode 100755 new mode 100644 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/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/templates/services/services.html b/allianceauth/services/templates/services/services.html old mode 100755 new mode 100644 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/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 diff --git a/allianceauth/timerboard/models.py b/allianceauth/timerboard/models.py old mode 100755 new mode 100644 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 From 12383d79c8c3f63e9bcf5c2ba82219a6c6fa50e0 Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Tue, 1 Aug 2023 21:37:25 +1000 Subject: [PATCH 31/41] Version Bump 3.6.0 --- allianceauth/__init__.py | 2 +- docker/.env.example | 2 +- docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/allianceauth/__init__.py b/allianceauth/__init__.py index 9434ffb7..d8150fec 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -5,7 +5,7 @@ manage online service access. # This will make sure the app is always imported when # Django starts so that shared_task will use this app. -__version__ = '3.5.1' +__version__ = '3.6.0' __title__ = 'Alliance Auth' __url__ = 'https://gitlab.com/allianceauth/allianceauth' NAME = f'{__title__} v{__version__}' diff --git a/docker/.env.example b/docker/.env.example index 14b6e136..243647c1 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.5.1 +AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.6.0 # Nginx Proxy Manager PROXY_HTTP_PORT=80 diff --git a/docker/Dockerfile b/docker/Dockerfile index 86f06f00..a5b3552f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.9-slim -ARG AUTH_VERSION=v3.5.1 +ARG AUTH_VERSION=v3.6.0 ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ENV VIRTUAL_ENV=/opt/venv ENV AUTH_USER=allianceauth From 1f55fbfccc78667c2cc41bdd9cf5f92c29d6f901 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 2 Aug 2023 15:41:47 +0200 Subject: [PATCH 32/41] Add minimum to ItemCounter and refactor redis client init --- .../task_statistics/event_series.py | 44 ++----------- .../authentication/task_statistics/helpers.py | 65 ++++++++++++++++++- .../tests/test_event_series.py | 43 ++++-------- .../{test_item_counter.py => test_helpers.py} | 43 +++++++++++- 4 files changed, 124 insertions(+), 71 deletions(-) rename allianceauth/authentication/task_statistics/tests/{test_item_counter.py => test_helpers.py} (58%) diff --git a/allianceauth/authentication/task_statistics/event_series.py b/allianceauth/authentication/task_statistics/event_series.py index 03865103..9ac1f15d 100644 --- a/allianceauth/authentication/task_statistics/event_series.py +++ b/allianceauth/authentication/task_statistics/event_series.py @@ -5,59 +5,27 @@ 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): @@ -97,7 +65,7 @@ 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 diff --git a/allianceauth/authentication/task_statistics/helpers.py b/allianceauth/authentication/task_statistics/helpers.py index 7b887be7..1766a372 100644 --- a/allianceauth/authentication/task_statistics/helpers.py +++ b/allianceauth/authentication/task_statistics/helpers.py @@ -1,21 +1,62 @@ """Helpers for Task Statistics.""" +import logging from typing import Optional +from app_utils.allianceauth import get_redis_client +from redis import Redis, RedisError + from django.core.cache import cache +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.""" + """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) -> None: + 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 = redis if redis else get_redis_client() @property def _cache_key(self) -> str: @@ -23,6 +64,9 @@ class ItemCounter: def reset(self, init_value: int = 0): """Reset counter to initial value.""" + 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): @@ -34,6 +78,8 @@ class ItemCounter: def decr(self, delta: int = 1): """Decrement counter by delta.""" + if self._minimum is not None and self.value() == self._minimum: + return try: cache.decr(self._cache_key, delta) except ValueError: @@ -42,3 +88,18 @@ class ItemCounter: 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(): + """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/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_item_counter.py b/allianceauth/authentication/task_statistics/tests/test_helpers.py similarity index 58% rename from allianceauth/authentication/task_statistics/tests/test_item_counter.py rename to allianceauth/authentication/task_statistics/tests/test_helpers.py index da6f49c1..c4d4f56b 100644 --- a/allianceauth/authentication/task_statistics/tests/test_item_counter.py +++ b/allianceauth/authentication/task_statistics/tests/test_helpers.py @@ -1,6 +1,13 @@ from unittest import TestCase +from unittest.mock import patch -from allianceauth.authentication.task_statistics.helpers import ItemCounter +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" @@ -72,3 +79,37 @@ class TestItemCounter(TestCase): 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) + + +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) From 20187cc73e04eae4021863f2c11f677f415b8093 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 2 Aug 2023 15:58:36 +0200 Subject: [PATCH 33/41] Add locks to ensure process safety --- .../authentication/task_statistics/helpers.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/allianceauth/authentication/task_statistics/helpers.py b/allianceauth/authentication/task_statistics/helpers.py index 1766a372..b75fb39c 100644 --- a/allianceauth/authentication/task_statistics/helpers.py +++ b/allianceauth/authentication/task_statistics/helpers.py @@ -3,11 +3,12 @@ import logging from typing import Optional -from app_utils.allianceauth import get_redis_client from redis import Redis, RedisError from django.core.cache import cache +from allianceauth.utils.cache import get_redis_client + logger = logging.getLogger(__name__) @@ -56,7 +57,7 @@ class ItemCounter: self._name = str(name) self._minimum = minimum - self._redis = redis if redis else get_redis_client() + self._redis = get_redis_client_or_stub() if not redis else redis @property def _cache_key(self) -> str: @@ -64,10 +65,11 @@ class ItemCounter: def reset(self, init_value: int = 0): """Reset counter to initial value.""" - if self._minimum is not None and init_value < self._minimum: - raise ValueError("Can not reset below minimum") + 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) + cache.set(self._cache_key, init_value, self.DEFAULT_CACHE_TIMEOUT) def incr(self, delta: int = 1): """Increment counter by delta.""" @@ -78,19 +80,20 @@ class ItemCounter: def decr(self, delta: int = 1): """Decrement counter by delta.""" - if self._minimum is not None and self.value() == self._minimum: - return - try: - cache.decr(self._cache_key, delta) - except ValueError: - pass + 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(): +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: From ab3f10e6f2a613a29cdaa3920cbebbe9748c34b1 Mon Sep 17 00:00:00 2001 From: ErikKalkoken Date: Wed, 2 Aug 2023 16:11:24 +0200 Subject: [PATCH 34/41] Add more tests for ItemCounter --- .../task_statistics/tests/test_helpers.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/allianceauth/authentication/task_statistics/tests/test_helpers.py b/allianceauth/authentication/task_statistics/tests/test_helpers.py index c4d4f56b..757ae38e 100644 --- a/allianceauth/authentication/task_statistics/tests/test_helpers.py +++ b/allianceauth/authentication/task_statistics/tests/test_helpers.py @@ -96,6 +96,33 @@ class TestItemCounter(TestCase): 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): From f23d4f4dd17b880bbcd1756b6f787bad43fc060d Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 2 Aug 2023 23:17:28 +0200 Subject: [PATCH 35/41] [FIX] `base.html` prepared for public views Certain things need to be behind `{% if user.is_authenticated %}` in order for the base template to play nice with public views. --- allianceauth/templates/allianceauth/base.html | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/allianceauth/templates/allianceauth/base.html b/allianceauth/templates/allianceauth/base.html index 2becf3c2..f1142c08 100644 --- a/allianceauth/templates/allianceauth/base.html +++ b/allianceauth/templates/allianceauth/base.html @@ -21,33 +21,39 @@ - {% if user.is_authenticated %} -
    - - {% include 'allianceauth/top-menu.html' %} -
    +
    + + {% include 'allianceauth/top-menu.html' %} + +
    + {% if user.is_authenticated %} {% include 'allianceauth/side-menu.html' %} -
    + {% endif %} + +
    + {% if user.is_authenticated %} {% include 'allianceauth/messages.html' %} - {% block content %} - {% endblock content %} -
    -
    + {% endif %} + + {% 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 %} From 7290eaad7e2567bc8fc1d8ec91243dc20346aa5b Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 2 Aug 2023 23:22:14 +0200 Subject: [PATCH 36/41] [FIX] Notifications menu item removed in public views --- allianceauth/templates/allianceauth/top-menu.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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' %} From 32128ace1cca3d88c958e0977aac353406c10eff Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Wed, 2 Aug 2023 23:35:25 +0200 Subject: [PATCH 37/41] [FIX] Better explanation in `local.py` project template --- .../project_name/settings/local.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/allianceauth/project_template/project_name/settings/local.py b/allianceauth/project_template/project_name/settings/local.py index 1405fbd4..3c978885 100644 --- a/allianceauth/project_template/project_name/settings/local.py +++ b/allianceauth/project_template/project_name/settings/local.py @@ -32,10 +32,16 @@ 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. -# If you want to allow specific apps to have public views -# you can put there names here (same name as in INSTALLED_APPS): -APPS_WITH_PUBLIC_VIEWS = [] +# By default, apps are prevented from having public views for security reasons. +# If you want to allow specific apps to have public views, +# you can put their names here (same name as in INSTALLED_APPS). +# +# Note: +# » The format is the same as in INSTALLED_APPS +# » The app developer must explicitly allow public views for his app +APPS_WITH_PUBLIC_VIEWS = [ + +] # Enter credentials to use MySQL/MariaDB. Comment out to use sqlite3 DATABASES['default'] = { From 1563805ddbfeaaebbb697b0c99081c54df345161 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Sat, 5 Aug 2023 18:51:12 +0200 Subject: [PATCH 38/41] [ADD] CCP's "No Character" character image as user menu image for public --- allianceauth/static/allianceauth/css/auth-base.css | 2 +- .../templates/allianceauth/top-menu-user-dropdown.html | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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/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 %} From 8fbe0ba45d31af566d0555bfb797b8ede692e991 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Mon, 7 Aug 2023 07:23:21 +0200 Subject: [PATCH 39/41] [CHANGE] Comment --- .../project_template/project_name/settings/local.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/allianceauth/project_template/project_name/settings/local.py b/allianceauth/project_template/project_name/settings/local.py index 3c978885..d83fd718 100644 --- a/allianceauth/project_template/project_name/settings/local.py +++ b/allianceauth/project_template/project_name/settings/local.py @@ -33,12 +33,9 @@ INSTALLED_APPS += [ # LOGGING['handlers']['extension_file']['level'] = 'DEBUG' # By default, apps are prevented from having public views for security reasons. -# If you want to allow specific apps to have public views, -# you can put their names here (same name as in INSTALLED_APPS). -# -# Note: +# 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 explicitly allow public views for his app +# » The app developer must also explicitly allow public views for their app APPS_WITH_PUBLIC_VIEWS = [ ] From 55927c6f15b39ad20013b1ec5042b926405db786 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Tue, 8 Aug 2023 23:20:43 +0200 Subject: [PATCH 40/41] [FIX] Allow messages also for non-logged-in user to be displayed --- allianceauth/templates/allianceauth/base.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/allianceauth/templates/allianceauth/base.html b/allianceauth/templates/allianceauth/base.html index f1142c08..f82e7124 100644 --- a/allianceauth/templates/allianceauth/base.html +++ b/allianceauth/templates/allianceauth/base.html @@ -31,9 +31,7 @@ {% endif %}
    - {% if user.is_authenticated %} - {% include 'allianceauth/messages.html' %} - {% endif %} + {% include 'allianceauth/messages.html' %} {% block content %} {% endblock content %} From 36b3077caa478aed2f33fcc67c712150561d4927 Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Thu, 10 Aug 2023 14:37:27 +1000 Subject: [PATCH 41/41] Version Bump 3.6.1 --- allianceauth/__init__.py | 2 +- docker/.env.example | 2 +- docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/allianceauth/__init__.py b/allianceauth/__init__.py index d8150fec..c846863b 100644 --- a/allianceauth/__init__.py +++ b/allianceauth/__init__.py @@ -5,7 +5,7 @@ manage online service access. # This will make sure the app is always imported when # Django starts so that shared_task will use this app. -__version__ = '3.6.0' +__version__ = '3.6.1' __title__ = 'Alliance Auth' __url__ = 'https://gitlab.com/allianceauth/allianceauth' NAME = f'{__title__} v{__version__}' diff --git a/docker/.env.example b/docker/.env.example index 243647c1..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.6.0 +AA_DOCKER_TAG=registry.gitlab.com/allianceauth/allianceauth/auth:v3.6.1 # Nginx Proxy Manager PROXY_HTTP_PORT=80 diff --git a/docker/Dockerfile b/docker/Dockerfile index a5b3552f..6607eeb9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.9-slim -ARG AUTH_VERSION=v3.6.0 +ARG AUTH_VERSION=v3.6.1 ARG AUTH_PACKAGE=allianceauth==${AUTH_VERSION} ENV VIRTUAL_ENV=/opt/venv ENV AUTH_USER=allianceauth