Compare commits

..

178 Commits

Author SHA1 Message Date
Ariel Rin
399ef1917d Version Bump 3.3.0 2022-10-14 21:46:28 +10:00
Ariel Rin
9db443ba54 Merge branch 'tokens-and-alts' into 'master'
CCP SSO Issues, Mitigations

See merge request allianceauth/allianceauth!1472
2022-10-14 11:44:18 +00:00
Ariel Rin
0f2f5ea0ba nowrap to stop buttons moving around 2022-10-14 20:21:07 +10:00
Ariel Rin
1f781c5037 datatables statesave 2022-10-12 20:52:59 +10:00
Ariel Rin
36dedfcbd2 add disclaimer 2022-10-12 20:51:36 +10:00
Ariel Rin
13a05606fb Scopes Typo 2022-10-12 20:29:42 +10:00
Ariel Rin
90ad7790e1 rename revoke to delete to be clearer 2022-10-12 20:22:20 +10:00
Ariel Rin
6b8341ab5a Add FA icon to user dropdown 2022-10-12 20:21:27 +10:00
Ariel Rin
d15f42b3fd Merge branch 'tokens-and-alts' of https://gitlab.com/aaronkable/allianceauth into tokens-and-alts 2022-10-12 20:07:49 +10:00
Aaron Kable
cc60b26f5a add token management link to user dropdown 2022-10-12 17:57:12 +08:00
Aaron Kable
36ff0af993 Add token management and restrict logins to mains only 2022-10-12 17:50:41 +08:00
Aaron Kable
f17c94a9e1 Add token management and restrict logins to mains only 2022-10-12 17:49:28 +08:00
Ariel Rin
7e3ba476f3 Merge branch 'docs' into 'master'
Discord Credential Clarification

See merge request allianceauth/allianceauth!1470
2022-10-12 08:07:02 +00:00
Ariel Rin
dd1313a2a9 Merge branch 'remove-unnecessary-lambda-statement' into 'master'
[REMOVED] Unnecessary `lambda` statement

See merge request allianceauth/allianceauth!1465
2022-10-09 08:17:46 +00:00
Ariel Rin
763003bd7d Clarify new discord developers layout 2022-10-09 17:41:41 +10:00
Ariel Rin
f3217443dd Merge branch 'remove-celery-backend-from-docker' into 'master'
[REMOVED] Celery backend from docker config

See merge request allianceauth/allianceauth!1466
2022-10-09 07:16:04 +00:00
Ariel Rin
a713ae1914 Merge branch 'fix-default-perms-for-static-files' into 'master'
[FIX] Default permissions for static files

See merge request allianceauth/allianceauth!1469
2022-10-09 06:15:18 +00:00
Peter Pfeufer
5815bac0df [FIX] Default permissions for static files
A little fallacy on my end in the docs.

655 is enough for files in those directories, but the directories themselves need to be traversal, so 755 for the directories ...
2022-09-22 19:54:58 +02:00
Ariel Rin
6154d2c2e7 Merge branch 'better-exclude-regex' into 'master'
[CHANGE] Better regex for exclusion in pre-commit

See merge request allianceauth/allianceauth!1468
2022-09-18 08:26:22 +00:00
Peter Pfeufer
b34661b35d [CHANGE] Better regex for exclusion in pre-commit 2022-09-18 08:26:22 +00:00
Ariel Rin
a9a7e03b80 Merge branch 'capntack-master-patch-07678' into 'master'
Update switch_to_non_root.md

See merge request allianceauth/allianceauth!1467
2022-09-16 14:48:08 +00:00
Tack
23c797ef64 Update switch_to_non_root.md
chmod requires "-R" to be Recursive, not "-r"
2022-09-14 16:51:10 +00:00
Ariel Rin
da102618a0 Version Bump 3.2.0 2022-09-14 23:26:02 +10:00
Ariel Rin
51ee281b14 Update from Transifex 2022-09-14 23:20:08 +10:00
Peter Pfeufer
9133232c20 [REMOVED] Celery backend from docker config 2022-09-14 13:06:30 +02:00
Peter Pfeufer
9cbabee126 [CHANGE] Language names always start with a capital letter 2022-09-13 21:00:31 +02:00
Peter Pfeufer
4026523a2e [REMOVED] Unnecessary lambda statement
The `lambda` statement in `base.py` is unnecessary and has no effect.

```py
ugettext = lambda s: s
LANGUAGES = (
    ("en", ugettext("English")),
    ("de", ugettext("German")),
    ("es", ugettext("Spanish")),
    ("zh-hans", ugettext("Chinese Simplified")),
    ("ru", ugettext("Russian")),
    ("ko", ugettext("Korean")),
    ("fr", ugettext("French")),
    ("ja", ugettext("Japanese")),
    ("it", ugettext("Italian")),
)
```

In this case `ugettext = lambda s: s` is pretty much the same as:
```py
def ugettext(s):
    return s
```
And would simply return the string the function receives as parameter.

So we can omit this completely and simplify the `LANGUAGES` list to:

```py
LANGUAGES = (
    ("en", "English"),
    ("de", "German"),
    ("es", "Spanish"),
    ("zh-hans", "Chinese Simplified"),
    ("ru", "Russian"),
    ("ko", "Korean"),
    ("fr", "French"),
    ("ja", "Japanese"),
    ("it", "Italian"),
)
```
2022-09-13 20:59:14 +02:00
Ariel Rin
7fbf96623b Update from Transifex 2022-09-12 11:41:08 +10:00
Ariel Rin
273bda173e Merge branch '1088-qol-improve-name-handling-for-smf' into 'master'
Displayed names for SMF

Closes #1088

See merge request allianceauth/allianceauth!1459
2022-09-11 13:53:25 +00:00
Ariel Rin
7bd5838ea1 Merge branch 'master' into 'master'
minor fixes to dev environment setup

See merge request allianceauth/allianceauth!1458
2022-09-11 13:51:42 +00:00
Ariel Rin
b232d9ab17 Merge branch 'use-SITE_URL-in-templates' into 'master'
[ADDED] `SITE_URL` usage in templates

See merge request allianceauth/allianceauth!1456
2022-09-11 13:51:21 +00:00
Ariel Rin
a11b870664 Merge branch 'switch-to-non-root' into 'master'
Add docs for switching to a non-root installation

See merge request allianceauth/allianceauth!1463
2022-09-11 13:48:44 +00:00
Erik Kalkoken
a27aae5d1c Add docs for switching to a non-root installation 2022-09-11 13:48:43 +00:00
Ariel Rin
117ef63d90 Merge branch 'trailing-slash-it' into 'master'
[ADDED] Missing trailing slashes to URLs

See merge request allianceauth/allianceauth!1457
2022-09-11 13:45:50 +00:00
Ariel Rin
1bde3d5672 Merge branch 'esi-related-links-on-login' into 'master'
[ADDED] ESI related links in login box

See merge request allianceauth/allianceauth!1455
2022-09-11 13:44:55 +00:00
Ariel Rin
d2355b1ec8 Merge branch 'none-is-not-an-alliance' into 'master'
[CHANGE] None is not an alliance name, so don't show `None`

See merge request allianceauth/allianceauth!1460
2022-09-11 13:44:34 +00:00
Ariel Rin
191d474a8e Merge branch 'task-queue-top-margin-fix' into 'master'
[FIX] Top margin on celery task percentage bar

See merge request allianceauth/allianceauth!1461
2022-09-11 13:44:11 +00:00
Peter Pfeufer
ec9a9733be [FIX] Top margin on celery task percentage bar 2022-09-11 13:44:11 +00:00
Ariel Rin
cf7a8cedf1 Merge branch 'dont-fight-against-bootstrap' into 'master'
[FIX] Use proper markup instead of fighting against Bootstrap

See merge request allianceauth/allianceauth!1462
2022-09-11 13:43:38 +00:00
Peter Pfeufer
18cbb994d5 [FIX] Use proper markup instead of fighting against Bootstrap 2022-09-08 11:22:40 +02:00
Peter Pfeufer
663388a0c2 [CHANGE] None is not an alliance name, so don't show None 2022-09-08 00:19:41 +02:00
Peter Pfeufer
7a943591ec [ADDED] Migration to update existing user's displayed names 2022-09-07 23:01:38 +02:00
Peter Pfeufer
cd189927fe [ADDED] Update displayed name when main is changed 2022-09-07 23:01:07 +02:00
Peter Pfeufer
8772349309 [ADDED] Main character name as displayed name on SMF service activation 2022-09-07 21:14:18 +02:00
Arc Tiru
cf20100cb5 minor fixes to dev environment setup 2022-09-07 11:00:38 -07:00
Peter Pfeufer
9b9c2ddc04 [ADDED] SITE_URLto test settings 2022-09-07 15:31:22 +02:00
Peter Pfeufer
34839e8344 [ADDED] Trailing slahes to URLs
URLs in AA usually use a trailing slash, so this was added to the ones that were missing it.
2022-09-07 15:18:06 +02:00
Peter Pfeufer
89ef4f4cbc [ADDED] SITE_URL usage in templates
Instead of {{ request.scheme }}://{{request.get_host}}`
2022-09-07 15:04:25 +02:00
Peter Pfeufer
2cc7f46aae [ADDED] ESI related links in login box 2022-09-07 14:45:04 +02:00
Ariel Rin
8d255fb720 Merge branch '834-check-if-character-is-online' into 'master'
[FIX] Check if character is online before accepting FAT click

Closes #834

See merge request allianceauth/allianceauth!1451
2022-09-07 06:29:19 +00:00
Ariel Rin
67cf68ad87 Merge branch 'no_unique_names' into 'master'
Corp and Alliance names are not unique

Closes #1317

See merge request allianceauth/allianceauth!1452
2022-09-07 06:28:01 +00:00
colcrunch
db1971d4c2 Corp and Alliance names are not unique 2022-09-07 06:28:01 +00:00
Ariel Rin
63c1521cba Merge branch 'add-missing-doctype' into 'master'
[FIX] Missing DOCTYPE and padding

See merge request allianceauth/allianceauth!1449
2022-09-07 06:25:45 +00:00
Ariel Rin
ba7ef11505 Merge branch 'fix-deprecated-translation-tags' into 'master'
[FIX] Deprecated `{% blocktrans %}` tags to `{% blocktranslate %}`

See merge request allianceauth/allianceauth!1454
2022-09-07 06:22:55 +00:00
Ariel Rin
d2e494b9be Merge branch 'modernize-css' into 'master'
CSS modernized

See merge request allianceauth/allianceauth!1448
2022-09-07 06:21:15 +00:00
Ariel Rin
98bab0b180 Merge branch 'bundle-all-the-things' into 'master'
Bundle the remaining static files

See merge request allianceauth/allianceauth!1447
2022-09-07 06:20:41 +00:00
Ariel Rin
c4efb2a11f Merge branch 'aa3-docker-fixes' into 'master'
[CHANGE] Docker updates for AA3

Closes #1352

See merge request allianceauth/allianceauth!1453
2022-09-07 06:19:47 +00:00
Ariel Rin
94e4895f29 Merge branch 'clarify-url-format' into 'master'
[MISC] Clarify URL format

See merge request allianceauth/allianceauth!1450
2022-09-07 06:18:43 +00:00
Peter Pfeufer
70eb1b5b50 [CHANGE] Deprecated {% blocktrans %} tags to {% blocktranslate %} 2022-09-06 23:48:36 +02:00
Peter Pfeufer
e247a94db3 [CHANGE] Updates for AA3
- SITE_URL introduced
- Redis cache fixed (#1352)
- Using the same type of quotes, not wildly mixing them
2022-09-06 22:50:49 +02:00
Peter Pfeufer
714431c932 [FIX] Check if character is online before accepting FAT click
Fixes #834
2022-08-07 12:08:57 +02:00
Peter Pfeufer
b026277ab0 [MISC] Clarify URL format
Seems his is causing confusion, so add a note that the URL should be without a trailing slash
2022-08-07 02:01:26 +02:00
Peter Pfeufer
11855f0b54 [FIX] Missing DOCTYPE and padding 2022-08-05 19:51:50 +02:00
Ariel Rin
635fbfe2c8 Version Bump v3.1.1 2022-08-05 21:35:51 +10:00
Ariel Rin
b10233daf0 Merge branch 'transifex' of https://gitlab.com/allianceauth/allianceauth 2022-08-05 21:23:58 +10:00
Ariel Rin
1aa3187491 Cap Django to 4.0.x 2022-08-05 21:23:40 +10:00
Ariel Rin
59f17a88f0 Update from Transifex 2022-08-05 21:20:32 +10:00
Peter Pfeufer
75db3195d4 CSS modernized 2022-08-02 23:56:47 +02:00
Peter Pfeufer
afe3fea757 Bundle the remaining static files
They are used in some template overrides. To prevent missing JS or CSS in the future in those template overrides, it's a good idea to provide HTML templates for these static files.
2022-08-01 14:15:55 +02:00
Ariel Rin
1072c00a28 Version Bump 3.1.0 2022-08-01 20:44:07 +10:00
Ariel Rin
b221c1ce24 highlight the settings file configration step 2022-08-01 19:28:28 +10:00
Ariel Rin
1617c775ee note SITE_URL in settings file config, plus explicitly other things from install guide 2022-08-01 19:19:01 +10:00
Ariel Rin
cc88a02001 Merge branch 'csrf-trusted-origins' into 'master'
Add `CSRF_TRUSTED_ORIGINS` to `local.py`

See merge request allianceauth/allianceauth!1446
2022-08-01 08:59:15 +00:00
Ariel Rin
ae5d0f4a2f Merge branch 'setuptools-test' into 'master'
Re-adding MANIFEST.in

See merge request allianceauth/allianceauth!1445
2022-08-01 08:53:11 +00:00
Peter Pfeufer
067e2c424e Added new variable SITE_URL to DISCORD_CALLBACK_URL 2022-07-31 17:02:02 +02:00
Peter Pfeufer
d64675a3b0 Added new variable SITE_URL to ESI_SSO_CALLBACK_URL 2022-07-31 16:57:37 +02:00
Peter Pfeufer
17a6b3225e Add CSRF_TRUSTED_ORIGINS to local.py
Addresses #1350
2022-07-31 16:55:34 +02:00
Peter Pfeufer
b83f591dc2 Re-adding MANIFEST.in 2022-07-31 01:01:43 +02:00
Ariel Rin
be2fbe862b Version Bump 3.0.0 2022-07-30 19:10:07 +10:00
Ariel Rin
f95bee0921 roll back readthedocs python version, pending #1331 2022-07-30 18:54:53 +10:00
Ariel Rin
2f9ae8b054 Django bugfix bump before release 2022-07-30 18:40:27 +10:00
Ariel Rin
74651dd30a Update from Transifex 2022-07-30 18:21:20 +10:00
Ariel Rin
9cdcd8365c Merge branch 'docs' into 'v3.x'
Refactor Docs with OS Versions and non-root

See merge request allianceauth/allianceauth!1390
2022-07-30 07:26:43 +00:00
Ariel Rin
f5d70a2c48 Refactor Docs with OS Versions and non-root 2022-07-30 07:26:42 +00:00
Ariel Rin
f40ebbfba4 Merge branch 'v3.x' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-30 16:35:31 +10:00
Ariel Rin
2551a9dd64 Merge branch 'the-big-template-cleanup' into 'v3.x'
Big Template Cleanup

See merge request allianceauth/allianceauth!1443
2022-07-29 12:49:58 +00:00
Ariel Rin
e3b01ccbc9 Merge branch 'apache-ssl-settings-in-local-py' into 'v3.x'
Added fix for "apache vs django" proxy headers to docs

See merge request allianceauth/allianceauth!1440
2022-07-29 12:48:35 +00:00
Ariel Rin
267a392945 Merge branch 'timerboard-mandatory-fields' into 'v3.x'
[FIX] Set `planet_moon` field as not required

See merge request allianceauth/allianceauth!1441
2022-07-23 05:13:03 +00:00
Ariel Rin
634d021bf2 Merge branch 'fix-hr-search-result-duplication' into 'v3.x'
[FIX] Search result duplication in HR module

See merge request allianceauth/allianceauth!1444
2022-07-23 05:12:15 +00:00
Ariel Rin
4e8bfba738 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-19 20:46:24 +10:00
Ariel Rin
297f98f046 Version Bump 2.15.1 2022-07-19 19:07:47 +10:00
Ariel Rin
27dad05927 Merge branch 'fix-discord-update-username' into 'master'
Fix discord update username

See merge request allianceauth/allianceauth!1442
2022-07-19 08:48:26 +00:00
Erik Kalkoken
697e9dd772 Fix discord update username 2022-07-19 08:48:25 +00:00
Peter Pfeufer
312951ea3f One search result per user is enough
No need to show the same result for each alt ....
2022-07-18 23:02:28 +02:00
Peter Pfeufer
e4bf96cfb6 Deprecated attributes removed 2022-07-18 21:51:07 +02:00
Peter Pfeufer
3bd6baa8f9 Templates cleaned up / fixed
- Deprecated CSS atrributes removed
- HTML fixes
    - Mandatory attributes added
    - Missing semicolons added
    - Missing closing tags added
    - Missing label association in forms added/fixed
    - Missing quotes added
    - Closing tags that have no opening tag removed
- Bootstrap fixes
- Unused template tags removed
2022-07-18 21:39:20 +02:00
Peter Pfeufer
06e38dcd93 [FIX] Set planet_moon field as not required
It is explicitly set to be not required in the form, so it shouldn't be required in the Django backend
2022-07-18 19:37:25 +02:00
Peter Pfeufer
f47b9eee5b Added fix for "apache vs django" proxy headers 2022-07-18 18:10:54 +02:00
Ariel Rin
0d4cab66b2 Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-18 21:25:23 +10:00
Ariel Rin
dc23ee8ad2 Delay automated docker builds, to allow publishing pipeline to catch up 2022-07-18 10:14:05 +00:00
Ariel Rin
65f2efc890 Version Bump 2.15.0 2022-07-18 19:22:50 +10:00
Ariel Rin
def30900b4 Merge branch 'discord_bugfixes_and_refactor' into 'master'
Fix managed roles and reserved groups bugs in Discord Service and more

Closes #1345 and #1334

See merge request allianceauth/allianceauth!1429
2022-07-18 09:12:32 +00:00
Erik Kalkoken
d7fabccddd Fix managed roles and reserved groups bugs in Discord Service and more 2022-07-18 09:12:32 +00:00
Ariel Rin
45289e1d17 Merge branch 'fix-filterdropdown-bug' into 'master'
Fix filterdropdown bug

See merge request allianceauth/allianceauth!1439
2022-07-18 09:04:35 +00:00
Ariel Rin
e7bafaa4d8 Merge branch 'datatables-filterdropdown-js-update' into 'v3.x'
Update `filterDropDown.js` to latest available version

See merge request allianceauth/allianceauth!1438
2022-07-18 09:01:30 +00:00
Peter Pfeufer
ba3f1507be LICENSE file added 2022-07-18 10:51:40 +02:00
ErikKalkoken
7b9bf08aa3 Fix bug in filterDropDown bundle 2022-07-15 13:39:48 +02:00
Peter Pfeufer
360458f574 Update with latest version 2022-07-12 18:27:28 +02:00
Ariel Rin
def6431052 Version Bump 2.14.0 2022-07-11 14:27:49 +10:00
Ariel Rin
a47bd8d7c7 Version Bump 3.0.0b3 2022-07-11 14:20:53 +10:00
Ariel Rin
22a270aedb Merge branch 'filterdropdown-backwards-compatibility' into 'master'
Add filterdropdown bundle to AA2 to ensure backwards compatibility

See merge request allianceauth/allianceauth!1437
2022-07-11 04:15:25 +00:00
Ariel Rin
54bce4315b Merge branch 'add-filterdropdown-js-to-bundles' into 'v3.x'
Add filterdropdown js to bundles

See merge request allianceauth/allianceauth!1436
2022-07-11 04:14:46 +00:00
Peter Pfeufer
c930f7bbeb Also adds timers.js, eve-time.js and refresh_notifications.js
As these seem to be used in some apps as well
2022-07-09 15:57:43 +02:00
Peter Pfeufer
8c1f06d7b8 Added refresh_notifications.js to bundles
Probably used in template overrides
2022-07-09 15:51:55 +02:00
Peter Pfeufer
815b6fa030 Added eve-time.js to bundles
Probably used in template overrides
2022-07-09 15:50:09 +02:00
Peter Pfeufer
7c05217900 Add timers.js to bundle
It's used in `mumbletemps`
2022-07-09 15:45:25 +02:00
Peter Pfeufer
64ee273953 Add filterdropdown bundle to AA2 to ensure backwards compatibility 2022-07-09 13:43:05 +02:00
Peter Pfeufer
d8c2944966 [FIX] table HTML syntax 2022-07-07 11:55:10 +02:00
Peter Pfeufer
7669c9e55d Add filterDropDown.js to bundles 2022-07-07 11:54:31 +02:00
Ariel Rin
71c9faaf28 Version Bump 3.0.0b2 2022-07-07 18:15:37 +10:00
Ariel Rin
236c70316c Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-07-07 18:07:49 +10:00
Ariel Rin
0d0686f58a Merge branch 'allianceauth-prefix-static-files-directory' into v3.x 2022-07-07 18:01:12 +10:00
Ariel Rin
3706a1aedf Merge branch 'improve-autodocs-for-models' into 'master'
Improve autodocs for models & more

See merge request allianceauth/allianceauth!1435
2022-07-07 07:38:58 +00:00
Ariel Rin
47f1b77320 Merge branch 'consolidate-redis-client-access' into 'master'
Ensure backwards compatibility when fetching a redis client

See merge request allianceauth/allianceauth!1428
2022-07-07 07:37:21 +00:00
Erik Kalkoken
8dec242a93 Ensure backwards compatibility when fetching a redis client 2022-07-07 07:37:21 +00:00
Ariel Rin
6b934060dd Merge branch 'remove-old-unused-templates' into 'v3.x'
Remove unused templates

See merge request allianceauth/allianceauth!1431
2022-07-07 07:36:21 +00:00
Ariel Rin
ff88a16163 Merge branch 'smf-2.1-compatibility' into 'v3.x'
SMF 2.1 compatibility

See merge request allianceauth/allianceauth!1432
2022-07-07 07:35:58 +00:00
Ariel Rin
e81a66b74b Merge branch 'setup.cfg-vs-manifest-part-2' into 'v3.x'
setup.cfg vs MANIFEST.in (Round 2)

See merge request allianceauth/allianceauth!1434
2022-07-07 07:34:17 +00:00
ErikKalkoken
2ff200c566 Refer to django-esi docs 2022-06-27 13:43:45 +02:00
ErikKalkoken
091a2637ea Add extension to improve autodocs for Django models & enable source links 2022-06-27 13:41:15 +02:00
Peter Pfeufer
6c7729308c Should be find_namespace 2022-06-27 05:39:45 +02:00
Peter Pfeufer
0195ef23d5 Attempt to remove MANIFEST.in file (Part 2) 2022-06-27 05:38:31 +02:00
Peter Pfeufer
a7afa4a0c3 Background for login page fixed 2022-06-25 14:44:28 +02:00
Peter Pfeufer
004100091f Moved authentication and services into allianceauth folder 2022-06-25 14:01:32 +02:00
Peter Pfeufer
20231ce198 Load static files from their new place 2022-06-25 13:51:46 +02:00
Peter Pfeufer
0851a6d085 [Cleanup] Removed {% load static %} when no static files are loaded 2022-06-25 13:51:10 +02:00
Peter Pfeufer
0cd36ad5bc Moved SSO button from "root" to "authentication" 2022-06-25 13:46:56 +02:00
Peter Pfeufer
7618dd0f91 Removed obsolete attribute 2022-06-25 13:31:12 +02:00
Peter Pfeufer
cf49a2cb65 Moved static files to their own directory 2022-06-25 13:30:32 +02:00
Peter Pfeufer
cbdce18633 Use regex to determine the SMF version (thanks @colcrunch) 2022-06-24 23:01:16 +02:00
Peter Pfeufer
a0d14eb1d3 SQL queries need to be different depending on SMF version
It's not a work of art, but it does the job. If anyone has a better idea, hit the comments ...
2022-06-24 22:40:04 +02:00
Peter Pfeufer
53ce4d2453 f-strings in log messages
Easier to read and modernized the code
2022-06-24 21:09:32 +02:00
Peter Pfeufer
1ddb041d6d Add Exception messages to exception logging 2022-06-24 21:00:54 +02:00
Peter Pfeufer
43cbfd1c47 Stop usage of deprecated logger functions 2022-06-24 20:57:58 +02:00
Peter Pfeufer
b9a8495a43 Add exception message to log output 2022-06-24 20:56:38 +02:00
Peter Pfeufer
e296477880 Use pwhash instead of passwd
This way we ensure that the initial password actually works and the user doesn't have to set a new one right away.
2022-06-24 20:55:04 +02:00
Peter Pfeufer
bd5c2d8cbc Removed non existent table columns from SQL query 2022-06-24 20:54:05 +02:00
Peter Pfeufer
ccd40d5c68 Remove unused templates 2022-06-23 21:26:13 +02:00
Ariel Rin
7f8ca4fad2 Version Bump 3.0.0b1 2022-06-18 14:48:40 +10:00
Ariel Rin
4bb9a7155d this should point at master, not an old branch 2022-06-18 14:48:14 +10:00
Ariel Rin
2ac79954f3 update from Transifex 2022-06-18 14:42:26 +10:00
Ariel Rin
585e1f47f3 cap docutils to enable recommonmark to still work. 2022-06-18 14:21:56 +10:00
Ariel Rin
a33c474b35 Use new Redis in tests 2022-06-18 14:05:39 +10:00
Ariel Rin
61c3d8964b use new django-redis package, reorganize to match setup.cfg order for better comparison 2022-06-18 13:48:46 +10:00
Ariel Rin
1c927c5820 move secret_detection gitlab job into gitlab stage 2022-06-18 13:41:39 +10:00
Ariel Rin
ff0fa0329d Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v3.x 2022-06-18 13:28:15 +10:00
Ariel Rin
e51ea439ca update pre-commit in prep 2022-06-18 13:19:59 +10:00
Ariel Rin
113977b19f Version Bump 2.13.0 2022-06-18 13:07:36 +10:00
Ariel Rin
8f39b50b6d Merge branch 'Maestro-Zacht-fix-fat-attributeerror' into 'master'
fixed attribute error

See merge request allianceauth/allianceauth!1421
2022-06-18 02:53:11 +00:00
Maestro-Zacht
95b309c358 fixed attribute error 2022-06-18 02:53:11 +00:00
Ariel Rin
cf3df3b715 Merge branch 'fix_issue_1328' into 'master'
Fix: Changing group's state setting does not kick existing non-conforming group members

Closes #1328

See merge request allianceauth/allianceauth!1400
2022-06-18 02:47:14 +00:00
Erik Kalkoken
d815028c4d Fix: Changing group's state setting does not kick existing non-conforming group members 2022-06-18 02:47:14 +00:00
Ariel Rin
ac5570abe2 Merge branch 'fix_issue_1268' into 'master'
Fix: Service group updates broken when adding users to groups

Closes #1268

See merge request allianceauth/allianceauth!1403
2022-06-18 02:41:23 +00:00
Erik Kalkoken
84ad571aa4 Fix: Service group updates broken when adding users to groups 2022-06-18 02:41:23 +00:00
Ariel Rin
38e7705ae7 Merge branch 'docs-dark-mode' into 'master'
Add automatic dark mode to docs

See merge request allianceauth/allianceauth!1427
2022-06-18 02:39:59 +00:00
ErikKalkoken
0b6af014fa Add automatic dark mode to docs 2022-06-17 21:49:18 +02:00
Ariel Rin
2401f2299d Merge branch 'fix-doc-redis-issue' into 'master'
Fix: Broken docs generation on readthedocs.org (2nd attempt)

See merge request allianceauth/allianceauth!1425
2022-06-17 11:58:45 +00:00
Erik Kalkoken
919768c8bb Fix: Broken docs generation on readthedocs.org (2nd attempt) 2022-06-17 11:58:45 +00:00
Ariel Rin
24db21463b Merge branch 'docs-template-tags-example' into 'master'
Add example for template tags to docs

See merge request allianceauth/allianceauth!1426
2022-06-17 11:58:05 +00:00
Erik Kalkoken
1e029af83a Add example for template tags to docs 2022-06-17 11:58:05 +00:00
Ariel Rin
53dd8ce606 Merge branch 'buil-tests' into 'v3.x'
Add build test to downloadable artifacts

See merge request allianceauth/allianceauth!1424
2022-06-17 11:56:20 +00:00
Peter Pfeufer
0f4003366d Add build test to downloadable artifacts 2022-06-17 11:56:20 +00:00
Ariel Rin
2b31be789d Merge branch 'fix-issue-1336' into 'master'
Fix: Broken docs generation on readthedocs.org

Closes #1336

See merge request allianceauth/allianceauth!1423
2022-06-06 10:48:16 +00:00
Erik Kalkoken
bf1b4bb549 Fix: Broken docs generation on readthedocs.org 2022-06-06 10:48:16 +00:00
Ariel Rin
dd42b807f0 Version Bump 2.12.1 2022-05-13 00:19:45 +10:00
Ariel Rin
542fbafd98 Merge branch 'cherry-pick-4836559a' into 'v2.12.x'
Merge branch 'fix-decimal_widthratio-template-tag' into 'v2.12.x'

See merge request allianceauth/allianceauth!1420
2022-05-12 14:14:01 +00:00
Ariel Rin
37b9f5c882 Merge branch 'fix-decimal_widthratio-template-tag' into 'v3.x'
[FIX] Division by zero in decimal_widthratio template tag

See merge request allianceauth/allianceauth!1419

(cherry picked from commit 4836559abe)

8dd07b97 [FIX] Devision by zero in decimal_widthratio template tag
17b06c88 Make it a string in accordance to the return value type
2022-05-12 13:33:45 +00:00
Ariel Rin
5bde9a6952 Version Bump 2.12.0 2022-05-12 18:54:22 +10:00
239 changed files with 11008 additions and 7807 deletions

View File

@@ -5,16 +5,16 @@
- merge_requests - merge_requests
stages: stages:
- pre-commit - pre-commit
- gitlab - gitlab
- test - test
- deploy - deploy
- docker - docker
include: include:
- template: Dependency-Scanning.gitlab-ci.yml - template: Dependency-Scanning.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml - template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml
before_script: before_script:
- apt-get update && apt-get install redis-server -y - apt-get update && apt-get install redis-server -y
@@ -42,16 +42,20 @@ sast:
dependency_scanning: dependency_scanning:
stage: gitlab stage: gitlab
before_script: before_script:
- apt-get update && apt-get install redis-server libmariadb-dev -y - apt-get update && apt-get install redis-server libmariadb-dev -y
- redis-server --daemonize yes - redis-server --daemonize yes
- python -V - python -V
- pip install wheel tox - pip install wheel tox
secret_detection:
stage: gitlab
before_script: []
test-3.8-core: test-3.8-core:
<<: *only-default <<: *only-default
image: python:3.8-bullseye image: python:3.8-bullseye
script: script:
- tox -e py38-core - tox -e py38-core
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -63,7 +67,7 @@ test-3.9-core:
<<: *only-default <<: *only-default
image: python:3.9-bullseye image: python:3.9-bullseye
script: script:
- tox -e py39-core - tox -e py39-core
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -75,7 +79,7 @@ test-3.10-core:
<<: *only-default <<: *only-default
image: python:3.10-bullseye image: python:3.10-bullseye
script: script:
- tox -e py310-core - tox -e py310-core
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -87,7 +91,7 @@ test-3.11-core:
<<: *only-default <<: *only-default
image: python:3.11-rc-bullseye image: python:3.11-rc-bullseye
script: script:
- tox -e py311-core - tox -e py311-core
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -100,7 +104,7 @@ test-3.8-all:
<<: *only-default <<: *only-default
image: python:3.8-bullseye image: python:3.8-bullseye
script: script:
- tox -e py38-all - tox -e py38-all
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -112,7 +116,7 @@ test-3.9-all:
<<: *only-default <<: *only-default
image: python:3.9-bullseye image: python:3.9-bullseye
script: script:
- tox -e py39-all - tox -e py39-all
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -124,7 +128,7 @@ test-3.10-all:
<<: *only-default <<: *only-default
image: python:3.10-bullseye image: python:3.10-bullseye
script: script:
- tox -e py310-all - tox -e py310-all
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -136,7 +140,7 @@ test-3.11-all:
<<: *only-default <<: *only-default
image: python:3.11-rc-bullseye image: python:3.11-rc-bullseye
script: script:
- tox -e py311-all - tox -e py311-all
artifacts: artifacts:
when: always when: always
reports: reports:
@@ -145,6 +149,31 @@ test-3.11-all:
path: coverage.xml path: coverage.xml
allow_failure: true allow_failure: true
build-test:
stage: test
image: python:3.10-bullseye
before_script:
- python -m pip install --upgrade pip
- python -m pip install --upgrade build
- python -m pip install --upgrade setuptools wheel
script:
- python -m build
artifacts:
when: always
name: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- dist/*
expire_in: 1 year
test-docs:
<<: *only-default
image: python:3.10-bullseye
script:
- tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.10-bullseye image: python:3.10-bullseye
@@ -184,6 +213,8 @@ build-image:
docker image push --all-tags $CI_REGISTRY_IMAGE/auth docker image push --all-tags $CI_REGISTRY_IMAGE/auth
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
when: delayed
start_in: 10 minutes
build-image-dev: build-image-dev:
before_script: [] before_script: []

View File

@@ -5,7 +5,7 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0 rev: v4.3.0
hooks: hooks:
- id: check-case-conflict - id: check-case-conflict
- id: check-json - id: check-json
@@ -13,9 +13,23 @@ repos:
- id: check-yaml - id: check-yaml
- id: fix-byte-order-marker - id: fix-byte-order-marker
- id: trailing-whitespace - id: trailing-whitespace
exclude: (\.min\.css|\.min\.js|\.mo|\.po|swagger\.json)$ exclude: |
(?x)(
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json
)
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: (\.min\.css|\.min\.js|\.mo|\.po|swagger\.json)$ exclude: |
(?x)(
\.min\.css|
\.min\.js|
\.po|
\.mo|
swagger\.json
)
- id: mixed-line-ending - id: mixed-line-ending
args: [ '--fix=lf' ] args: [ '--fix=lf' ]
- id: fix-encoding-pragma - id: fix-encoding-pragma
@@ -25,15 +39,22 @@ repos:
rev: 2.4.0 rev: 2.4.0
hooks: hooks:
- id: editorconfig-checker - id: editorconfig-checker
exclude: ^(LICENSE|allianceauth\/static\/css\/themes\/bootstrap-locals.less|allianceauth\/eveonline\/swagger.json|(.*.po)|(.*.mo)) exclude: |
(?x)(
LICENSE|
allianceauth\/static\/allianceauth\/css\/themes\/bootstrap-locals.less|
\.po|
\.mo|
swagger\.json
)
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.31.0 rev: v2.34.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [ --py38-plus ] args: [ --py38-plus ]
- repo: https://github.com/asottile/setup-cfg-fmt - repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.0 rev: v1.20.1
hooks: hooks:
- id: setup-cfg-fmt - id: setup-cfg-fmt

View File

@@ -11,7 +11,7 @@ build:
apt_packages: apt_packages:
- redis - redis
tools: tools:
python: "3.10" python: "3.8"
# Build documentation in the docs/ directory with Sphinx # Build documentation in the docs/ directory with Sphinx
sphinx: sphinx:

View File

@@ -1,7 +1,7 @@
# This will make sure the app is always imported when # This will make sure the app is always imported when
# Django starts so that shared_task will use this app. # Django starts so that shared_task will use this app.
__version__ = '3.0.0a5' __version__ = '3.3.0'
__title__ = 'Alliance Auth' __title__ = 'Alliance Auth'
__url__ = 'https://gitlab.com/allianceauth/allianceauth' __url__ = 'https://gitlab.com/allianceauth/allianceauth'
NAME = f'{__title__} v{__version__}' NAME = f'{__title__} v{__version__}'

View File

@@ -8,13 +8,13 @@ from uuid import uuid4
class AnalyticsIdentifier(models.Model): class AnalyticsIdentifier(models.Model):
identifier = models.UUIDField(default=uuid4, identifier = models.UUIDField(default=uuid4,
editable=False) editable=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.pk and AnalyticsIdentifier.objects.exists(): if not self.pk and AnalyticsIdentifier.objects.exists():
# Force a single object # Force a single object
raise ValidationError('There is can be only one \ raise ValidationError('There is can be only one \
AnalyticsIdentifier instance') AnalyticsIdentifier instance')
self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1 self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

@@ -3,16 +3,17 @@ from urllib.parse import parse_qs
import requests_mock import requests_mock
from django.test import TestCase, override_settings from django.test import override_settings
from allianceauth.analytics.tasks import ANALYTICS_URL from allianceauth.analytics.tasks import ANALYTICS_URL
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock() @requests_mock.mock()
class TestAnalyticsForViews(TestCase): class TestAnalyticsForViews(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False) @override_settings(ANALYTICS_DISABLED=False)
def test_should_run_analytics(self, requests_mocker): def test_should_run_analytics(self, requests_mocker):
# given # given
@@ -40,7 +41,7 @@ class TestAnalyticsForViews(TestCase):
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock() @requests_mock.mock()
class TestAnalyticsForTasks(TestCase): class TestAnalyticsForTasks(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False) @override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character") @patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_successful_task( def test_should_run_analytics_for_successful_task(

View File

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

View File

@@ -322,7 +322,7 @@ class UserAdmin(BaseUserAdmin):
class Media: class Media:
css = { css = {
"all": ("authentication/css/admin.css",) "all": ("allianceauth/authentication/css/admin.css",)
} }
def get_queryset(self, request): def get_queryset(self, request):
@@ -542,7 +542,7 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
class Media: class Media:
css = { css = {
"all": ("authentication/css/admin.css",) "all": ("allianceauth/authentication/css/admin.css",)
} }
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):

View File

@@ -2,6 +2,7 @@ import logging
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import User, Permission
from django.contrib import messages
from .models import UserProfile, CharacterOwnership, OwnershipRecord from .models import UserProfile, CharacterOwnership, OwnershipRecord
@@ -37,7 +38,13 @@ class StateBackend(ModelBackend):
ownership = CharacterOwnership.objects.get(character__character_id=token.character_id) ownership = CharacterOwnership.objects.get(character__character_id=token.character_id)
if ownership.owner_hash == token.character_owner_hash: if ownership.owner_hash == token.character_owner_hash:
logger.debug(f'Authenticating {ownership.user} by ownership of character {token.character_name}') logger.debug(f'Authenticating {ownership.user} by ownership of character {token.character_name}')
return ownership.user if ownership.user.profile.main_character:
if ownership.user.profile.main_character.character_id == token.character_id:
return ownership.user
else: ## this is an alt, enforce main only.
if request:
messages.error("Unable to authenticate with this Character, Please log in with the main character associated with this account.")
return None
else: else:
logger.debug(f'{token.character_name} has changed ownership. Creating new user account.') logger.debug(f'{token.character_name} has changed ownership. Creating new user account.')
ownership.delete() ownership.delete()
@@ -57,13 +64,20 @@ class StateBackend(ModelBackend):
if records.exists(): if records.exists():
# we've seen this character owner before. Re-attach to their old user account # we've seen this character owner before. Re-attach to their old user account
user = records[0].user user = records[0].user
if user.profile.main_character:
if ownership.user.profile.main_character.character_id != token.character_id:
## this is an alt, enforce main only due to trust issues in SSO.
if request:
messages.error("Unable to authenticate with this Character, Please log in with the main character associated with this account. Then add this character from the dashboard.")
return None
token.user = user token.user = user
co = CharacterOwnership.objects.create_by_token(token) co = CharacterOwnership.objects.create_by_token(token)
logger.debug(f'Authenticating {user} by matching owner hash record of character {co.character}') logger.debug(f'Authenticating {user} by matching owner hash record of character {co.character}')
if not user.profile.main_character:
# set this as their main by default if they have none # set this as their main by default as they have none
user.profile.main_character = co.character user.profile.main_character = co.character
user.profile.save() user.profile.save()
return user return user
logger.debug(f'Unable to authenticate character {token.character_name}. Creating new user.') logger.debug(f'Unable to authenticate character {token.character_name}. Creating new user.')
return self.create_user(token) return self.create_user(token)

View File

@@ -18,13 +18,13 @@ class State(models.Model):
priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.") priority = models.IntegerField(unique=True, help_text="Users get assigned the state with the highest priority available to them.")
member_characters = models.ManyToManyField(EveCharacter, blank=True, member_characters = models.ManyToManyField(EveCharacter, blank=True,
help_text="Characters to which this state is available.") help_text="Characters to which this state is available.")
member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True, member_corporations = models.ManyToManyField(EveCorporationInfo, blank=True,
help_text="Corporations to whose members this state is available.") help_text="Corporations to whose members this state is available.")
member_alliances = models.ManyToManyField(EveAllianceInfo, blank=True, member_alliances = models.ManyToManyField(EveAllianceInfo, blank=True,
help_text="Alliances to whose members this state is available.") help_text="Alliances to whose members this state is available.")
member_factions = models.ManyToManyField(EveFactionInfo, blank=True, member_factions = models.ManyToManyField(EveFactionInfo, blank=True,
help_text="Factions to whose members this state is available.") help_text="Factions to whose members this state is available.")
public = models.BooleanField(default=False, help_text="Make this state available to any character.") public = models.BooleanField(default=False, help_text="Make this state available to any character.")
objects = StateManager() objects = StateManager()

View File

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

View File

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

View File

@@ -1,27 +1,62 @@
import datetime as dt import datetime as dt
from typing import Optional, List import logging
from typing import List, Optional
from redis import Redis
from pytz import utc from pytz import utc
from redis import Redis, RedisError
from django_redis import get_redis_connection from allianceauth.utils.cache import get_redis_client
logger = logging.getLogger(__name__)
class _RedisStub:
"""Stub of a Redis client.
It's purpose is to prevent EventSeries objects from trying to access Redis
when it is not available. e.g. when the Sphinx docs are rendered by readthedocs.org.
"""
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: class EventSeries:
"""API for recording and analysing a series of events.""" """API for recording and analyzing a series of events."""
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES" _ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Redis = None) -> None: def __init__(self, key_id: str, redis: Redis = None) -> None:
self._redis = get_redis_connection("default") if not redis else redis self._redis = get_redis_client() if not redis else redis
if not isinstance(self._redis, Redis): try:
raise TypeError( if not self._redis.ping():
"This class requires a Redis client, but none was provided " raise RuntimeError()
"and the default Django cache backend is not Redis either." except (AttributeError, RedisError, RuntimeError):
logger.exception(
"Failed to establish a connection with Redis. "
"This EventSeries object is disabled.",
) )
self._redis = _RedisStub()
self._key_id = str(key_id) self._key_id = str(key_id)
self.clear() 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)
@property @property
def _key_counter(self): def _key_counter(self):
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER" return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"

View File

@@ -1,13 +1,48 @@
import datetime as dt import datetime as dt
from unittest.mock import patch
from pytz import utc from pytz import utc
from redis import RedisError
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import EventSeries from allianceauth.authentication.task_statistics.event_series import (
EventSeries,
_RedisStub,
)
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
class TestEventSeries(TestCase): 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): def test_should_add_event(self):
# given # given
events = EventSeries("dummy") events = EventSeries("dummy")

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Dashboard" %}{% endblock %} {% block page_title %}{% translate "Dashboard" %}{% endblock %}
@@ -15,9 +14,9 @@
<div class="panel panel-primary" style="height:100%"> <div class="panel panel-primary" style="height:100%">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">
{% blocktrans with state=request.user.profile.state %} {% blocktranslate with state=request.user.profile.state %}
Main Character (State: {{ state }}) Main Character (State: {{ state }})
{% endblocktrans %} {% endblocktranslate %}
</h3> </h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@@ -28,7 +27,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar"src="{{ main.portrait_url_128 }}"> <img class="ra-avatar" src="{{ main.portrait_url_128 }}" alt="{{ main.character_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -40,7 +39,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar"src="{{ main.corporation_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.corporation_logo_url_128 }}" alt="{{ main.corporation_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -53,7 +52,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar"src="{{ main.alliance_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.alliance_logo_url_128 }}" alt="{{ main.alliance_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -64,7 +63,7 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar"src="{{ main.faction_logo_url_128 }}"> <img class="ra-avatar" src="{{ main.faction_logo_url_128 }}" alt="{{ main.faction_name }}">
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -76,13 +75,13 @@
</div> </div>
<div class="table visible-xs-block"> <div class="table visible-xs-block">
<p> <p>
<img class="ra-avatar" src="{{ main.portrait_url_64 }}"> <img class="ra-avatar" src="{{ main.portrait_url_64 }}" alt="{{ main.corporation_name }}">
<img class="ra-avatar" src="{{ main.corporation_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.corporation_logo_url_64 }}" alt="{{ main.corporation_name }}">
{% if main.alliance_id %} {% if main.alliance_id %}
<img class="ra-avatar" src="{{ main.alliance_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.alliance_logo_url_64 }}" alt="{{ main.alliance_name }}">
{% endif %} {% endif %}
{% if main.faction_id %} {% if main.faction_id %}
<img class="ra-avatar" src="{{ main.faction_logo_url_64 }}"> <img class="ra-avatar" src="{{ main.faction_logo_url_64 }}" alt="{{ main.faction_name }}">
{% endif %} {% endif %}
</p> </p>
<p> <p>
@@ -104,13 +103,17 @@
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="row"> <div class="row">
<div class="col-sm-6 button-wrapper"> <div class="col-sm-6">
<a href="{% url 'authentication:add_character' %}" class="btn btn-block btn-info" <p>
title="Add Character">{% translate 'Add Character' %}</a> <a href="{% url 'authentication:add_character' %}" class="btn btn-block btn-info"
title="Add Character">{% translate 'Add Character' %}</a>
</p>
</div> </div>
<div class="col-sm-6 button-wrapper"> <div class="col-sm-6">
<a href="{% url 'authentication:change_main_character' %}" class="btn btn-block btn-info" <p>
title="Change Main Character">{% translate "Change Main" %}</a> <a href="{% url 'authentication:change_main_character' %}" class="btn btn-block btn-info"
title="Change Main Character">{% translate "Change Main" %}</a>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -122,7 +125,7 @@
<h3 class="panel-title">{% translate "Group Memberships" %}</h3> <h3 class="panel-title">{% translate "Group Memberships" %}</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div style="height: 240px;overflow:-moz-scrollbars-vertical;overflow-y:auto;"> <div style="height: 240px;overflow-y:auto;">
<table class="table table-aa"> <table class="table table-aa">
{% for group in groups %} {% for group in groups %}
<tr> <tr>
@@ -155,11 +158,12 @@
<tbody> <tbody>
{% for char in characters %} {% for char in characters %}
<tr> <tr>
<td class="text-center"><img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}"> <td class="text-center">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center">{{ char.character_name }}</td> <td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td> <td class="text-center">{{ char.corporation_name }}</td>
<td class="text-center">{{ char.alliance_name }}</td> <td class="text-center">{{ char.alliance_name|default:"" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -169,7 +173,7 @@
{% for char in characters %} {% for char in characters %}
<tr> <tr>
<td class="text-center" style="vertical-align: middle"> <td class="text-center" style="vertical-align: middle">
<img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}"> <img class="ra-avatar img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center" style="vertical-align: middle; width: 100%"> <td class="text-center" style="vertical-align: middle; width: 100%">
<strong>{{ char.character_name }}</strong><br> <strong>{{ char.character_name }}</strong><br>

View File

@@ -0,0 +1,62 @@
{% extends "allianceauth/base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Dashboard" %}{% endblock %}
{% block content %}
<h1 class="page-header text-center">{% translate "Token Management" %}</h1>
<div class="col-sm-12">
<table class="table table-aa" id="table_tokens" style="width:100%">
<thead>
<tr>
<th>{% translate "Scopes" %}</th>
<th class="text-right">{% translate "Actions" %}</th>
<th>{% translate "Character" %}</th>
</tr>
</thead>
<tbody>
{% for t in tokens %}
<tr>
<td styl="white-space:initial;">{% for s in t.scopes.all %}<span class="label label-default">{{s.name}}</span> {% endfor %}</td>
<td nowrap class="text-right"><a href="{% url 'authentication:token_delete' t.id %}" class="btn btn-danger"><i class="fas fa-trash"></i></a> <a href="{% url 'authentication:token_refresh' t.id %}" class="btn btn-success"><i class="fas fa-sync-alt"></i></a></td>
<td>{{t.character_name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% translate "This page is a best attempt, but backups or database logs can still contain your tokens. Always revoke tokens on https://community.eveonline.com/support/third-party-applications/ where possible."|urlize %}
</div>
{% endblock %}
{% block extra_javascript %}
{% include 'bundles/datatables-js.html' %}
{% endblock %}
{% block extra_css %}
{% include 'bundles/datatables-css.html' %}
{% endblock %}
{% block extra_script %}
$(document).ready(function(){
let grp = 2;
var table = $('#table_tokens').DataTable({
"columnDefs": [{ orderable: false, targets: [0,1] },{ "visible": false, "targets": grp }],
"order": [[grp, 'asc']],
"drawCallback": function (settings) {
var api = this.api();
var rows = api.rows({ page: 'current' }).nodes();
var last = null;
api.column(grp, { page: 'current' })
.data()
.each(function (group, i) {
if (last !== group) {
$(rows).eq(i).before('<tr class="info"><td colspan="3">' + group + '</td></tr>');
last = group;
}
});
},
"stateSave": true,
});
});
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% load static %} {% load static %}
<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -7,7 +8,7 @@
<meta name="description" content=""> <meta name="description" content="">
<meta name="author" content=""> <meta name="author" content="">
<meta property="og:title" content="{{ SITE_NAME }}"> <meta property="og:title" content="{{ SITE_NAME }}">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'icons/apple-touch-icon.png' %}"> <meta property="og:image" content="{{ SITE_URL }}{% static 'allianceauth/icons/apple-touch-icon.png' %}">
<meta property="og:description" content="Alliance Auth - An auth system for EVE Online to help in-game organizations manage online service access."> <meta property="og:description" content="Alliance Auth - An auth system for EVE Online to help in-game organizations manage online service access.">
{% include 'allianceauth/icons.html' %} {% include 'allianceauth/icons.html' %}
@@ -21,7 +22,7 @@
<style> <style>
body { body {
background: url('{% static 'authentication/img/background.jpg' %}') no-repeat center center fixed; background: url('{% static 'allianceauth/authentication/img/background.jpg' %}') no-repeat center center fixed;
-webkit-background-size: cover; -webkit-background-size: cover;
-moz-background-size: cover; -moz-background-size: cover;
-o-background-size: cover; -o-background-size: cover;
@@ -31,6 +32,7 @@
.panel-transparent { .panel-transparent {
background: rgba(48, 48, 48, 0.7); background: rgba(48, 48, 48, 0.7);
color: #ffffff; color: #ffffff;
padding-bottom: 21px;
} }
.panel-body { .panel-body {
@@ -47,7 +49,7 @@
</style> </style>
</head> </head>
<body> <body>
<div class="container" style="margin-top:150px"> <div class="container" style="margin-top:150px;">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -6,7 +6,7 @@
{% get_language_info_list for LANGUAGES as languages %} {% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %} {% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected="selected"{% endif %}> <option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected="selected"{% endif %}>
{{ language.name_local }} ({{ language.code }}) {{ language.name_local|capfirst }} ({{ language.code }})
</option> </option>
{% endfor %} {% endfor %}
</select> </select>

View File

@@ -7,6 +7,6 @@
{% block middle_box_content %} {% block middle_box_content %}
<a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next}}{%endif%}"> <a href="{% url 'auth_sso_login' %}{% if request.GET.next %}?next={{request.GET.next}}{%endif%}">
<img class="img-responsive center-block" src="{% static 'img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}" border=0> <img class="img-responsive center-block" src="{% static 'allianceauth/authentication/img/sso/EVE_SSO_Login_Buttons_Large_Black.png' %}" alt="{% translate 'Login with Eve SSO' %}">
</a> </a>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,7 @@
{% extends 'public/base.html' %} {% extends 'public/base.html' %}
{% load static %}
{% load i18n %}
{% block content %} {% block content %}
<div class="col-md-4 col-md-offset-4"> <div class="col-md-4 col-md-offset-4">
{% if messages %} {% if messages %}
@@ -7,6 +9,7 @@
<div class="alert alert-{{ message.level_tag}}">{{ message }}</div> <div class="alert alert-{{ message.level_tag}}">{{ message }}</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<div class="panel panel-default panel-transparent"> <div class="panel panel-default panel-transparent">
<div class="panel-body"> <div class="panel-body">
<div class="col-md-12"> <div class="col-md-12">
@@ -14,10 +17,25 @@
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
{% include 'public/lang_select.html' %} {% include 'public/lang_select.html' %}
<p class="text-center" style="margin-top: 2rem;">
{% translate "For information on SSO, ESI and security read the CCP Dev Blog" %}<br>
<a href="https://www.eveonline.com/article/introducing-esi" target="_blank" rel="noopener noreferrer">
{% translate "Introducing ESI - A New API For Eve Online" %}
</a>
</p>
<p class="text-center">
<a href="https://community.eveonline.com/support/third-party-applications/" target="_blank" rel="noopener noreferrer">
{% translate "Manage ESI Applications" %}
</a>
</p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_include %} {% block extra_include %}
{% include 'bundles/bootstrap-js.html' %} {% include 'bundles/bootstrap-js.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends 'public/base.html' %} {% extends 'public/base.html' %}
{% load static %}
{% load bootstrap %} {% load bootstrap %}
{% load i18n %} {% load i18n %}

View File

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

View File

@@ -1,14 +0,0 @@
{% extends 'public/middle_box.html' %}
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block page_title %}{% translate "Register" %}{% endblock %}
{% block middle_box_content %}
<form class="form-signin" role="form" action="" method="POST">
{% csrf_token %}
{{ form|bootstrap }}
<br>
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Submit" %}</button>
<br>
</form>
{% endblock %}

View File

@@ -116,10 +116,17 @@ class TestAuthenticate(TestCase):
user = StateBackend().authenticate(token=t) user = StateBackend().authenticate(token=t)
self.assertEqual(user, self.user) self.assertEqual(user, self.user)
""" Alt Login disabled
def test_authenticate_alt_character(self): def test_authenticate_alt_character(self):
t = Token(character_id=self.alt_character.character_id, character_owner_hash='2') t = Token(character_id=self.alt_character.character_id, character_owner_hash='2')
user = StateBackend().authenticate(token=t) user = StateBackend().authenticate(token=t)
self.assertEqual(user, self.user) self.assertEqual(user, self.user)
"""
def test_authenticate_alt_character_fail(self):
t = Token(character_id=self.alt_character.character_id, character_owner_hash='2')
user = StateBackend().authenticate(token=t)
self.assertEqual(user, None)
def test_authenticate_unclaimed_character(self): def test_authenticate_unclaimed_character(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='3') t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='3')
@@ -128,6 +135,7 @@ class TestAuthenticate(TestCase):
self.assertEqual(user.username, 'Unclaimed_Character') self.assertEqual(user.username, 'Unclaimed_Character')
self.assertEqual(user.profile.main_character, self.unclaimed_character) self.assertEqual(user.profile.main_character, self.unclaimed_character)
""" Alt Login disabled
def test_authenticate_character_record(self): def test_authenticate_character_record(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='4') t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='4')
OwnershipRecord.objects.create(user=self.old_user, character=self.unclaimed_character, owner_hash='4') OwnershipRecord.objects.create(user=self.old_user, character=self.unclaimed_character, owner_hash='4')
@@ -135,6 +143,15 @@ class TestAuthenticate(TestCase):
self.assertEqual(user, self.old_user) self.assertEqual(user, self.old_user)
self.assertTrue(CharacterOwnership.objects.filter(owner_hash='4', user=self.old_user).exists()) self.assertTrue(CharacterOwnership.objects.filter(owner_hash='4', user=self.old_user).exists())
self.assertTrue(user.profile.main_character) self.assertTrue(user.profile.main_character)
"""
def test_authenticate_character_record_fails(self):
t = Token(character_id=self.unclaimed_character.character_id, character_name=self.unclaimed_character.character_name, character_owner_hash='4')
OwnershipRecord.objects.create(user=self.old_user, character=self.unclaimed_character, owner_hash='4')
user = StateBackend().authenticate(token=t)
self.assertEqual(user, self.old_user)
self.assertTrue(CharacterOwnership.objects.filter(owner_hash='4', user=self.old_user).exists())
self.assertTrue(user.profile.main_character)
def test_iterate_username(self): def test_iterate_username(self):
t = Token(character_id=self.unclaimed_character.character_id, t = Token(character_id=self.unclaimed_character.character_id,

View File

@@ -22,5 +22,20 @@ urlpatterns = [
views.add_character, views.add_character,
name='add_character' name='add_character'
), ),
path(
'account/tokens/manage/',
views.token_management,
name='token_management'
),
path(
'account/tokens/delete/<int:token_id>',
views.token_delete,
name='token_delete'
),
path(
'account/tokens/refresh/<int:token_id>',
views.token_refresh,
name='token_refresh'
),
path('dashboard/', views.dashboard, name='dashboard'), path('dashboard/', views.dashboard, name='dashboard'),
] ]

View File

@@ -61,6 +61,44 @@ def dashboard(request):
} }
return render(request, 'authentication/dashboard.html', context) return render(request, 'authentication/dashboard.html', context)
@login_required
def token_management(request):
tokens = request.user.token_set.all()
context = {
'tokens': tokens
}
return render(request, 'authentication/tokens.html', context)
@login_required
def token_delete(request, token_id=None):
try:
token = Token.objects.get(id=token_id)
if request.user == token.user:
token.delete()
messages.success(request, "Token Deleted.")
else:
messages.error(request, "This token does not belong to you.")
except Token.DoesNotExist:
messages.warning(request, "Token does not exist")
return redirect('authentication:token_management')
@login_required
def token_refresh(request, token_id=None):
try:
token = Token.objects.get(id=token_id)
if request.user == token.user:
try:
token.refresh()
messages.success(request, "Token refreshed.")
except Exception as e:
messages.warning(request, f"Failed to refresh token. {e}")
else:
messages.error(request, "This token does not belong to you.")
except Token.DoesNotExist:
messages.warning(request, "Token does not exist")
return redirect('authentication:token_management')
@login_required @login_required
@token_required(scopes=settings.LOGIN_TOKEN_SCOPES) @token_required(scopes=settings.LOGIN_TOKEN_SCOPES)

View File

@@ -5,5 +5,6 @@ from .views import NightModeRedirectView
def auth_settings(request): def auth_settings(request):
return { return {
'SITE_NAME': settings.SITE_NAME, 'SITE_NAME': settings.SITE_NAME,
'SITE_URL': settings.SITE_URL,
'NIGHT_MODE': NightModeRedirectView.night_mode_state(request), 'NIGHT_MODE': NightModeRedirectView.night_mode_state(request),
} }

View File

@@ -8,11 +8,11 @@
<table class="table"> <table class="table">
<tr> <tr>
<td class="text-center col-lg-6{% if corpstats.corp.alliance %}{% else %}col-lg-offset-3{% endif %}"> <td class="text-center col-lg-6{% if corpstats.corp.alliance %}{% else %}col-lg-offset-3{% endif %}">
<img class="ra-avatar" src="{{ corpstats.corp.logo_url_64 }}"> <img class="ra-avatar" src="{{ corpstats.corp.logo_url_64 }}" alt="{{ corpstats.corp.corporation_name }}">
</td> </td>
{% if corpstats.corp.alliance %} {% if corpstats.corp.alliance %}
<td class="text-center col-lg-6"> <td class="text-center col-lg-6">
<img class="ra-avatar" src="{{ corpstats.corp.alliance.logo_url_64 }}"> <img class="ra-avatar" src="{{ corpstats.corp.alliance.logo_url_64 }}" alt="{{ corpstats.corp.alliance.alliance_name }}">
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@@ -59,7 +59,7 @@
<tr> <tr>
<td class="text-center" style="vertical-align:middle"> <td class="text-center" style="vertical-align:middle">
<div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;"> <div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;">
<img src="{{ main.main.portrait_url_64 }}" class="img-circle"> <img src="{{ main.main.portrait_url_64 }}" class="img-circle" alt="{{ main.main }}">
<div class="caption text-center"> <div class="caption text-center">
{{ main.main }} {{ main.main }}
</div> </div>
@@ -80,7 +80,7 @@
<tr> <tr>
<td class="text-center" style="width:5%"> <td class="text-center" style="width:5%">
<div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;"> <div class="thumbnail" style="border: 0 none; box-shadow: none; background: transparent;">
<img src="{{ alt.portrait_url_32 }}" class="img-circle"> <img src="{{ alt.portrait_url_32 }}" class="img-circle" alt="{{ alt.character_name }}">
</div> </div>
</td> </td>
<td class="text-center" style="width:30%">{{ alt.character_name }}</td> <td class="text-center" style="width:30%">{{ alt.character_name }}</td>
@@ -119,7 +119,7 @@
<tbody> <tbody>
{% for member in members %} {% for member in members %}
<tr> <tr>
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member }}"></td>
<td class="text-center">{{ member }}</td> <td class="text-center">{{ member }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a>
@@ -131,7 +131,7 @@
{% endfor %} {% endfor %}
{% for member in unregistered %} {% for member in unregistered %}
<tr class="danger"> <tr class="danger">
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td class="text-center">{{ member.character_name }}</td> <td class="text-center">{{ member.character_name }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a>
@@ -160,7 +160,7 @@
<tbody> <tbody>
{% for member in unregistered %} {% for member in unregistered %}
<tr class="danger"> <tr class="danger">
<td><img src="{{ member.portrait_url }}" class="img-circle"></td> <td><img src="{{ member.portrait_url }}" class="img-circle" alt="{{ member.character_name }}"></td>
<td class="text-center">{{ member.character_name }}</td> <td class="text-center">{{ member.character_name }}</td>
<td class="text-center"> <td class="text-center">
<a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank"> <a href="https://zkillboard.com/character/{{ member.character_id }}/" class="label label-danger" target="_blank">

View File

@@ -21,7 +21,7 @@
<tbody> <tbody>
{% for result in results %} {% for result in results %}
<tr {% if not result.1.registered %}class="danger"{% endif %}> <tr {% if not result.1.registered %}class="danger"{% endif %}>
<td class="text-center"><img src="{{ result.1.portrait_url }}" class="img-circle"></td> <td class="text-center"><img src="{{ result.1.portrait_url }}" class="img-circle" alt="{{ result.1.character_name }}"></td>
<td class="text-center">{{ result.1.character_name }}</td> <td class="text-center">{{ result.1.character_name }}</td>
<td class="text-center">{{ result.0.corp.corporation_name }}</td> <td class="text-center">{{ result.0.corp.corporation_name }}</td>
<td class="text-center"><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a></td> <td class="text-center"><a href="https://zkillboard.com/character/{{ result.1.character_id }}/" class="label label-danger" target="_blank">{% translate "Killboard" %}</a></td>

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.0.7 on 2022-08-14 16:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eveonline', '0016_character_names_are_not_unique'),
]
operations = [
migrations.AlterField(
model_name='eveallianceinfo',
name='alliance_name',
field=models.CharField(max_length=254, db_index=True),
),
migrations.AlterField(
model_name='evecorporationinfo',
name='corporation_name',
field=models.CharField(max_length=254, db_index=True),
),
]

View File

@@ -71,7 +71,7 @@ class EveAllianceInfo(models.Model):
"""An alliance in Eve Online.""" """An alliance in Eve Online."""
alliance_id = models.PositiveIntegerField(unique=True) alliance_id = models.PositiveIntegerField(unique=True)
alliance_name = models.CharField(max_length=254, unique=True) alliance_name = models.CharField(max_length=254, db_index=True)
alliance_ticker = models.CharField(max_length=254) alliance_ticker = models.CharField(max_length=254)
executor_corp_id = models.PositiveIntegerField() executor_corp_id = models.PositiveIntegerField()
@@ -139,7 +139,7 @@ class EveCorporationInfo(models.Model):
"""A corporation in Eve Online.""" """A corporation in Eve Online."""
corporation_id = models.PositiveIntegerField(unique=True) corporation_id = models.PositiveIntegerField(unique=True)
corporation_name = models.CharField(max_length=254, unique=True) corporation_name = models.CharField(max_length=254, db_index=True)
corporation_ticker = models.CharField(max_length=254) corporation_ticker = models.CharField(max_length=254)
member_count = models.IntegerField() member_count = models.IntegerField()
ceo_id = models.PositiveIntegerField(blank=True, null=True, default=None) ceo_id = models.PositiveIntegerField(blank=True, null=True, default=None)

View File

@@ -12,7 +12,7 @@
<div class="panel-heading">{{ character_name }}</div> <div class="panel-heading">{{ character_name }}</div>
<div class="panel-body"> <div class="panel-body">
<div class="col-lg-2 col-sm-2"> <div class="col-lg-2 col-sm-2">
<img class="ra-avatar img-responsive" src="{{ character_portrait_url }}"> <img class="ra-avatar img-responsive" src="{{ character_portrait_url }}" alt="{{ character_name }}">
</div> </div>
<div class="col-lg-10 col-sm-2"> <div class="col-lg-10 col-sm-2">
<div class="alert alert-danger" role="alert">{% translate "Character not registered!" %}</div> <div class="alert alert-danger" role="alert">{% translate "Character not registered!" %}</div>

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %} {% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Create Fatlink" %}{% endblock page_title %} {% block page_title %}{% translate "Create Fatlink" %}{% endblock page_title %}

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %}
@@ -32,7 +30,7 @@
<td class="text-center">{{ fat.user }}</td> <td class="text-center">{{ fat.user }}</td>
<td class="text-center">{{ fat.character.character_name }}</td> <td class="text-center">{{ fat.character.character_name }}</td>
{% if fat.station != "No Station" %} {% if fat.station != "No Station" %}
<td class="text-center">{% blocktrans %}Docked in {% endblocktrans %}{{ fat.system }}</td> <td class="text-center">{% blocktranslate %}Docked in {% endblocktranslate %}{{ fat.system }}</td>
{% else %} {% else %}
<td class="text-center">{{ fat.system }}</td> <td class="text-center">{{ fat.system }}</td>
{% endif %} {% endif %}

View File

@@ -1,13 +1,11 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %}
{% block content %} {% block content %}
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header text-center">{% blocktrans %}Participation data statistics for {{ month }}, {{ year }}{% endblocktrans %} <h1 class="page-header text-center">{% blocktranslate %}Participation data statistics for {{ month }}, {{ year }}{% endblocktranslate %}
{% if char_id %} {% if char_id %}
<div class="text-right"> <div class="text-right">
<a href="{% url 'fatlink:user_statistics_month' char_id previous_month|date:'Y' previous_month|date:'m' %}" class="btn btn-info">{% translate "Previous month" %}</a> <a href="{% url 'fatlink:user_statistics_month' char_id previous_month|date:'Y' previous_month|date:'m' %}" class="btn btn-info">{% translate "Previous month" %}</a>
@@ -16,11 +14,11 @@
{% endif %} {% endif %}
</h1> </h1>
<h2> <h2>
{% blocktrans count links=n_fats trimmed %} {% blocktranslate count links=n_fats trimmed %}
{{ user }} has collected one link this month. {{ user }} has collected one link this month.
{% plural %} {% plural %}
{{ user }} has collected {{ links }} links this month. {{ user }} has collected {{ links }} links this month.
{% endblocktrans %} {% endblocktranslate %}
</h2> </h2>
<table class="table table-responsive"> <table class="table table-responsive">
<tr> <tr>
@@ -36,11 +34,11 @@
</table> </table>
{% if created_fats %} {% if created_fats %}
<h2> <h2>
{% blocktrans count links=n_created_fats trimmed %} {% blocktranslate count links=n_created_fats trimmed %}
{{ user }} has created one link this month. {{ user }} has created one link this month.
{% plural %} {% plural %}
{{ user }} has created {{ links }} links this month. {{ user }} has created {{ links }} links this month.
{% endblocktrans %} {% endblocktranslate %}
</h2> </h2>
{% if created_fats %} {% if created_fats %}
<table class="table"> <table class="table">

View File

@@ -1,13 +1,11 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Personal fatlink statistics" %}{% endblock page_title %}
{% block content %} {% block content %}
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header text-center">{% blocktrans %}Participation data statistics for {{ year }}{% endblocktrans %} <h1 class="page-header text-center">{% blocktranslate %}Participation data statistics for {{ year }}{% endblocktranslate %}
<div class="text-right"> <div class="text-right">
<a href="{% url 'fatlink:personal_statistics_year' previous_year %}" class="btn btn-info">{% translate "Previous year" %}</a> <a href="{% url 'fatlink:personal_statistics_year' previous_year %}" class="btn btn-info">{% translate "Previous year" %}</a>
{% if next_year %} {% if next_year %}

View File

@@ -1,13 +1,11 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink Corp Statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink Corp Statistics" %}{% endblock page_title %}
{% block content %} {% block content %}
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header text-center">{% blocktrans %}Participation data statistics for {{ month }}, {{ year }}{% endblocktrans %} <h1 class="page-header text-center">{% blocktranslate %}Participation data statistics for {{ month }}, {{ year }}{% endblocktranslate %}
<div class="text-right"> <div class="text-right">
<a href="{% url 'fatlink:statistics_corp_month' corpid previous_month|date:"Y" previous_month|date:"m" %}" class="btn btn-info">{% translate "Previous month" %}</a> <a href="{% url 'fatlink:statistics_corp_month' corpid previous_month|date:"Y" previous_month|date:"m" %}" class="btn btn-info">{% translate "Previous month" %}</a>
{% if next_month %} {% if next_month %}
@@ -29,7 +27,7 @@
{% for memberStat in fatStats %} {% for memberStat in fatStats %}
<tr> <tr>
<td> <td>
<img src="{{ memberStat.mainchar.portrait_url_32 }}" class="ra-avatar img-responsive"> <img src="{{ memberStat.mainchar.portrait_url_32 }}" class="ra-avatar img-responsive" alt="{{ memberStat.mainchar.character_name }}">
</td> </td>
<td class="text-center">{{ memberStat.mainchar.character_name }}</td> <td class="text-center">{{ memberStat.mainchar.character_name }}</td>
<td class="text-center">{{ memberStat.n_chars }}</td> <td class="text-center">{{ memberStat.n_chars }}</td>

View File

@@ -1,13 +1,11 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink statistics" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink statistics" %}{% endblock page_title %}
{% block content %} {% block content %}
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header text-center">{% blocktrans %}Participation data statistics for {{ month }}, {{ year }}{% endblocktrans %} <h1 class="page-header text-center">{% blocktranslate %}Participation data statistics for {{ month }}, {{ year }}{% endblocktranslate %}
<div class="text-right"> <div class="text-right">
<a href="{% url 'fatlink:statistics_month' previous_month|date:"Y" previous_month|date:"m" %}" class="btn btn-info">{% translate "Previous month" %}</a> <a href="{% url 'fatlink:statistics_month' previous_month|date:"Y" previous_month|date:"m" %}" class="btn btn-info">{% translate "Previous month" %}</a>
{% if next_month %} {% if next_month %}
@@ -30,9 +28,9 @@
{% for corpStat in fatStats %} {% for corpStat in fatStats %}
<tr> <tr>
<td> <td>
<img src="{{ corpStat.corp.logo_url_32 }}" class="ra-avatar img-responsive"> <img src="{{ corpStat.corp.logo_url_32 }}" class="ra-avatar img-responsive" alt="{{ corpStat.corp.corporation_name }}">
</td> </td>
<td class="text-center"><a href="{% url 'fatlink:statistics_corp' corpStat.corp.corporation_id %}">[{{ corpStat.corp.corporation_ticker }}]</td> <td class="text-center"><a href="{% url 'fatlink:statistics_corp' corpStat.corp.corporation_id %}">[{{ corpStat.corp.corporation_ticker }}]</a></td>
<td class="text-center">{{ corpStat.corp.corporation_name }}</td> <td class="text-center">{{ corpStat.corp.corporation_name }}</td>
<td class="text-center">{{ corpStat.corp.member_count }}</td> <td class="text-center">{{ corpStat.corp.member_count }}</td>
<td class="text-center">{{ corpStat.n_fats }}</td> <td class="text-center">{{ corpStat.n_fats }}</td>

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %} {% block page_title %}{% translate "Fatlink view" %}{% endblock page_title %}
@@ -35,7 +33,7 @@
<td class="text-center">{{ fat.fatlink.fleet }}</td> <td class="text-center">{{ fat.fatlink.fleet }}</td>
<td class="text-center">{{ fat.character.character_name }}</td> <td class="text-center">{{ fat.character.character_name }}</td>
{% if fat.station != "No Station" %} {% if fat.station != "No Station" %}
<td class="text-center">{% blocktrans %}Docked in {% endblocktrans %}{{ fat.system }}</td> <td class="text-center">{% blocktranslate %}Docked in {% endblocktranslate %}{{ fat.system }}</td>
{% else %} {% else %}
<td class="text-center">{{ fat.system }}</td> <td class="text-center">{{ fat.system }}</td>
{% endif %} {% endif %}

View File

@@ -212,7 +212,14 @@ def fatlink_monthly_personal_statistics_view(request, year, month, char_id=None)
start_of_previous_month = first_day_of_previous_month(year, month) start_of_previous_month = first_day_of_previous_month(year, month)
if request.user.has_perm('auth.fleetactivitytracking_statistics') and char_id: if request.user.has_perm('auth.fleetactivitytracking_statistics') and char_id:
user = EveCharacter.objects.get(character_id=char_id).user try:
user = EveCharacter.objects.get(character_id=char_id).character_ownership.user
except EveCharacter.DoesNotExist:
messages.error(request, _('Character does not exist'))
return redirect('fatlink:view')
except AttributeError:
messages.error(request, _('User does not exist'))
return redirect('fatlink:view')
else: else:
user = request.user user = request.user
logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}") logger.debug(f"Personal monthly statistics view for user {user} called by {request.user}")
@@ -241,59 +248,82 @@ def fatlink_monthly_personal_statistics_view(request, year, month, char_id=None)
@login_required @login_required
@token_required( @token_required(
scopes=['esi-location.read_location.v1', 'esi-location.read_ship_type.v1', 'esi-universe.read_structures.v1']) scopes=[
'esi-location.read_location.v1',
'esi-location.read_ship_type.v1',
'esi-universe.read_structures.v1',
'esi-location.read_online.v1',
]
)
def click_fatlink_view(request, token, fat_hash=None): def click_fatlink_view(request, token, fat_hash=None):
fatlink = get_object_or_404(Fatlink, hash=fat_hash) c = token.get_esi_client(spec_file=SWAGGER_SPEC_PATH)
character = EveCharacter.objects.get_character_by_id(token.character_id)
character_online = c.Location.get_characters_character_id_online(
character_id=token.character_id
).result()
if (timezone.now() - fatlink.fatdatetime) < datetime.timedelta(seconds=(fatlink.duration * 60)): if character_online["online"] is True:
fatlink = get_object_or_404(Fatlink, hash=fat_hash)
character = EveCharacter.objects.get_character_by_id(token.character_id) if (timezone.now() - fatlink.fatdatetime) < datetime.timedelta(seconds=(fatlink.duration * 60)):
if character:
# get data
location = c.Location.get_characters_character_id_location(character_id=token.character_id).result()
ship = c.Location.get_characters_character_id_ship(character_id=token.character_id).result()
location['solar_system_name'] = \
c.Universe.get_universe_systems_system_id(system_id=location['solar_system_id']).result()['name']
if character: if location['station_id']:
# get data location['station_name'] = \
c = token.get_esi_client(spec_file=SWAGGER_SPEC_PATH) c.Universe.get_universe_stations_station_id(station_id=location['station_id']).result()['name']
location = c.Location.get_characters_character_id_location(character_id=token.character_id).result() elif location['structure_id']:
ship = c.Location.get_characters_character_id_ship(character_id=token.character_id).result() location['station_name'] = \
location['solar_system_name'] = \ c.Universe.get_universe_structures_structure_id(structure_id=location['structure_id']).result()[
c.Universe.get_universe_systems_system_id(system_id=location['solar_system_id']).result()['name'] 'name']
if location['station_id']: else:
location['station_name'] = \ location['station_name'] = "No Station"
c.Universe.get_universe_stations_station_id(station_id=location['station_id']).result()['name']
elif location['structure_id']: ship['ship_type_name'] = provider.get_itemtype(ship['ship_type_id']).name
location['station_name'] = \
c.Universe.get_universe_structures_structure_id(structure_id=location['structure_id']).result()[ fat = Fat()
'name'] fat.system = location['solar_system_name']
fat.station = location['station_name']
fat.shiptype = ship['ship_type_name']
fat.fatlink = fatlink
fat.character = character
fat.user = request.user
try:
fat.full_clean()
fat.save()
messages.success(request, _('Fleet participation registered.'))
except ValidationError as e:
err_messages = []
for errorname, message in e.message_dict.items():
err_messages.append(message[0])
messages.error(request, ' '.join(err_messages))
else: else:
location['station_name'] = "No Station" context = {
ship['ship_type_name'] = provider.get_itemtype(ship['ship_type_id']).name 'character_id': token.character_id,
'character_name': token.character_name,
'character_portrait_url': EveCharacter.generic_portrait_url(
token.character_id, 128
),
}
fat = Fat() return render(request, 'fleetactivitytracking/characternotexisting.html', context=context)
fat.system = location['solar_system_name']
fat.station = location['station_name']
fat.shiptype = ship['ship_type_name']
fat.fatlink = fatlink
fat.character = character
fat.user = request.user
try:
fat.full_clean()
fat.save()
messages.success(request, _('Fleet participation registered.'))
except ValidationError as e:
err_messages = []
for errorname, message in e.message_dict.items():
err_messages.append(message[0])
messages.error(request, ' '.join(err_messages))
else: else:
context = { messages.error(request, _('FAT link has expired.'))
'character_id': token.character_id,
'character_name': token.character_name,
'character_portrait_url': EveCharacter.generic_portrait_url(
token.character_id, 128
),
}
return render(request, 'fleetactivitytracking/characternotexisting.html', context=context)
else: else:
messages.error(request, _('FAT link has expired.')) messages.warning(
request,
_(
f"Cannot register the fleet participation for {character.character_name}. The character needs to be online."
),
)
return redirect('fatlink:view') return redirect('fatlink:view')

View File

@@ -1,8 +1,8 @@
from django.apps import apps from django.apps import apps
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group as BaseGroup
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Group as BaseGroup, Permission, User
from django.db.models import Count from django.db.models import Count, Exists, OuterRef
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.db.models.signals import ( from django.db.models.signals import (
m2m_changed, m2m_changed,
@@ -15,6 +15,7 @@ from django.dispatch import receiver
from .forms import GroupAdminForm, ReservedGroupNameAdminForm from .forms import GroupAdminForm, ReservedGroupNameAdminForm
from .models import AuthGroup, GroupRequest, ReservedGroupName from .models import AuthGroup, GroupRequest, ReservedGroupName
from .tasks import remove_users_not_matching_states_from_group
if 'eve_autogroups' in apps.app_configs: if 'eve_autogroups' in apps.app_configs:
_has_auto_groups = True _has_auto_groups = True
@@ -106,14 +107,13 @@ class HasLeaderFilter(admin.SimpleListFilter):
class GroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
form = GroupAdminForm form = GroupAdminForm
list_select_related = ('authgroup',)
ordering = ('name',) ordering = ('name',)
list_display = ( list_display = (
'name', 'name',
'_description', '_description',
'_properties', '_properties',
'_member_count', '_member_count',
'has_leader' 'has_leader',
) )
list_filter = [ list_filter = [
'authgroup__internal', 'authgroup__internal',
@@ -129,31 +129,51 @@ class GroupAdmin(admin.ModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
if _has_auto_groups: has_leader_qs = (
qs = qs.prefetch_related('managedalliancegroup_set', 'managedcorpgroup_set') AuthGroup.objects.filter(group=OuterRef('pk'), group_leaders__isnull=False)
qs = qs.prefetch_related('authgroup__group_leaders').select_related('authgroup')
qs = qs.annotate(
member_count=Count('user', distinct=True),
) )
has_leader_groups_qs = (
AuthGroup.objects.filter(
group=OuterRef('pk'), group_leader_groups__isnull=False
)
)
qs = (
qs.select_related('authgroup')
.annotate(member_count=Count('user', distinct=True))
.annotate(has_leader=Exists(has_leader_qs))
.annotate(has_leader_groups=Exists(has_leader_groups_qs))
)
if _has_auto_groups:
is_autogroup_corp = (
Group.objects.filter(
pk=OuterRef('pk'), managedcorpgroup__isnull=False
)
)
is_autogroup_alliance = (
Group.objects.filter(
pk=OuterRef('pk'), managedalliancegroup__isnull=False
)
)
qs = (
qs.annotate(is_autogroup_corp=Exists(is_autogroup_corp))
.annotate(is_autogroup_alliance=Exists(is_autogroup_alliance))
)
return qs return qs
def _description(self, obj): def _description(self, obj):
return obj.authgroup.description return obj.authgroup.description
@admin.display(description="Members", ordering="member_count") @admin.display(description='Members', ordering='member_count')
def _member_count(self, obj): def _member_count(self, obj):
return obj.member_count return obj.member_count
@admin.display(boolean=True) @admin.display(boolean=True)
def has_leader(self, obj): def has_leader(self, obj):
return obj.authgroup.group_leaders.exists() or obj.authgroup.group_leader_groups.exists() return obj.has_leader or obj.has_leader_groups
def _properties(self, obj): def _properties(self, obj):
properties = list() properties = list()
if _has_auto_groups and ( if _has_auto_groups and (obj.is_autogroup_corp or obj.is_autogroup_alliance):
obj.managedalliancegroup_set.exists()
or obj.managedcorpgroup_set.exists()
):
properties.append('Auto Group') properties.append('Auto Group')
elif obj.authgroup.internal: elif obj.authgroup.internal:
properties.append('Internal') properties.append('Internal')
@@ -183,6 +203,8 @@ class GroupAdmin(admin.ModelAdmin):
ag_instance = inline_form.save(commit=False) ag_instance = inline_form.save(commit=False)
ag_instance.group = form.instance ag_instance.group = form.instance
ag_instance.save() ag_instance.save()
if ag_instance.states.exists():
remove_users_not_matching_states_from_group.delay(ag_instance.group.pk)
formset.save() formset.save()
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):

View File

@@ -189,6 +189,15 @@ class AuthGroup(models.Model):
| User.objects.filter(groups__in=list(self.group_leader_groups.all())) | User.objects.filter(groups__in=list(self.group_leader_groups.all()))
) )
def remove_users_not_matching_states(self):
"""Remove users not matching defined states from related group."""
states_qs = self.states.all()
if states_qs.exists():
states = list(states_qs)
non_compliant_users = self.group.user_set.exclude(profile__state__in=states)
for user in non_compliant_users:
self.group.user_set.remove(user)
class ReservedGroupName(models.Model): class ReservedGroupName(models.Model):
"""Name that can not be used for groups. """Name that can not be used for groups.

View File

@@ -0,0 +1,10 @@
from celery import shared_task
from django.contrib.auth.models import Group
@shared_task
def remove_users_not_matching_states_from_group(group_pk: int) -> None:
"""Remove users not matching defined states from related group."""
group = Group.objects.get(pk=group_pk)
group.authgroup.remove_users_not_matching_states()

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{{ group }} {% translate "Audit Log" %}{% endblock page_title %} {% block page_title %}{{ group }} {% translate "Audit Log" %}{% endblock page_title %}
@@ -25,13 +24,15 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped" id="log-entries"> <table class="table table-striped" id="log-entries">
<thead> <thead>
<th scope="col">{% translate "Date/Time" %}</th> <tr>
<th scope="col">{% translate "Requestor" %}</th> <th scope="col">{% translate "Date/Time" %}</th>
<th scope="col">{% translate "Character" %}</th> <th scope="col">{% translate "Requestor" %}</th>
<th scope="col">{% translate "Corporation" %}</th> <th scope="col">{% translate "Character" %}</th>
<th scope="col">{% translate "Type" %}</th> <th scope="col">{% translate "Corporation" %}</th>
<th scope="col">{% translate "Action" %}</th> <th scope="col">{% translate "Type" %}</th>
<th scope="col">{% translate "Actor" %}</th> <th scope="col">{% translate "Action" %}</th>
<th scope="col">{% translate "Actor" %}</th>
</tr>
</thead> </thead>
<tbody> <tbody>
@@ -74,7 +75,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
{% include 'bundles/moment-js.html' with locale=True %} {% include 'bundles/moment-js.html' with locale=True %}
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% load evelinks %} {% load evelinks %}
@@ -37,7 +36,7 @@
{% for member in members %} {% for member in members %}
<tr> <tr>
<td> <td>
<img src="{{ member.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ member.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ member.main_char.character_name }}">
{% if member.main_char %} {% if member.main_char %}
<a href="{{ member.main_char|evewho_character_url }}" target="_blank"> <a href="{{ member.main_char|evewho_character_url }}" target="_blank">
{{ member.main_char.character_name }} {{ member.main_char.character_name }}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Groups Membership" %}{% endblock page_title %} {% block page_title %}{% translate "Groups Membership" %}{% endblock page_title %}
@@ -61,7 +60,7 @@
<i class="glyphicon glyphicon-list-alt"></i> <i class="glyphicon glyphicon-list-alt"></i>
</a> </a>
<a id="clipboard-copy" data-clipboard-text="{{ request.scheme }}://{{request.get_host}}{% url 'groupmanagement:request_add' group.id %}" class="btn btn-warning" title="{% translate "Copy Direct Join Link" %}"> <a id="clipboard-copy" data-clipboard-text="{{ SITE_URL }}{% url 'groupmanagement:request_add' group.id %}" class="btn btn-warning" title="{% translate "Copy Direct Join Link" %}">
<i class="glyphicon glyphicon-copy"></i> <i class="glyphicon glyphicon-copy"></i>
</a> </a>
</td> </td>

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Available Groups" %}{% endblock page_title %} {% block page_title %}{% translate "Available Groups" %}{% endblock page_title %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% load evelinks %} {% load evelinks %}
@@ -64,7 +63,7 @@
{% for acceptrequest in acceptrequests %} {% for acceptrequest in acceptrequests %}
<tr> <tr>
<td> <td>
<img src="{{ acceptrequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ acceptrequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ acceptrequest.main_char.character_name }}">
{% if acceptrequest.main_char %} {% if acceptrequest.main_char %}
<a href="{{ acceptrequest.main_char|evewho_character_url }}" target="_blank"> <a href="{{ acceptrequest.main_char|evewho_character_url }}" target="_blank">
{{ acceptrequest.main_char.character_name }} {{ acceptrequest.main_char.character_name }}
@@ -121,7 +120,7 @@
{% for leaverequest in leaverequests %} {% for leaverequest in leaverequests %}
<tr> <tr>
<td> <td>
<img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;" alt="{{ leaverequest.main_char.character_name }}">
{% if leaverequest.main_char %} {% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank"> <a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank">
{{ leaverequest.main_char.character_name }} {{ leaverequest.main_char.character_name }}

View File

@@ -1,4 +1,3 @@
{% load static %}
{% load i18n %} {% load i18n %}
{% load navactive %} {% load navactive %}

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client, override_settings
from allianceauth.authentication.models import CharacterOwnership, State from allianceauth.authentication.models import CharacterOwnership, State
from allianceauth.eveonline.models import ( from allianceauth.eveonline.models import (
@@ -236,60 +236,104 @@ class TestGroupAdmin(TestCase):
self.assertEqual(result, expected) self.assertEqual(result, expected)
def test_member_count(self): def test_member_count(self):
expected = 1 # given
obj = self.modeladmin.get_queryset(MockRequest(user=self.user_1))\ request = MockRequest(user=self.user_1)
.get(pk=self.group_1.pk) obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin._member_count(obj) result = self.modeladmin._member_count(obj)
self.assertEqual(result, expected) # then
self.assertEqual(result, 1)
def test_has_leader_user(self): def test_has_leader_user(self):
result = self.modeladmin.has_leader(self.group_1) # given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin.has_leader(obj)
# then
self.assertTrue(result) self.assertTrue(result)
def test_has_leader_group(self): def test_has_leader_group(self):
result = self.modeladmin.has_leader(self.group_2) # given
request = MockRequest(user=self.user_1)
obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
# when
result = self.modeladmin.has_leader(obj)
# then
self.assertTrue(result) self.assertTrue(result)
def test_properties_1(self): def test_properties_1(self):
expected = ['Default'] # given
result = self.modeladmin._properties(self.group_1) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_1.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Default'])
def test_properties_2(self): def test_properties_2(self):
expected = ['Internal'] # given
result = self.modeladmin._properties(self.group_2) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_2.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Internal'])
def test_properties_3(self): def test_properties_3(self):
expected = ['Hidden'] # given
result = self.modeladmin._properties(self.group_3) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_3.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Hidden'])
def test_properties_4(self): def test_properties_4(self):
expected = ['Open'] # given
result = self.modeladmin._properties(self.group_4) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_4.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Open'])
def test_properties_5(self): def test_properties_5(self):
expected = ['Public'] # given
result = self.modeladmin._properties(self.group_5) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_5.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Public'])
def test_properties_6(self): def test_properties_6(self):
expected = ['Hidden', 'Open', 'Public'] # given
result = self.modeladmin._properties(self.group_6) request = MockRequest(user=self.user_1)
self.assertListEqual(result, expected) obj = self.modeladmin.get_queryset(request).get(pk=self.group_6.pk)
# when
result = self.modeladmin._properties(obj)
self.assertListEqual(result, ['Hidden', 'Open', 'Public'])
if _has_auto_groups: if _has_auto_groups:
@patch(MODULE_PATH + '._has_auto_groups', True) @patch(MODULE_PATH + '._has_auto_groups', True)
def test_properties_7(self): def test_should_show_autogroup_for_corporation(self):
# given
self._create_autogroups() self._create_autogroups()
expected = ['Auto Group'] request = MockRequest(user=self.user_1)
my_group = Group.objects\ queryset = self.modeladmin.get_queryset(request)
.filter(managedcorpgroup__isnull=False)\ obj = queryset.filter(managedcorpgroup__isnull=False).first()
.first() # when
result = self.modeladmin._properties(my_group) result = self.modeladmin._properties(obj)
self.assertListEqual(result, expected) # then
self.assertListEqual(result, ['Auto Group'])
@patch(MODULE_PATH + '._has_auto_groups', True)
def test_should_show_autogroup_for_alliance(self):
# given
self._create_autogroups()
request = MockRequest(user=self.user_1)
queryset = self.modeladmin.get_queryset(request)
obj = queryset.filter(managedalliancegroup__isnull=False).first()
# when
result = self.modeladmin._properties(obj)
# then
self.assertListEqual(result, ['Auto Group'])
# actions # actions
@@ -539,6 +583,68 @@ class TestGroupAdminChangeFormSuperuserExclusiveEdits(WebTest):
self.assertNotIn(field, form.fields) self.assertNotIn(field, form.fields)
@override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True)
class TestGroupAdmin2(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.superuser = User.objects.create_superuser("super")
def test_should_remove_users_from_state_groups(self):
# given
user_member = AuthUtils.create_user("Bruce Wayne")
character_member = AuthUtils.add_main_character_2(
user_member,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies",
)
user_guest = AuthUtils.create_user("Lex Luthor")
AuthUtils.add_main_character_2(
user_guest,
name="Lex Luthor",
character_id=1011,
corp_id=2011,
corp_name="Luthor Corp",
)
member_state = AuthUtils.get_member_state()
member_state.member_characters.add(character_member)
user_member.refresh_from_db()
user_guest.refresh_from_db()
group = Group.objects.create(name="dummy")
user_member.groups.add(group)
user_guest.groups.add(group)
group.authgroup.states.add(member_state)
self.client.force_login(self.superuser)
# when
response = self.client.post(
f"/admin/groupmanagement/group/{group.pk}/change/",
data={
"name": f"{group.name}",
"authgroup-TOTAL_FORMS": "1",
"authgroup-INITIAL_FORMS": "1",
"authgroup-MIN_NUM_FORMS": "0",
"authgroup-MAX_NUM_FORMS": "1",
"authgroup-0-description": "",
"authgroup-0-states": f"{member_state.pk}",
"authgroup-0-internal": "on",
"authgroup-0-hidden": "on",
"authgroup-0-group": f"{group.pk}",
"authgroup-__prefix__-description": "",
"authgroup-__prefix__-internal": "on",
"authgroup-__prefix__-hidden": "on",
"authgroup-__prefix__-group": f"{group.pk}",
"_save": "Save"
}
)
# then
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/admin/groupmanagement/group/")
self.assertIn(group, user_member.groups.all())
self.assertNotIn(group, user_guest.groups.all())
class TestReservedGroupNameAdmin(TestCase): class TestReservedGroupNameAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

View File

@@ -232,6 +232,38 @@ class TestAuthGroup(TestCase):
expected = 'Superheros' expected = 'Superheros'
self.assertEqual(str(group.authgroup), expected) self.assertEqual(str(group.authgroup), expected)
def test_should_remove_guests_from_group_when_restricted_to_members_only(self):
# given
user_member = AuthUtils.create_user("Bruce Wayne")
character_member = AuthUtils.add_main_character_2(
user_member,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies",
)
user_guest = AuthUtils.create_user("Lex Luthor")
AuthUtils.add_main_character_2(
user_guest,
name="Lex Luthor",
character_id=1011,
corp_id=2011,
corp_name="Luthor Corp",
)
member_state = AuthUtils.get_member_state()
member_state.member_characters.add(character_member)
user_member.refresh_from_db()
user_guest.refresh_from_db()
group = Group.objects.create(name="dummy")
user_member.groups.add(group)
user_guest.groups.add(group)
group.authgroup.states.add(member_state)
# when
group.authgroup.remove_users_not_matching_states()
# then
self.assertIn(group, user_member.groups.all())
self.assertNotIn(group, user_guest.groups.all())
class TestAuthGroupRequestApprovers(TestCase): class TestAuthGroupRequestApprovers(TestCase):
def setUp(self) -> None: def setUp(self) -> None:

View File

@@ -5,7 +5,7 @@ app_name = "groupmanagement"
urlpatterns = [ urlpatterns = [
# groups # groups
path("groups", views.groups_view, name="groups"), path("groups/", views.groups_view, name="groups"),
path("group/request/join/<int:group_id>/", views.group_request_add, name="request_add"), path("group/request/join/<int:group_id>/", views.group_request_add, name="request_add"),
path( path(
"group/request/leave/<int:group_id>/", views.group_request_leave, name="request_leave" "group/request/leave/<int:group_id>/", views.group_request_leave, name="request_leave"

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Choose a Corp" %}{% endblock page_title %} {% block page_title %}{% translate "Choose a Corp" %}{% endblock page_title %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Apply To" %} {{ corp.corporation_name }}{% endblock page_title %} {% block page_title %}{% translate "Apply To" %} {{ corp.corporation_name }}{% endblock page_title %}
@@ -16,11 +15,11 @@
<label class="control-label" for="id_{{ question.pk }}">{{ question.title }}</label> <label class="control-label" for="id_{{ question.pk }}">{{ question.title }}</label>
<div class=" "> <div class=" ">
{% if question.help_text %} {% if question.help_text %}
<div cass="text-center">{{ question.help_text }}</div> <div class="text-center">{{ question.help_text }}</div>
{% endif %} {% endif %}
{% for choice in question.choices.all %} {% for choice in question.choices.all %}
<input type={% if question.multi_select == False %}"radio"{% else %}"checkbox"{% endif %} name="{{ question.pk }}" id="id_{{ question.pk }}" value="{{ choice.choice_text }}"> <input type={% if question.multi_select == False %}"radio"{% else %}"checkbox"{% endif %} name="{{ question.pk }}" id="id_{{ question.pk }}_choice_{{ forloop.counter }}" value="{{ choice.choice_text }}">
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br> <label for="id_{{ question.pk }}_choice_{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% empty %} {% empty %}
<textarea class="form-control" cols="30" id="id_{{ question.pk }}" name="{{ question.pk }}" rows="4"></textarea> <textarea class="form-control" cols="30" id="id_{{ question.pk }}" name="{{ question.pk }}" rows="4"></textarea>
{% endfor %} {% endfor %}

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %} {% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "HR Application Management" %}{% endblock page_title %} {% block page_title %}{% translate "HR Application Management" %}{% endblock page_title %}
@@ -178,7 +177,7 @@
<h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4> <h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST"> <form class="form-signin" role="form" action="{% url 'hrapplications:search' %}" method="POST">
{% csrf_token %} {% csrf_token %}
{{ search_form|bootstrap }} {{ search_form|bootstrap }}
<br> <br>

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %} {% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "HR Application Management" %}{% endblock page_title %} {% block page_title %}{% translate "HR Application Management" %}{% endblock page_title %}
@@ -64,7 +63,7 @@
<h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4> <h4 class="modal-title" id="myModalLabel">{% translate "Application Search" %}</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="form-signin" role="form" action={% url 'hrapplications:search' %} method="POST"> <form class="form-signin" role="form" action="{% url 'hrapplications:search' %}" method="POST">
{% csrf_token %} {% csrf_token %}
{{ search_form|bootstrap }} {{ search_form|bootstrap }}
<br> <br>

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load bootstrap %} {% load bootstrap %}
{% load i18n %} {% load i18n %}
@@ -49,7 +48,7 @@
{% for char in app.characters %} {% for char in app.characters %}
<tr> <tr>
<td class="text-center"> <td class="text-center">
<img class="ra-avatar img-responsive img-circle" src="{{ char.portrait_url_32 }}"> <img class="ra-avatar img-responsive img-circle" src="{{ char.portrait_url_32 }}" alt="{{ char.character_name }}">
</td> </td>
<td class="text-center">{{ char.character_name }}</td> <td class="text-center">{{ char.character_name }}</td>
<td class="text-center">{{ char.corporation_name }}</td> <td class="text-center">{{ char.corporation_name }}</td>

View File

@@ -219,7 +219,7 @@ def hr_application_search(request):
Q(user__character_ownerships__character__corporation_name__icontains=searchstring) | Q(user__character_ownerships__character__corporation_name__icontains=searchstring) |
Q(user__character_ownerships__character__alliance_name__icontains=searchstring) | Q(user__character_ownerships__character__alliance_name__icontains=searchstring) |
Q(user__username__icontains=searchstring) Q(user__username__icontains=searchstring)
) ).distinct()
context = {'applications': applications, 'search_form': HRApplicationSearchForm()} context = {'applications': applications, 'search_form': HRApplicationSearchForm()}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Notifications" %}{% endblock %} {% block page_title %}{% translate "Notifications" %}{% endblock %}
@@ -9,15 +8,15 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading clearfix">
<ul class="nav nav-pills"> <ul class="nav nav-pills navbar-left">
<li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li> <li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li>
<li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li> <li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li>
<div class="pull-right">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</ul> </ul>
<div class="nav navbar-nav navbar-right" style="margin-right: 0;">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</div> </div>
<div class="panel-body"> <div class="panel-body">

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "View Notification" %}{% endblock page_title %} {% block page_title %}{% translate "View Notification" %}{% endblock page_title %}

View File

@@ -34,7 +34,7 @@ class OpTimer(models.Model):
fc = models.CharField(max_length=254, default="") fc = models.CharField(max_length=254, default="")
post_time = models.DateTimeField(default=timezone.now) post_time = models.DateTimeField(default=timezone.now)
eve_character = models.ForeignKey(EveCharacter, null=True, eve_character = models.ForeignKey(EveCharacter, null=True,
on_delete=models.SET_NULL) on_delete=models.SET_NULL)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
type = models.ForeignKey(OpTimerType, null=True, on_delete=models.SET_NULL) type = models.ForeignKey(OpTimerType, null=True, on_delete=models.SET_NULL)

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %} {% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}

View File

@@ -1,5 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load static %}
{% load i18n %} {% load i18n %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
@@ -40,9 +39,9 @@
</div> </div>
{% include 'bundles/moment-js.html' with locale=True %} {% include 'bundles/moment-js.html' with locale=True %}
<script src="{% static 'js/timers.js' %}"></script> {% include 'bundles/timers-js.html' %}
<script type="application/javascript"> <script>
// Data // Data
let timers = [ let timers = [
{% for op in optimer %} {% for op in optimer %}

View File

@@ -1,6 +1,5 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %} {% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{{ permission.permission.codename }} - {% translate "Permissions Audit" %}{% endblock page_title %} {% block page_title %}{{ permission.permission.codename }} - {% translate "Permissions Audit" %}{% endblock page_title %}
@@ -47,7 +45,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -5,7 +5,7 @@
{{ type }}: {{ name }} {{ type }}: {{ name }}
</td> </td>
<td class="text-right"> <td class="text-right">
<img src="{{ user.profile.main_character|character_portrait_url:32 }}" class="img-circle"> <img src="{{ user.profile.main_character|character_portrait_url:32 }}" class="img-circle" alt="{{ user.profile.main_character.character_name }}">
</td> </td>
<td> <td>
<strong>{{ user }}<br></strong> <strong>{{ user }}<br></strong>

View File

@@ -1,6 +1,4 @@
{% extends "allianceauth/base.html" %} {% extends "allianceauth/base.html" %}
{% load bootstrap %}
{% load static %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% translate "Permissions Overview" %}{% endblock page_title %} {% block page_title %}{% translate "Permissions Overview" %}{% endblock page_title %}
@@ -10,10 +8,10 @@
<h1 class="page-header">{% translate "Permissions Overview" %}</h1> <h1 class="page-header">{% translate "Permissions Overview" %}</h1>
<p> <p>
{% if request.GET.all != 'yes' %} {% if request.GET.all != 'yes' %}
{% blocktrans %}Showing only applied permissions{% endblocktrans %} {% blocktranslate %}Showing only applied permissions{% endblocktranslate %}
<a href="{% url 'permissions_tool:overview' %}?all=yes" class="btn btn-primary">{% translate "Show All" %}</a> <a href="{% url 'permissions_tool:overview' %}?all=yes" class="btn btn-primary">{% translate "Show All" %}</a>
{% else %} {% else %}
{% blocktrans %}Showing all permissions{% endblocktrans %} {% blocktranslate %}Showing all permissions{% endblocktranslate %}
<a href="{% url 'permissions_tool:overview' %}?all=no" class="btn btn-primary">{% translate "Show Applied" %}</a> <a href="{% url 'permissions_tool:overview' %}?all=no" class="btn btn-primary">{% translate "Show Applied" %}</a>
{% endif %} {% endif %}
</p> </p>
@@ -80,7 +78,7 @@
{% block extra_javascript %} {% block extra_javascript %}
{% include 'bundles/datatables-js.html' %} {% include 'bundles/datatables-js.html' %}
<script type="application/javascript" src="{% static 'js/filterDropDown/filterDropDown.min.js' %}"></script> {% include 'bundles/filterdropdown-js.html' %}
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View File

@@ -84,17 +84,16 @@ LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale/'), os.path.join(BASE_DIR, 'locale/'),
) )
ugettext = lambda s: s
LANGUAGES = ( LANGUAGES = (
('en', ugettext('English')), ("en", "English"),
('de', ugettext('German')), ("de", "German"),
('es', ugettext('Spanish')), ("es", "Spanish"),
('zh-hans', ugettext('Chinese Simplified')), ("zh-hans", "Chinese Simplified"),
('ru', ugettext('Russian')), ("ru", "Russian"),
('ko', ugettext('Korean')), ("ko", "Korean"),
('fr', ugettext('French')), ("fr", "French"),
('ja', ugettext('Japanese')), ("ja", "Japanese"),
('it', ugettext('Italian')), ("it", "Italian"),
) )
TEMPLATES = [ TEMPLATES = [

View File

@@ -13,6 +13,13 @@ STATIC_ROOT = "/var/www/{{ project_name }}/static/"
# in page titles and the site header. # in page titles and the site header.
SITE_NAME = '{{ project_name }}' SITE_NAME = '{{ project_name }}'
# This is your websites URL, set it accordingly
# Make sure this URL is WITHOUT a trailing slash
SITE_URL = "https://example.com"
# Django security
CSRF_TRUSTED_ORIGINS = [SITE_URL]
# Change this to enable/disable debug mode, which displays # Change this to enable/disable debug mode, which displays
# useful error messages but can leak sensitive data. # useful error messages but can leak sensitive data.
DEBUG = False DEBUG = False
@@ -39,15 +46,16 @@ DATABASES['default'] = {
# Register an application at https://developers.eveonline.com for Authentication # Register an application at https://developers.eveonline.com for Authentication
# & API Access and fill out these settings. Be sure to set the callback URL # & API Access and fill out these settings. Be sure to set the callback URL
# to https://example.com/sso/callback substituting your domain for example.com # to https://example.com/sso/callback substituting your domain for example.com in
# CCP's developer portal
# Logging in to auth requires the publicData scope (can be overridden through the # Logging in to auth requires the publicData scope (can be overridden through the
# LOGIN_TOKEN_SCOPES setting). Other apps may require more (see their docs). # LOGIN_TOKEN_SCOPES setting). Other apps may require more (see their docs).
ESI_SSO_CLIENT_ID = '' ESI_SSO_CLIENT_ID = ''
ESI_SSO_CLIENT_SECRET = '' ESI_SSO_CLIENT_SECRET = ''
ESI_SSO_CALLBACK_URL = '' ESI_SSO_CALLBACK_URL = f"{SITE_URL}/sso/callback"
ESI_USER_CONTACT_EMAIL = '' # A server maintainer that CCP can contact in case of issues. ESI_USER_CONTACT_EMAIL = '' # A server maintainer that CCP can contact in case of issues.
# By default emails are validated before new users can log in. # By default, emails are validated before new users can log in.
# It's recommended to use a free service like SparkPost or Elastic Email to send email. # It's recommended to use a free service like SparkPost or Elastic Email to send email.
# https://www.sparkpost.com/docs/integrations/django/ # https://www.sparkpost.com/docs/integrations/django/
# https://elasticemail.com/resources/settings/smtp-api/ # https://elasticemail.com/resources/settings/smtp-api/

View File

@@ -3,11 +3,11 @@ from django.contrib import admin
from allianceauth import hooks from allianceauth import hooks
from allianceauth.authentication.admin import ( from allianceauth.authentication.admin import (
MainAllianceFilter,
MainCorporationsFilter,
user_main_organization,
user_profile_pic, user_profile_pic,
user_username, user_username,
user_main_organization,
MainCorporationsFilter,
MainAllianceFilter
) )
from .models import NameFormatConfig from .models import NameFormatConfig
@@ -17,7 +17,7 @@ class ServicesUserAdmin(admin.ModelAdmin):
"""Parent class for UserAdmin classes for all services""" """Parent class for UserAdmin classes for all services"""
class Media: class Media:
css = { css = {
"all": ("services/admin.css",) "all": ("allianceauth/services/admin.css",)
} }
search_fields = ('user__username',) search_fields = ('user__username',)
@@ -36,19 +36,18 @@ class ServicesUserAdmin(admin.ModelAdmin):
MainAllianceFilter, MainAllianceFilter,
'user__date_joined', 'user__date_joined',
) )
list_select_related = (
'user', 'user__profile__main_character', 'user__profile__state'
)
@admin.display(ordering='user__profile__state__name')
def _state(self, obj): def _state(self, obj):
return obj.user.profile.state.name return obj.user.profile.state.name
_state.short_description = 'state' @admin.display(ordering='user__date_joined')
_state.admin_order_field = 'user__profile__state__name'
def _date_joined(self, obj): def _date_joined(self, obj):
return obj.user.date_joined return obj.user.date_joined
_date_joined.short_description = 'date joined'
_date_joined.admin_order_field = 'user__date_joined'
class NameFormatConfigForm(forms.ModelForm): class NameFormatConfigForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -62,6 +61,7 @@ class NameFormatConfigForm(forms.ModelForm):
self.fields['service_name'] = forms.ChoiceField(choices=SERVICE_CHOICES) self.fields['service_name'] = forms.ChoiceField(choices=SERVICE_CHOICES)
@admin.register(NameFormatConfig)
class NameFormatConfigAdmin(admin.ModelAdmin): class NameFormatConfigAdmin(admin.ModelAdmin):
form = NameFormatConfigForm form = NameFormatConfigForm
list_display = ('service_name', 'get_state_display_string') list_display = ('service_name', 'get_state_display_string')
@@ -69,6 +69,3 @@ class NameFormatConfigAdmin(admin.ModelAdmin):
def get_state_display_string(self, obj): def get_state_display_string(self, obj):
return ', '.join([state.name for state in obj.states.all()]) return ', '.join([state.name for state in obj.states.all()])
get_state_display_string.short_description = 'States' get_state_display_string.short_description = 'States'
admin.site.register(NameFormatConfig, NameFormatConfigAdmin)

View File

@@ -2,12 +2,11 @@ import logging
from django.contrib import admin from django.contrib import admin
from . import __title__
from ...admin import ServicesUserAdmin from ...admin import ServicesUserAdmin
from . import __title__
from .models import DiscordUser from .models import DiscordUser
from .utils import LoggerAddTag from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__) logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -18,21 +17,16 @@ class DiscordUserAdmin(ServicesUserAdmin):
list_filter = ServicesUserAdmin.list_filter + ('activated',) list_filter = ServicesUserAdmin.list_filter + ('activated',)
ordering = ('-activated',) ordering = ('-activated',)
def _uid(self, obj):
return obj.uid
_uid.short_description = 'Discord ID (UID)'
_uid.admin_order_field = 'uid'
def _username(self, obj):
if obj.username and obj.discriminator:
return f'{obj.username}#{obj.discriminator}'
else:
return ''
def delete_queryset(self, request, queryset): def delete_queryset(self, request, queryset):
for user in queryset: for user in queryset:
user.delete_user() user.delete_user()
_username.short_description = 'Discord Username' @admin.display(description='Discord ID (UID)', ordering='uid')
_username.admin_order_field = 'username' def _uid(self, obj):
return obj.uid
@admin.display(description='Discord Username', ordering='username')
def _username(self, obj):
if obj.username and obj.discriminator:
return f'{obj.username}#{obj.discriminator}'
return ''

View File

@@ -0,0 +1,37 @@
"""Public interface for community apps who want to interact with the Discord server
of the current Alliance Auth instance.
Example
=======
Here is an example for using the api to fetch the current roles from the configured Discord server.
.. code-block:: python
from allianceauth.services.modules.discord.api import create_bot_client, discord_guild_id
client = create_bot_client() # create a new Discord client
guild_id = discord_guild_id() # get the ID of the configured Discord server
roles = client.guild_roles(guild_id) # fetch the roles from our Discord server
.. seealso::
The docs for the client class can be found here: :py:class:`~allianceauth.services.modules.discord.discord_client.client.DiscordClient`
"""
from typing import Optional
from .app_settings import DISCORD_GUILD_ID
from .core import create_bot_client, group_to_role, server_name # noqa
from .discord_client.models import Role # noqa
from .models import DiscordUser # noqa
__all__ = ["create_bot_client", "group_to_role", "server_name", "DiscordUser", "Role"]
def discord_guild_id() -> Optional[int]:
"""Guild ID of configured Discord server.
Returns:
Guild ID or ``None`` if not configured
"""
return int(DISCORD_GUILD_ID) if DISCORD_GUILD_ID else None

View File

@@ -2,16 +2,25 @@ from .utils import clean_setting
DISCORD_APP_ID = clean_setting('DISCORD_APP_ID', '') DISCORD_APP_ID = clean_setting('DISCORD_APP_ID', '')
"""App ID for the AA bot on Discord. Needs to be set."""
DISCORD_APP_SECRET = clean_setting('DISCORD_APP_SECRET', '') DISCORD_APP_SECRET = clean_setting('DISCORD_APP_SECRET', '')
"""App secret for the AA bot on Discord. Needs to be set."""
DISCORD_BOT_TOKEN = clean_setting('DISCORD_BOT_TOKEN', '') DISCORD_BOT_TOKEN = clean_setting('DISCORD_BOT_TOKEN', '')
"""Token used by the AA bot on Discord. Needs to be set."""
DISCORD_CALLBACK_URL = clean_setting('DISCORD_CALLBACK_URL', '') DISCORD_CALLBACK_URL = clean_setting('DISCORD_CALLBACK_URL', '')
"""Callback URL for OAuth with Discord. Needs to be set."""
DISCORD_GUILD_ID = clean_setting('DISCORD_GUILD_ID', '') DISCORD_GUILD_ID = clean_setting('DISCORD_GUILD_ID', '')
"""ID of the Discord Server. Needs to be set."""
# max retries of tasks after an error occurred
DISCORD_TASKS_MAX_RETRIES = clean_setting('DISCORD_TASKS_MAX_RETRIES', 3) DISCORD_TASKS_MAX_RETRIES = clean_setting('DISCORD_TASKS_MAX_RETRIES', 3)
"""Max retries of tasks after an error occurred."""
# Pause in seconds until next retry for tasks after the API returned an error
DISCORD_TASKS_RETRY_PAUSE = clean_setting('DISCORD_TASKS_RETRY_PAUSE', 60) DISCORD_TASKS_RETRY_PAUSE = clean_setting('DISCORD_TASKS_RETRY_PAUSE', 60)
"""Pause in seconds until next retry for tasks after the API returned an error."""
# automatically sync Discord users names to user's main character name when created
DISCORD_SYNC_NAMES = clean_setting('DISCORD_SYNC_NAMES', False) DISCORD_SYNC_NAMES = clean_setting('DISCORD_SYNC_NAMES', False)
"""Automatically sync Discord users names to user's main character name when created."""

View File

@@ -6,6 +6,7 @@ from django.template.loader import render_to_string
from allianceauth import hooks from allianceauth import hooks
from allianceauth.services.hooks import ServicesHook from allianceauth.services.hooks import ServicesHook
from .core import server_name, user_formatted_nick
from .models import DiscordUser from .models import DiscordUser
from .urls import urlpatterns from .urls import urlpatterns
from .utils import LoggerAddTag from .utils import LoggerAddTag
@@ -53,7 +54,7 @@ class DiscordService(ServicesHook):
return render_to_string( return render_to_string(
self.service_ctrl_template, self.service_ctrl_template,
{ {
'server_name': DiscordUser.objects.server_name(), 'server_name': server_name(),
'user_has_account': user_has_account, 'user_has_account': user_has_account,
'discord_username': discord_username 'discord_username': discord_username
}, },
@@ -73,7 +74,7 @@ class DiscordService(ServicesHook):
'user_pk': user.pk, 'user_pk': user.pk,
# since the new nickname is not yet in the DB we need to # since the new nickname is not yet in the DB we need to
# provide it manually to the task # provide it manually to the task
'nickname': DiscordUser.objects.user_formatted_nick(user) 'nickname': user_formatted_nick(user)
}, },
priority=SINGLE_TASK_PRIORITY priority=SINGLE_TASK_PRIORITY
) )

View File

@@ -0,0 +1,129 @@
"""Core functionality of the Discord service not directly related to models."""
import logging
from typing import List, Optional, Tuple
from requests.exceptions import HTTPError
from django.contrib.auth.models import Group, User
from allianceauth.groupmanagement.models import ReservedGroupName
from allianceauth.services.hooks import NameFormatter
from . import __title__
from .app_settings import DISCORD_BOT_TOKEN, DISCORD_GUILD_ID
from .discord_client import DiscordClient, RolesSet, Role
from .discord_client.exceptions import DiscordClientException
from .utils import LoggerAddTag
logger = LoggerAddTag(logging.getLogger(__name__), __title__)
def create_bot_client(is_rate_limited: bool = True) -> DiscordClient:
"""Create new bot client for accessing the configured Discord server.
Args:
is_rate_limited: Set to False to turn off rate limiting (use with care).
Return:
Discord client instance
"""
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited)
def calculate_roles_for_user(
user: User,
client: DiscordClient,
discord_uid: int,
state_name: str = None,
) -> Tuple[RolesSet, Optional[bool]]:
"""Calculate current Discord roles for an Auth user.
Takes into account reserved groups and existing managed roles (e.g. nitro).
Returns:
- Discord roles, changed flag:
- True when roles have changed,
- False when they have not changed,
- None if user is not a member of the guild
"""
roles_calculated = client.match_or_create_roles_from_names_2(
guild_id=DISCORD_GUILD_ID,
role_names=_user_group_names(user=user, state_name=state_name),
)
logger.debug("Calculated roles for user %s: %s", user, roles_calculated.ids())
roles_current = client.guild_member_roles(
guild_id=DISCORD_GUILD_ID, user_id=discord_uid
)
if roles_current is None:
logger.debug("User %s is not a member of the guild.", user)
return roles_calculated, None
logger.debug("Current roles user %s: %s", user, roles_current.ids())
reserved_role_names = ReservedGroupName.objects.values_list("name", flat=True)
roles_reserved = roles_current.subset(role_names=reserved_role_names)
roles_managed = roles_current.subset(managed_only=True)
roles_persistent = roles_managed.union(roles_reserved)
if roles_calculated == roles_current.difference(roles_persistent):
return roles_calculated, False
return roles_calculated.union(roles_persistent), True
def _user_group_names(user: User, state_name: str = None) -> List[str]:
"""Names of groups and state the given user is a member of."""
if not state_name:
state_name = user.profile.state.name
group_names = [group.name for group in user.groups.all()] + [state_name]
logger.debug("Group names for roles updates of user %s are: %s", user, group_names)
return group_names
def user_formatted_nick(user: User) -> Optional[str]:
"""Name of the given user's main character with name formatting applied.
Returns:
Name or ``None`` if user has no main.
"""
from .auth_hooks import DiscordService
if user.profile.main_character:
return NameFormatter(DiscordService(), user).format_name()
return None
def group_to_role(group: Group) -> Optional[Role]:
"""Fetch the Discord role matching the given Django group by name.
Returns:
Discord role or None if no matching role exist
"""
return default_bot_client.match_role_from_name(
guild_id=DISCORD_GUILD_ID, role_name=group.name
)
def server_name(use_cache: bool = True) -> str:
"""Fetches the name of the current Discord server.
Args:
use_cache: When set False will force an API call to get the server name
Returns:
Server name or an empty string if the name could not be retrieved
"""
try:
server_name = default_bot_client.guild_name(
guild_id=DISCORD_GUILD_ID, use_cache=use_cache
)
except (HTTPError, DiscordClientException):
server_name = ""
except Exception:
logger.warning(
"Unexpected error when trying to retrieve the server name from Discord",
exc_info=True,
)
server_name = ""
return server_name
# Default bot client to be used by modules of this package
default_bot_client = create_bot_client()

View File

@@ -1,3 +1,10 @@
from .client import DiscordClient # noqa from .app_settings import DISCORD_OAUTH_BASE_URL, DISCORD_OAUTH_TOKEN_URL # noqa
from .exceptions import DiscordApiBackoff # noqa from .client import DiscordClient # noqa
from .helpers import DiscordRoles # noqa from .exceptions import ( # noqa
DiscordApiBackoff,
DiscordClientException,
DiscordRateLimitExhausted,
DiscordTooManyRequestsError,
)
from .helpers import RolesSet # noqa
from .models import Guild, GuildMember, Role, User # noqa

View File

@@ -1,45 +1,56 @@
"""Settings for the Discord client.
To overwrite a default set the variable in your local Django settings, e.g:
.. code:: python
DISCORD_GUILD_NAME_CACHE_MAX_AGE = 7200
"""
from ..utils import clean_setting from ..utils import clean_setting
# Base URL for all API calls. Must end with /.
DISCORD_API_BASE_URL = clean_setting( DISCORD_API_BASE_URL = clean_setting(
'DISCORD_API_BASE_URL', 'https://discord.com/api/' 'DISCORD_API_BASE_URL', 'https://discord.com/api/'
) )
"""Base URL for all API calls. Must end with /."""
# Low level connecttimeout for requests to the Discord API in seconds
DISCORD_API_TIMEOUT_CONNECT = clean_setting( DISCORD_API_TIMEOUT_CONNECT = clean_setting(
'DISCORD_API_TIMEOUT', 5 'DISCORD_API_TIMEOUT', 5
) )
"""Low level connect timeout for requests to the Discord API in seconds."""
# Low level read timeout for requests to the Discord API in seconds
DISCORD_API_TIMEOUT_READ = clean_setting( DISCORD_API_TIMEOUT_READ = clean_setting(
'DISCORD_API_TIMEOUT', 30 'DISCORD_API_TIMEOUT', 30
) )
"""Low level read timeout for requests to the Discord API in seconds."""
# Base authorization URL for Discord Oauth
DISCORD_OAUTH_BASE_URL = clean_setting( DISCORD_OAUTH_BASE_URL = clean_setting(
'DISCORD_OAUTH_BASE_URL', 'https://discord.com/api/oauth2/authorize' 'DISCORD_OAUTH_BASE_URL', 'https://discord.com/api/oauth2/authorize'
) )
"""Base authorization URL for Discord Oauth."""
# Base authorization URL for Discord Oauth
DISCORD_OAUTH_TOKEN_URL = clean_setting( DISCORD_OAUTH_TOKEN_URL = clean_setting(
'DISCORD_OAUTH_TOKEN_URL', 'https://discord.com/api/oauth2/token' 'DISCORD_OAUTH_TOKEN_URL', 'https://discord.com/api/oauth2/token'
) )
"""Base authorization URL for Discord Oauth."""
# How long the Discord guild names retrieved from the server are
# caches locally in seconds.
DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting( DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting(
'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 24 'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 24
) )
"""How long the Discord guild names retrieved from the server
are caches locally in seconds.
"""
# How long Discord roles retrieved from the server are caches locally in seconds.
DISCORD_ROLES_CACHE_MAX_AGE = clean_setting( DISCORD_ROLES_CACHE_MAX_AGE = clean_setting(
'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1 'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1
) )
"""How long Discord roles retrieved from the server are caches locally in seconds."""
# Turns off creation of new roles. In case the rate limit for creating roles is
# exhausted, this setting allows the Discord service to continue to function
# and wait out the reset. Rate limit is about 250 per 48 hrs.
DISCORD_DISABLE_ROLE_CREATION = clean_setting( DISCORD_DISABLE_ROLE_CREATION = clean_setting(
'DISCORD_DISABLE_ROLE_CREATION', False 'DISCORD_DISABLE_ROLE_CREATION', False
) )
"""Turns off creation of new roles. In case the rate limit for creating roles is
exhausted, this setting allows the Discord service to continue to function
and wait out the reset. Rate limit is about 250 per 48 hrs.
"""

View File

@@ -1,32 +1,37 @@
from hashlib import md5 """Client for interacting with the Discord API."""
import json import json
import logging import logging
from enum import IntEnum
from hashlib import md5
from http import HTTPStatus
from time import sleep from time import sleep
from typing import Iterable, List, Optional, Set, Tuple
from urllib.parse import urljoin from urllib.parse import urljoin
from uuid import uuid1 from uuid import uuid1
from redis import Redis
import requests import requests
from requests.exceptions import HTTPError
from redis import Redis
from django_redis import get_redis_connection from allianceauth.utils.cache import get_redis_client
from allianceauth import __title__ as AUTH_TITLE, __url__, __version__ from allianceauth import __title__ as AUTH_TITLE
from allianceauth import __url__, __version__
from .. import __title__ from .. import __title__
from ..utils import LoggerAddTag
from .app_settings import ( from .app_settings import (
DISCORD_API_BASE_URL, DISCORD_API_BASE_URL,
DISCORD_API_TIMEOUT_CONNECT, DISCORD_API_TIMEOUT_CONNECT,
DISCORD_API_TIMEOUT_READ, DISCORD_API_TIMEOUT_READ,
DISCORD_DISABLE_ROLE_CREATION, DISCORD_DISABLE_ROLE_CREATION,
DISCORD_GUILD_NAME_CACHE_MAX_AGE, DISCORD_GUILD_NAME_CACHE_MAX_AGE,
DISCORD_OAUTH_BASE_URL,
DISCORD_OAUTH_TOKEN_URL,
DISCORD_ROLES_CACHE_MAX_AGE, DISCORD_ROLES_CACHE_MAX_AGE,
) )
from .exceptions import DiscordRateLimitExhausted, DiscordTooManyRequestsError from .exceptions import DiscordRateLimitExhausted, DiscordTooManyRequestsError
from .helpers import DiscordRoles from .helpers import RolesSet
from ..utils import LoggerAddTag from .models import Guild, GuildMember, Role, User
logger = LoggerAddTag(logging.getLogger(__name__), __title__) logger = LoggerAddTag(logging.getLogger(__name__), __title__)
@@ -58,8 +63,13 @@ MINIMUM_BLOCKING_WAIT = 50
RATE_LIMIT_RETRIES = 1000 RATE_LIMIT_RETRIES = 1000
class DiscordApiStatusCode(IntEnum):
"""Status code returned from the Discord API."""
UNKNOWN_MEMBER = 10007 #:
class DiscordClient: class DiscordClient:
"""This class provides a web client for interacting with the Discord API """This class provides a web client for interacting with the Discord API.
The client has rate limiting that supports concurrency. The client has rate limiting that supports concurrency.
This means it is able to ensure the API rate limit is not violated, This means it is able to ensure the API rate limit is not violated,
@@ -67,24 +77,30 @@ class DiscordClient:
In addition the client support proper API backoff. In addition the client support proper API backoff.
Synchronization of rate limit infos accross multiple processes Synchronization of rate limit infos across multiple processes
is implemented with Redis and thus requires Redis as Django cache backend. is implemented with Redis and thus requires Redis as Django cache backend.
All durations are in milliseconds. The cache is shared across all clients and processes (also using Redis).
"""
OAUTH_BASE_URL = DISCORD_OAUTH_BASE_URL
OAUTH_TOKEN_URL = DISCORD_OAUTH_TOKEN_URL
All durations are in milliseconds.
Most errors from the API will raise a requests.HTTPError.
Args:
access_token: Discord access token used to authenticate all calls to the API
redis: Redis instance to be used.
is_rate_limited: Set to False to turn off rate limiting (use with care).
If not specified will try to use the Redis instance
from the default Django cache backend.
Raises:
ValueError: No access token provided
"""
_KEY_GLOBAL_BACKOFF_UNTIL = 'DISCORD_GLOBAL_BACKOFF_UNTIL' _KEY_GLOBAL_BACKOFF_UNTIL = 'DISCORD_GLOBAL_BACKOFF_UNTIL'
_KEY_GLOBAL_RATE_LIMIT_REMAINING = 'DISCORD_GLOBAL_RATE_LIMIT_REMAINING' _KEY_GLOBAL_RATE_LIMIT_REMAINING = 'DISCORD_GLOBAL_RATE_LIMIT_REMAINING'
_KEYPREFIX_GUILD_NAME = 'DISCORD_GUILD_NAME' _KEYPREFIX_GUILD_NAME = 'DISCORD_GUILD_NAME'
_KEYPREFIX_GUILD_ROLES = 'DISCORD_GUILD_ROLES' _KEYPREFIX_GUILD_ROLES = 'DISCORD_GUILD_ROLES'
_KEYPREFIX_ROLE_NAME = 'DISCORD_ROLE_NAME' _KEYPREFIX_ROLE_NAME = 'DISCORD_ROLE_NAME'
_NICK_MAX_CHARS = 32
_HTTP_STATUS_CODE_NOT_FOUND = 404
_HTTP_STATUS_CODE_RATE_LIMITED = 429
_DISCORD_STATUS_CODE_UNKNOWN_MEMBER = 10007
def __init__( def __init__(
self, self,
@@ -92,18 +108,12 @@ class DiscordClient:
redis: Redis = None, redis: Redis = None,
is_rate_limited: bool = True is_rate_limited: bool = True
) -> None: ) -> None:
""" if not access_token:
Params: raise ValueError('You must provide an access token.')
- access_token: Discord access token used to authenticate all calls to the API
- redis: Redis instance to be used.
- is_rate_limited: Set to False to run of rate limiting (use with care)
If not specified will try to use the Redis instance
from the default Django cache backend.
"""
self._access_token = str(access_token) self._access_token = str(access_token)
self._is_rate_limited = bool(is_rate_limited) self._is_rate_limited = bool(is_rate_limited)
if not redis: if not redis:
self._redis = get_redis_connection("default") self._redis = get_redis_client()
if not isinstance(self._redis, Redis): if not isinstance(self._redis, Redis):
raise RuntimeError( raise RuntimeError(
'This class requires a Redis client, but none was provided ' 'This class requires a Redis client, but none was provided '
@@ -131,19 +141,20 @@ class DiscordClient:
self.__redis_script_set_longer = self._redis.register_script(lua_2) self.__redis_script_set_longer = self._redis.register_script(lua_2)
@property @property
def access_token(self): def access_token(self) -> str:
"""Discord access token."""
return self._access_token return self._access_token
@property @property
def is_rate_limited(self): def is_rate_limited(self) -> bool:
"""Wether this instance is rate limited."""
return self._is_rate_limited return self._is_rate_limited
def __repr__(self): def __repr__(self):
return f'{type(self).__name__}(access_token=...{self.access_token[-5:]})' return f'{type(self).__name__}(access_token=...{self.access_token[-5:]})'
def _redis_decr_or_set(self, name: str, value: str, px: int) -> bool: def _redis_decr_or_set(self, name: str, value: str, px: int) -> bool:
"""decreases the key value if it exists and returns the result """Decrease the key value if it exists and returns the result else set the key.
else sets the key
Implemented as Lua script to ensure atomicity. Implemented as Lua script to ensure atomicity.
""" """
@@ -152,7 +163,7 @@ class DiscordClient:
) )
def _redis_set_if_longer(self, name: str, value: str, px: int) -> bool: def _redis_set_if_longer(self, name: str, value: str, px: int) -> bool:
"""like set, but only goes through if either key doesn't exist """Like set, but only goes through if either key doesn't exist
or px would be extended. or px would be extended.
Implemented as Lua script to ensure atomicity. Implemented as Lua script to ensure atomicity.
@@ -163,111 +174,134 @@ class DiscordClient:
# users # users
def current_user(self) -> dict: def current_user(self) -> User:
"""returns the user belonging to the current access_token""" """Fetch user belonging to the current access_token."""
authorization = f'Bearer {self.access_token}' authorization = f'Bearer {self.access_token}'
r = self._api_request( r = self._api_request(
method='get', route='users/@me', authorization=authorization method='get', route='users/@me', authorization=authorization
) )
return r.json() return User.from_dict(r.json())
# guild # guild
def guild_infos(self, guild_id: int) -> dict: def guild_infos(self, guild_id: int) -> Guild:
"""Returns all basic infos about this guild""" """Fetch all basic infos about this guild.
Args:
guild_id: Discord ID of the guild
"""
route = f"guilds/{guild_id}" route = f"guilds/{guild_id}"
r = self._api_request(method='get', route=route) r = self._api_request(method='get', route=route)
return r.json() return Guild.from_dict(r.json())
def guild_name(self, guild_id: int, use_cache: bool = True) -> str: def guild_name(self, guild_id: int, use_cache: bool = True) -> str:
"""returns the name of this guild (cached) """Fetch the name of this guild (cached).
or an empty string if something went wrong
Params: Args:
- guild_id: ID of current guild guild_id: Discord ID of the guild
- use_cache: When set to False will force an API call to get the server name use_cache: When set to False will force an API call to get the server name
Returns:
Name of the server or an empty string if something went wrong.
""" """
key_name = self._guild_name_cache_key(guild_id) key_name = self._guild_name_cache_key(guild_id)
if use_cache: if use_cache:
guild_name = self._redis_decode(self._redis.get(key_name)) guild_name = self._redis_decode(self._redis.get(key_name))
else: else:
guild_name = None guild_name = ""
if not guild_name: if not guild_name:
guild_infos = self.guild_infos(guild_id) try:
if 'name' in guild_infos: guild = self.guild_infos(guild_id)
guild_name = guild_infos['name'] except HTTPError:
self._redis.set( guild_name = ""
name=key_name,
value=guild_name,
ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
)
else: else:
guild_name = '' guild_name = guild.name
self._redis.set(
name=key_name, value=guild_name, ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
)
return guild_name return guild_name
@classmethod @classmethod
def _guild_name_cache_key(cls, guild_id: int) -> str: def _guild_name_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing role given by name in the role cache""" """Construct key for accessing role given by name in the role cache.
Args:
guild_id: Discord ID of the guild
"""
gen_key = DiscordClient._generate_hash(f'{guild_id}') gen_key = DiscordClient._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_NAME}__{gen_key}' return f'{cls._KEYPREFIX_GUILD_NAME}__{gen_key}'
# guild roles # guild roles
def guild_roles(self, guild_id: int, use_cache: bool = True) -> list: def guild_roles(self, guild_id: int, use_cache: bool = True) -> Set[Role]:
"""Returns the list of all roles for this guild """Fetch all roles for this guild.
If use_cache is set to False it will always hit the API to retrieve Args:
fresh data and update the cache guild_id: Discord ID of the guild
use_cache: If is set to False it will always hit the API to retrieve
fresh data and update the cache.
Returns:
""" """
cache_key = self._guild_roles_cache_key(guild_id) cache_key = self._guild_roles_cache_key(guild_id)
roles = None
if use_cache: if use_cache:
roles_raw = self._redis.get(name=cache_key) roles_raw = self._redis.get(name=cache_key)
if roles_raw: if roles_raw:
logger.debug('Returning roles for guild %s from cache', guild_id) logger.debug('Returning roles for guild %s from cache', guild_id)
return json.loads(self._redis_decode(roles_raw)) roles = json.loads(self._redis_decode(roles_raw))
else: logger.debug('No roles for guild %s in cache', guild_id)
logger.debug('No roles for guild %s in cache', guild_id) if roles is None:
route = f"guilds/{guild_id}/roles"
route = f"guilds/{guild_id}/roles" r = self._api_request(method='get', route=route)
r = self._api_request(method='get', route=route) roles = r.json()
roles = r.json() if not roles or not isinstance(roles, list):
if roles and isinstance(roles, list): raise RuntimeError(
f"Unexpected response when fetching roles from API: {roles}"
)
self._redis.set( self._redis.set(
name=cache_key, name=cache_key,
value=json.dumps(roles), value=json.dumps(roles),
ex=DISCORD_ROLES_CACHE_MAX_AGE ex=DISCORD_ROLES_CACHE_MAX_AGE
) )
return roles return {Role.from_dict(role) for role in roles}
def create_guild_role(self, guild_id: int, role_name: str, **kwargs) -> dict: def create_guild_role(
self, guild_id: int, role_name: str, **kwargs
) -> Optional[Role]:
"""Create a new guild role with the given name. """Create a new guild role with the given name.
See official documentation for additional optional parameters. See official documentation for additional optional parameters.
Note that Discord allows the creation of multiple roles with the same name, Note that Discord allows the creation of multiple roles with the same name,
so to avoid duplicates it's important to check existing roles so to avoid duplicates it's important to check existing roles
before creating new one before creating new one
returns a new role dict on success Args:
guild_id: Discord ID of the guild
role_name: Name of new role to create
Returns:
new role on success
""" """
route = f"guilds/{guild_id}/roles" route = f"guilds/{guild_id}/roles"
data = {'name': DiscordRoles.sanitize_role_name(role_name)} data = {'name': Role.sanitize_name(role_name)}
data.update(kwargs) data.update(kwargs)
r = self._api_request(method='post', route=route, data=data) r = self._api_request(method='post', route=route, data=data)
role = r.json() role = r.json()
if role: if role:
self._invalidate_guild_roles_cache(guild_id) self._invalidate_guild_roles_cache(guild_id)
return role return Role.from_dict(role)
return None
def delete_guild_role(self, guild_id: int, role_id: int) -> bool: def delete_guild_role(self, guild_id: int, role_id: int) -> bool:
"""Deletes a guild role""" """Delete a guild role."""
route = f"guilds/{guild_id}/roles/{role_id}" route = f"guilds/{guild_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route) r = self._api_request(method='delete', route=route)
if r.status_code == 204: if r.status_code == 204:
self._invalidate_guild_roles_cache(guild_id) self._invalidate_guild_roles_cache(guild_id)
return True return True
else: return False
return False
def _invalidate_guild_roles_cache(self, guild_id: int) -> None: def _invalidate_guild_roles_cache(self, guild_id: int) -> None:
cache_key = self._guild_roles_cache_key(guild_id) cache_key = self._guild_roles_cache_key(guild_id)
@@ -276,67 +310,79 @@ class DiscordClient:
@classmethod @classmethod
def _guild_roles_cache_key(cls, guild_id: int) -> str: def _guild_roles_cache_key(cls, guild_id: int) -> str:
"""Returns key for accessing cached roles for a guild""" """Construct key for accessing cached roles for a guild.
Args:
guild_id: Discord ID of the guild
"""
gen_key = cls._generate_hash(f'{guild_id}') gen_key = cls._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}' return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}'
def match_role_from_name(self, guild_id: int, role_name: str) -> dict: def match_role_from_name(self, guild_id: int, role_name: str) -> Optional[Role]:
"""returns Discord role matching the given name or an empty dict""" """Fetch Discord role matching the given name (cached).
guild_roles = DiscordRoles(self.guild_roles(guild_id))
Args:
guild_id: Discord ID of the guild
role_name: Name of role
Returns:
Matching role or None if no match is found
"""
guild_roles = RolesSet(self.guild_roles(guild_id))
return guild_roles.role_by_name(role_name) return guild_roles.role_by_name(role_name)
def match_or_create_roles_from_names(self, guild_id: int, role_names: list) -> list: def match_or_create_roles_from_names(
"""returns Discord roles matching the given names self, guild_id: int, role_names: Iterable[str]
) -> List[Tuple[Role, bool]]:
Returns as list of tuple of role and created flag """Fetch or create Discord roles matching the given names (cached).
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Args:
- guild_id: ID of guild guild_id: ID of guild
- role_names: list of name strings each defining a role role_names: list of name strings each defining a role
Returns:
List of tuple of Role and created flag
""" """
roles = list() roles = list()
guild_roles = DiscordRoles(self.guild_roles(guild_id)) guild_roles = RolesSet(self.guild_roles(guild_id))
role_names_cleaned = { role_names_cleaned = {Role.sanitize_name(name) for name in role_names}
DiscordRoles.sanitize_role_name(name) for name in role_names
}
for role_name in role_names_cleaned: for role_name in role_names_cleaned:
role, created = self.match_or_create_role_from_name( role, created = self.match_or_create_role_from_name(
guild_id=guild_id, guild_id=guild_id, role_name=role_name, guild_roles=guild_roles
role_name=DiscordRoles.sanitize_role_name(role_name),
guild_roles=guild_roles
) )
if role: if role:
roles.append((role, created)) roles.append((role, created))
if created: if created:
guild_roles = guild_roles.union(DiscordRoles([role])) guild_roles = guild_roles.union(RolesSet([role]))
return roles return roles
def match_or_create_role_from_name( def match_or_create_role_from_name(
self, guild_id: int, role_name: str, guild_roles: DiscordRoles = None self, guild_id: int, role_name: str, guild_roles: RolesSet = None
) -> tuple: ) -> Tuple[Role, bool]:
"""returns Discord role matching the given name """Fetch or create Discord role matching the given name.
Returns as tuple of role and created flag
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Args:
- guild_id: ID of guild guild_id: ID of guild
- role_name: strings defining name of a role role_name: strings defining name of a role
- guild_roles: All known guild roles as DiscordRoles object. guild_roles: All known guild roles as RolesSet object.
Helps to void redundant lookups of guild roles Helps to void redundant lookups of guild roles
when this method is used multiple times. when this method is used multiple times.
Returns:
Tuple of Role and created flag
""" """
if not isinstance(role_name, str): if not isinstance(role_name, str):
raise TypeError('role_name must be of type string') raise TypeError('role_name must be of type string')
created = False created = False
if guild_roles is None: if guild_roles is None:
guild_roles = DiscordRoles(self.guild_roles(guild_id)) guild_roles = RolesSet(self.guild_roles(guild_id))
role = guild_roles.role_by_name(role_name) role = guild_roles.role_by_name(role_name)
if not role: if not role:
if not DISCORD_DISABLE_ROLE_CREATION: if not DISCORD_DISABLE_ROLE_CREATION:
@@ -345,9 +391,24 @@ class DiscordClient:
created = True created = True
else: else:
role = None role = None
return role, created return role, created
def match_or_create_roles_from_names_2(
self, guild_id: int, role_names: Iterable[str]
) -> RolesSet:
"""Fetch or create Discord role matching the given name.
Wrapper for ``match_or_create_role_from_name()``
Returns:
Roles as RolesSet object.
"""
return RolesSet.create_from_matched_roles(
self.match_or_create_roles_from_names(
guild_id=guild_id, role_names=role_names
)
)
# guild members # guild members
def add_guild_member( def add_guild_member(
@@ -357,13 +418,13 @@ class DiscordClient:
access_token: str, access_token: str,
role_ids: list = None, role_ids: list = None,
nick: str = None nick: str = None
) -> bool: ) -> Optional[bool]:
"""Adds a user to the guilds. """Adds a user to the guild.
Returns: Returns:
- True when a new user was added - True when a new user was added
- None if the user already existed - None if the user already existed
- False when something went wrong or raises exception - False when something went wrong or raises exception
""" """
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
data = { data = {
@@ -371,42 +432,49 @@ class DiscordClient:
} }
if role_ids: if role_ids:
data['roles'] = self._sanitize_role_ids(role_ids) data['roles'] = self._sanitize_role_ids(role_ids)
if nick: if nick:
data['nick'] = str(nick)[:self._NICK_MAX_CHARS] data['nick'] = GuildMember.sanitize_nick(nick)
r = self._api_request(method='put', route=route, data=data) r = self._api_request(method='put', route=route, data=data)
r.raise_for_status() r.raise_for_status()
if r.status_code == 201: if r.status_code == 201:
return True return True
elif r.status_code == 204: elif r.status_code == 204:
return None return None
else: return False
return False
def guild_member(self, guild_id: int, user_id: int) -> dict: def guild_member(self, guild_id: int, user_id: int) -> Optional[GuildMember]:
"""returns the user info for a guild member """Fetch info for a guild member.
or None if the user is not a member of the guild Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
Returns:
guild member or ``None`` if the user is not a member of the guild
""" """
route = f'guilds/{guild_id}/members/{user_id}' route = f'guilds/{guild_id}/members/{user_id}'
r = self._api_request(method='get', route=route, raise_for_status=False) r = self._api_request(method='get', route=route, raise_for_status=False)
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning("Discord user ID %s could not be found on server.", user_id) logger.warning("Discord user ID %s could not be found on server.", user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status() return GuildMember.from_dict(r.json())
return r.json()
def modify_guild_member( def modify_guild_member(
self, guild_id: int, user_id: int, role_ids: list = None, nick: str = None self, guild_id: int, user_id: int, role_ids: List[int] = None, nick: str = None
) -> bool: ) -> Optional[bool]:
"""Modify attributes of a guild member. """Set properties of a guild member.
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
roles_id: New list of role IDs (if provided)
nick: New nickname (if provided)
Returns Returns
- True when successful - True when successful
- None if user is not a member of this guild - None if user is not a member of this guild
- False otherwise - False otherwise
""" """
if not role_ids and not nick: if not role_ids and not nick:
raise ValueError('Must specify role_ids or nick') raise ValueError('Must specify role_ids or nick')
@@ -419,7 +487,7 @@ class DiscordClient:
data['roles'] = self._sanitize_role_ids(role_ids) data['roles'] = self._sanitize_role_ids(role_ids)
if nick: if nick:
data['nick'] = self._sanitize_nick(nick) data['nick'] = GuildMember.sanitize_nick(nick)
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request( r = self._api_request(
@@ -428,21 +496,22 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def remove_guild_member(self, guild_id: int, user_id: int) -> bool: def remove_guild_member(self, guild_id: int, user_id: int) -> Optional[bool]:
"""Remove a member from a guild """Remove a member from a guild.
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
Returns: Returns:
- True when successful - True when successful
- None if member does not exist - None if member does not exist
- False otherwise - False otherwise
""" """
route = f"guilds/{guild_id}/members/{user_id}" route = f"guilds/{guild_id}/members/{user_id}"
r = self._api_request( r = self._api_request(
@@ -451,19 +520,16 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
# Guild member roles # Guild member roles
def add_guild_member_role( def add_guild_member_role(
self, guild_id: int, user_id: int, role_id: int self, guild_id: int, user_id: int, role_id: int
) -> bool: ) -> Optional[bool]:
"""Adds a role to a guild member """Adds a role to a guild member
Returns: Returns:
@@ -476,43 +542,69 @@ class DiscordClient:
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def remove_guild_member_role( def remove_guild_member_role(
self, guild_id: int, user_id: int, role_id: int self, guild_id: int, user_id: int, role_id: int
) -> bool: ) -> Optional[bool]:
"""Removes a role to a guild member """Remove a role to a guild member
Args:
guild_id: Discord ID of the guild
user_id: Discord ID of the user
role_id: Discord ID of role to be removed
Returns: Returns:
- True when successful - True when successful
- None if member does not exist - None if member does not exist
- False otherwise - False otherwise
""" """
route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}" route = f"guilds/{guild_id}/members/{user_id}/roles/{role_id}"
r = self._api_request(method='delete', route=route, raise_for_status=False) r = self._api_request(method='delete', route=route, raise_for_status=False)
if self._is_member_unknown_error(r): if self._is_member_unknown_error(r):
logger.warning('User ID %s is not a member of this guild', user_id) logger.warning('User ID %s is not a member of this guild', user_id)
return None return None
else: r.raise_for_status()
r.raise_for_status()
if r.status_code == 204: if r.status_code == 204:
return True return True
else: return False
return False
def guild_member_roles(self, guild_id: int, user_id: int) -> Optional[RolesSet]:
"""Fetch the current guild roles of a guild member.
Args:
- guild_id: Discord guild ID
- user_id: Discord user ID
Returns:
- Member roles
- None if user is not a member of the guild
"""
member_info = self.guild_member(guild_id=guild_id, user_id=user_id)
if member_info is None:
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())
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)
@classmethod @classmethod
def _is_member_unknown_error(cls, r: requests.Response) -> bool: def _is_member_unknown_error(cls, r: requests.Response) -> bool:
try: try:
result = ( result = (
r.status_code == cls._HTTP_STATUS_CODE_NOT_FOUND r.status_code == HTTPStatus.NOT_FOUND
and r.json()['code'] == cls._DISCORD_STATUS_CODE_UNKNOWN_MEMBER and r.json()['code'] == DiscordApiStatusCode.UNKNOWN_MEMBER
) )
except (ValueError, KeyError): except (ValueError, KeyError):
result = False result = False
@@ -529,7 +621,19 @@ class DiscordClient:
authorization: str = None, authorization: str = None,
raise_for_status: bool = True raise_for_status: bool = True
) -> requests.Response: ) -> requests.Response:
"""Core method for performing all API calls""" """Core method for performing all API calls.
Args:
method: HTTP method of the request, e.g. "get"
route: Route in the Discord API, e.g. "users/@me"
data: Data to be send with the request
authorization: The authorization string to be used.
Will use the default bot token if not set.
raise_for_status: Whether a requests exception is to be raised when not ok
Returns:
The raw response from the API
"""
uid = uuid1().hex uid = uuid1().hex
if not hasattr(requests, method): if not hasattr(requests, method):
@@ -577,7 +681,7 @@ class DiscordClient:
r.text r.text
) )
if r.status_code == self._HTTP_STATUS_CODE_RATE_LIMITED: if r.status_code == HTTPStatus.TOO_MANY_REQUESTS:
self._handle_new_api_backoff(r, uid) self._handle_new_api_backoff(r, uid)
self._report_rate_limit_from_api(r, uid) self._report_rate_limit_from_api(r, uid)
@@ -588,9 +692,10 @@ class DiscordClient:
return r return r
def _handle_ongoing_api_backoff(self, uid: str) -> None: def _handle_ongoing_api_backoff(self, uid: str) -> None:
"""checks if api is currently on backoff """Check if api is currently on backoff.
if on backoff: will do a blocking wait if it expires soon,
else raises exception If on backoff: will do a blocking wait if it expires soon,
else raises exception.
""" """
global_backoff_duration = self._redis.pttl(self._KEY_GLOBAL_BACKOFF_UNTIL) global_backoff_duration = self._redis.pttl(self._KEY_GLOBAL_BACKOFF_UNTIL)
if global_backoff_duration > 0: if global_backoff_duration > 0:
@@ -610,8 +715,9 @@ class DiscordClient:
raise DiscordTooManyRequestsError(retry_after=global_backoff_duration) raise DiscordTooManyRequestsError(retry_after=global_backoff_duration)
def _ensure_rate_limed_not_exhausted(self, uid: str) -> int: def _ensure_rate_limed_not_exhausted(self, uid: str) -> int:
"""ensures that the rate limit is not exhausted """Ensures that the rate limit is not exhausted.
if exhausted: will do a blocking wait if rate limit resets soon,
If exhausted: will do a blocking wait if rate limit resets soon,
else raises exception else raises exception
returns requests remaining on success returns requests remaining on success
@@ -654,10 +760,10 @@ class DiscordClient:
) )
raise DiscordRateLimitExhausted(resets_in) raise DiscordRateLimitExhausted(resets_in)
raise RuntimeError('Failed to handle rate limit after after too tries.') raise RuntimeError('Failed to handle rate limit after after too many tries.')
def _handle_new_api_backoff(self, r: requests.Response, uid: str) -> None: def _handle_new_api_backoff(self, r: requests.Response, uid: str) -> None:
"""raises exception for new API backoff error""" """Raise exception for new API backoff error."""
response = r.json() response = r.json()
if 'retry_after' in response: if 'retry_after' in response:
try: try:
@@ -679,8 +785,8 @@ class DiscordClient:
) )
raise DiscordTooManyRequestsError(retry_after=retry_after) raise DiscordTooManyRequestsError(retry_after=retry_after)
def _report_rate_limit_from_api(self, r, uid): def _report_rate_limit_from_api(self, r, uid) -> None:
"""Tries to log the current rate limit reported from API""" """Try to log the current rate limit reported from API."""
if ( if (
logger.getEffectiveLevel() <= logging.DEBUG logger.getEffectiveLevel() <= logging.DEBUG
and 'x-ratelimit-limit' in r.headers and 'x-ratelimit-limit' in r.headers
@@ -703,22 +809,17 @@ class DiscordClient:
@staticmethod @staticmethod
def _redis_decode(value: str) -> str: def _redis_decode(value: str) -> str:
"""Decodes a string from Redis and passes through None and Booleans""" """Decode a string from Redis and passes through None and Booleans."""
if value is not None and not isinstance(value, bool): if value is not None and not isinstance(value, bool):
return value.decode('utf-8') return value.decode('utf-8')
else: return value
return value
@staticmethod @staticmethod
def _generate_hash(key: str) -> str: def _generate_hash(key: str) -> str:
"""Generate hash key for given string."""
return md5(key.encode('utf-8')).hexdigest() return md5(key.encode('utf-8')).hexdigest()
@staticmethod @staticmethod
def _sanitize_role_ids(role_ids: list) -> list: def _sanitize_role_ids(role_ids: Iterable[int]) -> List[int]:
"""make sure its a list of integers""" """Sanitize a list of role IDs, i.e. make sure its a list of unique integers."""
return [int(role_id) for role_id in list(role_ids)] return [int(role_id) for role_id in set(role_ids)]
@classmethod
def _sanitize_nick(cls, nick: str) -> str:
"""shortens too long strings if necessary"""
return str(nick)[:cls._NICK_MAX_CHARS]

View File

@@ -1,23 +1,26 @@
"""Custom exceptions for the Discord Client package."""
import math import math
class DiscordClientException(Exception): class DiscordClientException(Exception):
"""Base Exception for the Discord client""" """Base Exception for the Discord client."""
class DiscordApiBackoff(DiscordClientException): class DiscordApiBackoff(DiscordClientException):
"""Exception signaling we need to backoff from sending requests to the API for now """Exception signaling we need to backoff from sending requests to the API for now.
Args:
retry_after: time to retry after in milliseconds
""" """
def __init__(self, retry_after: int): def __init__(self, retry_after: int):
"""
:param retry_after: int time to retry after in milliseconds
"""
super().__init__() super().__init__()
self.retry_after = int(retry_after) self.retry_after = int(retry_after)
@property @property
def retry_after_seconds(self): def retry_after_seconds(self):
"""Time to retry after in seconds."""
return math.ceil(self.retry_after / 1000) return math.ceil(self.retry_after / 1000)

View File

@@ -1,27 +1,37 @@
from copy import copy from copy import copy
from typing import Set, Iterable from typing import Iterable, List, Optional, Set, Tuple
from .models import Role
class DiscordRoles: class RolesSet:
"""Container class that helps dealing with Discord roles. """Container of Discord roles with added functionality.
Objects of this class are immutable and work in many ways like sets. Objects of this class are immutable and work in many ways like sets.
Ideally objects are initialized from raw API responses, Ideally objects are initialized from raw API responses,
e.g. from DiscordClient.guild.roles() e.g. from DiscordClient.guild.roles().
"""
_ROLE_NAME_MAX_CHARS = 100
def __init__(self, roles_lst: list) -> None: Args:
"""roles_lst must be a list of dict, each defining a role""" roles_lst: List of dicts, each defining a role
"""
def __init__(self, roles_lst: Iterable[Role]) -> None:
if not isinstance(roles_lst, (list, set, tuple)): if not isinstance(roles_lst, (list, set, tuple)):
raise TypeError('roles_lst must be of type list, set or tuple') raise TypeError('roles_lst must be of type list, set or tuple')
self._roles = dict() self._roles = dict()
self._roles_by_name = dict() self._roles_by_name = dict()
for role in list(roles_lst): for role in list(roles_lst):
self._assert_valid_role(role) if not isinstance(role, Role):
self._roles[int(role['id'])] = role raise TypeError('Roles must be of type Role: %s' % role)
self._roles_by_name[self.sanitize_role_name(role['name'])] = role self._roles[role.id] = role
self._roles_by_name[role.name] = role
def __repr__(self) -> str:
if self._roles_by_name:
roles = '"' + '", "'.join(sorted(list(self._roles_by_name.keys()))) + '"'
else:
roles = ""
return f'{self.__class__.__name__}([{roles}])'
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, type(self)): if isinstance(other, type(self)):
@@ -41,15 +51,15 @@ class DiscordRoles:
return len(self._roles.keys()) return len(self._roles.keys())
def has_roles(self, role_ids: Set[int]) -> bool: def has_roles(self, role_ids: Set[int]) -> bool:
"""returns true if this objects contains all roles defined by given role_ids """True if this objects contains all roles defined by given role_ids
incl. managed roles incl. managed roles.
""" """
role_ids = {int(id) for id in role_ids} role_ids = {int(id) for id in role_ids}
all_role_ids = self._roles.keys() all_role_ids = self._roles.keys()
return role_ids.issubset(all_role_ids) return role_ids.issubset(all_role_ids)
def ids(self) -> Set[int]: def ids(self) -> Set[int]:
"""return a set of all role IDs""" """Set of all role IDs."""
return set(self._roles.keys()) return set(self._roles.keys())
def subset( def subset(
@@ -57,13 +67,13 @@ class DiscordRoles:
role_ids: Iterable[int] = None, role_ids: Iterable[int] = None,
managed_only: bool = False, managed_only: bool = False,
role_names: Iterable[str] = None role_names: Iterable[str] = None
) -> "DiscordRoles": ) -> "RolesSet":
"""returns a new object containing the subset of roles """Create instance containing the subset of roles
Args: Args:
- role_ids: role ids must be in the provided list role_ids: role ids must be in the provided list
- managed_only: roles must be managed managed_only: roles must be managed
- role_names: role names must match provided list (not case sensitive) role_names: role names must match provided list (not case sensitive)
""" """
if role_ids is not None: if role_ids is not None:
role_ids = {int(id) for id in role_ids} role_ids = {int(id) for id in role_ids}
@@ -75,72 +85,50 @@ class DiscordRoles:
elif role_ids is None and managed_only: elif role_ids is None and managed_only:
return type(self)([ return type(self)([
role for _, role in self._roles.items() if role['managed'] role for _, role in self._roles.items() if role.managed
]) ])
elif role_ids is not None and managed_only: elif role_ids is not None and managed_only:
return type(self)([ return type(self)([
role for role_id, role in self._roles.items() role for role_id, role in self._roles.items()
if role_id in role_ids and role['managed'] if role_id in role_ids and role.managed
]) ])
elif role_ids is None and managed_only is False and role_names is not None: elif role_ids is None and managed_only is False and role_names is not None:
role_names = {self.sanitize_role_name(name).lower() for name in role_names} role_names = {Role.sanitize_name(name).lower() for name in role_names}
return type(self)([ return type(self)([
role for role in self._roles.values() role for role in self._roles.values()
if role["name"].lower() in role_names if role.name.lower() in role_names
]) ])
return copy(self) return copy(self)
def union(self, other: object) -> "DiscordRoles": def union(self, other: object) -> "RolesSet":
"""returns a new roles object that is the union of this roles object """Create instance that is the union of this roles object with other."""
with other"""
return type(self)(list(self) + list(other)) return type(self)(list(self) + list(other))
def difference(self, other: object) -> "DiscordRoles": def difference(self, other: object) -> "RolesSet":
"""returns a new roles object that only contains the roles """Create instance that only contains the roles
that exist in the current objects, but not in other that exist in the current objects, but not in other.
""" """
new_ids = self.ids().difference(other.ids()) new_ids = self.ids().difference(other.ids())
return self.subset(role_ids=new_ids) return self.subset(role_ids=new_ids)
def role_by_name(self, role_name: str) -> dict: def role_by_name(self, role_name: str) -> Optional[Role]:
"""returns role if one with matching name is found else an empty dict""" """Role if one with matching name is found else None."""
role_name = self.sanitize_role_name(role_name) role_name = Role.sanitize_name(role_name)
if role_name in self._roles_by_name: if role_name in self._roles_by_name:
return self._roles_by_name[role_name] return self._roles_by_name[role_name]
return dict() return None
@classmethod @classmethod
def create_from_matched_roles(cls, matched_roles: list) -> "DiscordRoles": def create_from_matched_roles(
"""returns a new object created from the given list of matches roles cls, matched_roles: List[Tuple[Role, bool]]
) -> "RolesSet":
"""Create new instance from the given list of matches roles.
matches_roles must be a list of tuples in the form: (role, created) Args:
matches_roles: list of matches roles
""" """
raw_roles = [x[0] for x in matched_roles] raw_roles = [x[0] for x in matched_roles]
return cls(raw_roles) return cls(raw_roles)
@staticmethod
def _assert_valid_role(role: dict) -> None:
if not isinstance(role, dict):
raise TypeError('Roles must be of type dict: %s' % role)
if 'id' not in role or 'name' not in role or 'managed' not in role:
raise ValueError('This role is not valid: %s' % role)
@classmethod
def sanitize_role_name(cls, role_name: str) -> str:
"""shortens too long strings if necessary"""
return str(role_name)[:cls._ROLE_NAME_MAX_CHARS]
def match_or_create_roles_from_names(
client: object, guild_id: int, role_names: list
) -> DiscordRoles:
"""Shortcut for getting the result of matching role names as DiscordRoles object"""
return DiscordRoles.create_from_matched_roles(
client.match_or_create_roles_from_names(
guild_id=guild_id, role_names=role_names
)
)

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