Compare commits

...

159 Commits
v2.9.1 ... v2.x

Author SHA1 Message Date
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
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
ErikKalkoken
7b9bf08aa3 Fix bug in filterDropDown bundle 2022-07-15 13:39:48 +02:00
Ariel Rin
def6431052 Version Bump 2.14.0 2022-07-11 14:27:49 +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
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
64ee273953 Add filterdropdown bundle to AA2 to ensure backwards compatibility 2022-07-09 13:43:05 +02: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
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
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
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
Ariel Rin
23ad9d02d3 Merge branch 'cherry-pick-7fa76d6d' into 'v2.11.x'
Update GitLab CI to conform with the changes to artifacts collection, 2.11.x backport

See merge request allianceauth/allianceauth!1418
2022-05-12 04:30:07 +00:00
Ariel Rin
f99878cc29 Update .gitlab-ci.yml 2022-05-12 04:07:43 +00:00
Ariel Rin
e64431b06c Merge branch 'update-gitlab-ci' into 'v3.x'
Update GitLab CI to conform with the changes to artifacts collection

See merge request allianceauth/allianceauth!1417

(cherry picked from commit 7fa76d6d37)

a3cce358 Update GitLab CI to conform with the changes to artifacts collection
2022-05-12 04:06:04 +00:00
Ariel Rin
0b2993c1c3 Merge branch 'improve_notifications_2' into 'v2.11.x'
Improve notifications

See merge request allianceauth/allianceauth!1411
2022-05-12 04:02:17 +00:00
Erik Kalkoken
75bccf1b0f Improve notifications 2022-05-12 04:02:17 +00:00
Ariel Rin
945bc92898 Merge branch 'admin-dash-improvement' into 'v2.11.x'
Improve Admin Celery Bar

See merge request allianceauth/allianceauth!1414
2022-05-12 03:57:02 +00:00
Ariel Rin
ec7d14a839 Merge branch 'fix_issue_1222' into 'v2.11.x'
Close security loopholes to make non-superuser admins usable

See merge request allianceauth/allianceauth!1413
2022-05-12 03:56:22 +00:00
Erik Kalkoken
dd1a368ff6 Close security loopholes to make non-superuser admins usable 2022-05-12 03:56:22 +00:00
colcrunch
54085617dc Add a few pixels of margin-top to bar labels to better center them. 2022-04-16 15:46:01 -04:00
colcrunch
8cdc5af453 Improve celery bar by using decimalized width values (2 decimal places) to reduce likelyhood of an empty portion of the bar. 2022-04-16 15:44:53 -04:00
Ariel Rin
da93940e13 Just an empty Tag Commit, because 2.11.2 bump went wonky 2022-03-29 14:48:39 +10:00
Ariel Rin
f53b43d9dc Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.11.x 2022-03-29 14:47:40 +10:00
Ariel Rin
497a167ca7 Version Bump v2.11.2 2022-03-29 14:46:59 +10:00
Ariel Rin
852c5a3037 Bump Django-ESI to 4.x, inc breaking CCP change in 4.0.1 2022-03-29 14:40:30 +10:00
Ariel Rin
90f6777a7a Version Bump 2.11.1 2022-03-20 14:42:39 +10:00
Ariel Rin
a8d890abaf Merge branch 'improve_task_statistics' into 'master'
Improve task statistics

See merge request allianceauth/allianceauth!1409
2022-03-09 10:04:14 +00:00
Erik Kalkoken
79379b444c Improve task statistics 2022-03-09 10:04:13 +00:00
Ariel Rin
ace1de5c68 Merge branch 'fix-docker-new-redis' into 'master'
Fix docker for new redis

See merge request allianceauth/allianceauth!1406
2022-03-09 10:02:01 +00:00
Kevin McKernan
5d6128e9ea remove collectstatic command from dockerfile 2022-03-01 13:23:49 -07:00
Ariel Rin
131cc5ed0a Version Bump 2.11.0 2022-02-26 17:26:55 +10:00
Ariel Rin
9297bed43f Version Bump 2.10.2 2022-02-26 16:37:20 +10:00
Ariel Rin
b2fddc683a Merge branch 'master' of https://gitlab.com/allianceauth/allianceauth into v2.10.x 2022-02-26 16:32:45 +10:00
Ariel Rin
9af634d16a Merge branch 'fix_show_available_groups_for_user_only' into 'master'
Fix: Users can be assigned to groups depite not matching state restrictions

See merge request allianceauth/allianceauth!1402
2022-02-26 05:19:45 +00:00
Erik Kalkoken
a68163caa3 Fix: Users can be assigned to groups depite not matching state restrictions 2022-02-26 05:19:45 +00:00
Ariel Rin
00770fd034 Merge branch 'improve_celery_info_on_dashboard' into 'master'
Improve celery infos on Dashboard

See merge request allianceauth/allianceauth!1384
2022-02-26 05:15:30 +00:00
Erik Kalkoken
01164777ed Improve celery infos on Dashboard 2022-02-26 05:15:30 +00:00
Ariel Rin
00f5e3e1e0 Version Bump 2.10.1 2022-02-21 00:02:12 +10:00
Ariel Rin
8b2527f408 Merge branch 'capsleekxmpp' into 'master'
Cap sleekxmpp to 1.3.2

See merge request allianceauth/allianceauth!1401
2022-02-20 13:44:27 +00:00
Ariel Rin
b7500e4e4e Cap sleekxmpp to 1.3.2 2022-02-20 13:44:27 +00:00
Kevin McKernan
4f4bd0c419 add note to docker README about Apple M1 support 2022-02-20 23:41:12 +10:00
Ariel Rin
8ae4e02012 Merge branch 'docker-bump-version' into 'v2.10.x'
Bump version for Docker deployment to v2.10.x.

See merge request allianceauth/allianceauth!1396
2022-02-02 13:26:33 +00:00
Weyland
cc9a07197d Bump version for Docker deployment to v2.10.x. 2022-02-02 13:30:05 +01:00
Ariel Rin
f18dd1029b Version Bump v2.10.0 2022-01-31 20:58:09 +10:00
Ariel Rin
fd8d43571a Merge branch 'analytics' into 'master'
Analytics - Extra Ignore Path

See merge request allianceauth/allianceauth!1347
2022-01-31 09:23:43 +00:00
Ariel Rin
13e88492f1 Analytics - Extra Ignore Path 2022-01-31 09:23:43 +00:00
Ariel Rin
38df580a56 Merge branch 'analytics_update' into 'master'
Add setting to disable analytics

See merge request allianceauth/allianceauth!1373
2022-01-27 05:14:12 +00:00
Erik Kalkoken
ba39318313 Add setting to disable analytics 2022-01-27 05:14:11 +00:00
Ariel Rin
d8c6035405 Merge branch 'ts3_reserved_groups' into 'master'
Implement reserved group names in Teamspeak3 service module.

See merge request allianceauth/allianceauth!1380
2022-01-27 05:10:22 +00:00
Ariel Rin
2ef3da916b Merge branch 'datatablessavestate' into 'master'
Add DataTables stateSave feature

See merge request allianceauth/allianceauth!1374
2022-01-27 05:05:37 +00:00
Ariel Rin
d32d8b26ce Merge branch 'delete_characters' into 'master'
Fix: Can not update biomassed characters

See merge request allianceauth/allianceauth!1381
2022-01-27 05:02:57 +00:00
Erik Kalkoken
f348b1a34c Fix: Can not update biomassed characters 2022-01-27 05:02:57 +00:00
Ariel Rin
86aaa3edda Merge branch 'fix-grafana-image-2' into 'master'
fix grafana image again, thanks grafana for not tagging your new images properly

See merge request allianceauth/allianceauth!1393
2022-01-27 04:57:40 +00:00
Ariel Rin
26017056c7 Merge branch 'evetime-js-update' into 'master'
Evetime js update

See merge request allianceauth/allianceauth!1395
2022-01-27 04:35:15 +00:00
Peter Pfeufer
e39a3c072b Evetime js update 2022-01-27 04:35:15 +00:00
Kevin McKernan
827291dda4 fix grafana image again, thanks grafana for not tagging your new images properly 2022-01-07 10:48:50 -07:00
Ariel Rin
ea8958ccc3 Version Bump v2.9.4 2021-12-28 21:56:46 +10:00
Ariel Rin
20554df857 Merge branch 'jqueryui' into 'master'
Missing jQuery-UI Images

See merge request allianceauth/allianceauth!1391
2021-12-28 11:55:37 +00:00
Ariel Rin
750f43eaf0 Missing jQuery-UI Images 2021-12-28 11:55:37 +00:00
Ariel Rin
09cf28ec9f Merge branch 'jqueryui' into 'master'
Removing Themeing from jQuery UI

See merge request allianceauth/allianceauth!1386
2021-12-28 11:30:05 +00:00
Ariel Rin
b61746b3cb Removing Themeing from jQuery UI 2021-12-28 11:30:05 +00:00
Ariel Rin
22c22fafeb Merge branch 'py3.11' into 'master'
Python 3.11 Testing

See merge request allianceauth/allianceauth!1388
2021-12-28 11:29:33 +00:00
Ariel Rin
577c4395c4 Python 3.11 Testing 2021-12-28 11:29:33 +00:00
Ariel Rin
d241f476f7 Merge branch 'fix-grafana-image' into 'master'
update grafana image

See merge request allianceauth/allianceauth!1389
2021-12-28 10:43:43 +00:00
Kevin McKernan
5832ed0c30 update grafana image 2021-12-28 10:43:43 +00:00
Ariel Rin
bd9ea225be Rogue comment annoying MRs 2021-12-24 05:02:46 +00:00
Ariel Rin
4a575dd70c Merge branch 'groupmanagement-auto-leave-improvements' into 'master'
Hide "Leave Requests" tab when GROUPMANAGEMENT_AUTO_LEAVE = True

See merge request allianceauth/allianceauth!1378
2021-12-24 04:54:55 +00:00
Ariel Rin
b80ee16a7c Merge branch 'fix_max_notifications_warning' into 'master'
Fix: NOTIFICATIONS_MAX_PER_USER warning when not set

See merge request allianceauth/allianceauth!1383
2021-12-24 04:53:40 +00:00
Ariel Rin
c888371e6c Merge branch 'fix_esi_spec' into 'master'
Add missing ESI operation to eveonline swagger spec file.

See merge request allianceauth/allianceauth!1382
2021-12-24 04:51:49 +00:00
Adarnof
8de2c3bfcb Update name of serverquery IP file changed in TS3 v3.13.0
Changelog indicates old filenames are still accepted, but newly installed servers come with the new file names.
Closes #1298
2021-12-16 22:23:15 -05:00
Adarnof
6688f73565 Use integer teamspeak group IDs when filtering. 2021-12-15 23:54:53 -05:00
ErikKalkoken
7d929cb6e2 Fix: NOTIFICATIONS_MAX_PER_USER warning when not set 2021-12-09 18:12:18 +01:00
Adarnof
72740b9e4d Prevent assignment of reserved groups to AuthTSgroup mappings.
Implemented in TS group updates to prevent their creation / delete once
reserved, and the admin site for when a reserved group name is created
but before the TS group sync occurs.
2021-12-08 23:41:10 -05:00
Adarnof
f7d279fa16 Add missing ESI operation to minimized spec file. My bad. 2021-12-07 23:42:02 -05:00
Ariel Rin
ff7c9c48f3 Merge branch 'v2.9.x' of https://gitlab.com/allianceauth/allianceauth 2021-12-02 02:26:33 +10:00
Adarnof
d11832913d Implement reserved group names in Teamspeak3 service module.
Closes #1302
2021-12-01 00:50:29 -05:00
Ariel Rin
724e0e83f2 Merge branch 'fix-docker-script' into 'v2.9.x'
fix download script

See merge request allianceauth/allianceauth!1379
2021-11-30 11:44:35 +00:00
Kevin McKernan
333f091f1a fix download script 2021-11-29 13:43:32 -07:00
Peter Pfeufer
cfbb0b993a Behavior added to documentation 2021-11-28 19:07:14 +01:00
Peter Pfeufer
582b6754a4 Hide "Leave Requests" tab when GROUPMANAGEMENT_AUTO_LEAVE = True 2021-11-28 18:12:02 +01:00
Ariel Rin
7767c46bf4 Version Bump v2.9.3 2021-11-28 15:37:17 +00:00
Ariel Rin
bf34cef896 Merge branch 'transifex' into 'v2.9.x'
Update from Transifex

See merge request allianceauth/allianceauth!1377
2021-11-28 15:36:14 +00:00
Ariel Rin
c085ec6860 Update from Transifex 2021-11-29 01:34:13 +10:00
Ariel Rin
5f5d0316b2 Merge branch 'transifex' into 'v2.9.x'
Update from Transifex

See merge request allianceauth/allianceauth!1376
2021-11-28 15:25:50 +00:00
Ariel Rin
f9ec64c3ad Update from Transifex 2021-11-28 15:25:50 +00:00
Ariel Rin
0dfd0ad4b0 Django support for Python 3.10 was added to django 3.2.9, missed this in MR !1367 2021-11-28 15:17:53 +00:00
Ariel Rin
e88e11b9ba Merge branch 'jqueryui' into 'v2.9.x'
Add jqueryui Bundles

See merge request allianceauth/allianceauth!1372
2021-11-28 14:50:22 +00:00
Ariel Rin
7a2a79ca7b Merge branch 'groups_blacklist' into 'v2.9.x'
Add blacklist for groups and ignore blacklisted roles in Discord service

See merge request allianceauth/allianceauth!1371
2021-11-28 14:48:49 +00:00
Erik Kalkoken
4c0683c484 Add blacklist for groups and ignore blacklisted roles in Discord service 2021-11-28 14:48:49 +00:00
Ariel Rin
dfe62db8ee add datatables savestate feature 2021-11-27 23:02:33 +10:00
Ariel Rin
025c824fbb Merge branch 'build-docker-image' into 'v2.9.x'
Build docker image in gitlab, add docs for turn key docker setup

See merge request allianceauth/allianceauth!1356
2021-11-27 04:45:21 +00:00
Kevin McKernan
930c5d7c7a Build docker image in gitlab, add docs for turn key docker setup 2021-11-27 04:45:21 +00:00
Ariel Rin
8b8dcc0127 Merge branch 'group-auto-leave-enhancements' into 'v2.9.x'
Group auto leave enhancements

See merge request allianceauth/allianceauth!1369
2021-11-27 03:45:31 +00:00
Peter Pfeufer
4ad8e88bd8 Old reference to AUTO_LEAVE removed 2021-11-27 04:40:23 +01:00
Ariel Rin
89d4640e92 Merge branch 'py310' into 'v2.9.x'
Python 3.10 Support

See merge request allianceauth/allianceauth!1367
2021-11-27 03:35:40 +00:00
Ariel Rin
60b12bad61 Python 3.10 Support 2021-11-27 03:35:40 +00:00
Ariel Rin
2595fa5c51 Merge branch 'gitlab-api-request-error-handling' into 'v2.9.x'
Added error handling when fetching from GitLab API

See merge request allianceauth/allianceauth!1370
2021-11-27 03:31:39 +00:00
Ariel Rin
3e487e5f13 Add jqueryui bundles 2021-11-24 17:56:15 +10:00
Peter Pfeufer
b6d6c68e54 Fix tests
Thanks to @ErikKalkoken for helping here
2021-11-23 02:31:18 +01:00
Peter Pfeufer
49548d6f9f test_can_handle_connection_timeout added 2021-11-23 01:23:34 +01:00
Peter Pfeufer
8faadc23b0 Set logger to warning to not trigger a notification to admins every time 2021-11-23 01:02:08 +01:00
Peter Pfeufer
20da1ebfab request.raise_for_status() re-added 2021-11-22 18:56:33 +01:00
Peter Pfeufer
03305c72c7 Added error handling when fetching from GitLab API 2021-11-22 18:10:02 +01:00
Peter Pfeufer
a636fd1cf0 Add it to the docu 2021-11-21 18:24:04 +01:00
Peter Pfeufer
d8797a8dc6 Prefix the setting so it is clear where it belongs to 2021-11-21 18:18:05 +01:00
Ariel Rin
363e18e15d Merge branch 'more_group_notifications' into 'v2.9.x'
Add option to notify approvers about new group requests

See merge request allianceauth/allianceauth!1363
2021-11-20 01:32:20 +00:00
Erik Kalkoken
982cac8c43 Add option to notify approvers about new group requests 2021-11-20 01:32:20 +00:00
Ariel Rin
fabf64b838 Merge branch 'cherry-pick-f17ebbed' into 'v2.9.x'
Use danger and error message tags to render correctly on admin site.

Closes #1305

See merge request allianceauth/allianceauth!1366
2021-11-20 01:27:26 +00:00
Ariel Rin
e3e6ebe953 Merge branch 'improve_menu_bar' into 'v2.9.x'
Remove seconds from eve clock in menu bar

See merge request allianceauth/allianceauth!1365
2021-11-20 01:26:24 +00:00
Ariel Rin
7ad9b52546 Merge branch 'fix_tox_ini' into 'v2.9.x'
Fix tox environment config

See merge request allianceauth/allianceauth!1364
2021-11-20 01:16:13 +00:00
Adarnof
abb5090d63 Use danger and error message tags to render correctly on admin site.
Closes #1305


(cherry picked from commit f17ebbede6)
2021-11-20 01:14:07 +00:00
Ariel Rin
52ae05d057 Merge branch 'messages' into 'master'
Use danger and error tags to render messages correctly on admin site.

See merge request allianceauth/allianceauth!1362
2021-11-20 01:13:27 +00:00
ErikKalkoken
3cd216d119 Remove seconds from eve clock in menu bar 2021-11-11 21:14:48 +01:00
ErikKalkoken
581edc0a38 Fix environment config in tox.ini 2021-11-11 17:37:27 +01:00
Adarnof
f17ebbede6 Use danger and error message tags to render correctly on admin site.
Closes #1305
2021-11-03 21:02:03 -04:00
Ariel Rin
2bd2c09c23 Version Bump 2.9.2 2021-10-30 05:23:48 +00:00
Ariel Rin
c1cf859ec9 Merge branch 'srp-provider' into 'v2.9.x'
Follow up test fixes missed from !1358

See merge request allianceauth/allianceauth!1361
2021-10-30 05:22:38 +00:00
Ariel Rin
1ebf864998 adapt tests 2021-10-30 05:22:38 +00:00
Ariel Rin
0948e34e48 Merge branch 'transifex' into 'v2.9.x'
Update from Transifex

See merge request allianceauth/allianceauth!1360
2021-10-30 04:54:00 +00:00
Ariel Rin
f9a1ea9c83 Update from Transifex 2021-10-30 04:54:00 +00:00
Ariel Rin
d02a8ebc1b Merge branch 'dependencies' into 'v2.9.x'
Dependencies, CDN imports, CSS optimization

See merge request allianceauth/allianceauth!1350
2021-10-30 04:35:55 +00:00
Ariel Rin
5c2625b648 Dependencies, CDN imports, CSS optimization 2021-10-30 04:35:55 +00:00
Ariel Rin
13174d006e Merge branch 'issue/1211-feature-request-optimer-labels' into 'v2.9.x'
Add types and description to optimers

Closes #1211

See merge request allianceauth/allianceauth!1357
2021-10-30 04:34:49 +00:00
Peter Pfeufer
2c59cc4cc3 Add types and description to optimers 2021-10-30 04:34:49 +00:00
Ariel Rin
41c81d3226 Merge branch 'chunkupdateerrortype' into 'v2.9.x'
Correct logging of affiliation endpoint to Info not Error

See merge request allianceauth/allianceauth!1359
2021-10-30 04:32:12 +00:00
Ariel Rin
2ec7d3b4d9 Merge branch 'srp-provider' into 'v2.9.x'
Decouple allianceauth.srp ESI Provider from allianceauth.eveonline

See merge request allianceauth/allianceauth!1358
2021-10-30 04:29:28 +00:00
Ariel Rin
b607b73598 chunk updates to be info, not error 2021-10-30 12:43:49 +10:00
Ariel Rin
f52791cd1f document swagger spec operations 2021-10-27 18:18:23 +10:00
Ariel Rin
d829facbd4 use own esi provider for itemtypes 2021-10-27 18:14:18 +10:00
Ariel Rin
44ac3a9ff2 decouple srp provider from eveonline 2021-10-27 17:54:14 +10:00
Ariel Rin
a19302babc Merge branch 'chunking_updates' into 'master'
Breakout Model updates into separate tasks

See merge request allianceauth/allianceauth!1343
2021-10-19 00:49:39 +00:00
Aaron Kable
18a627b01e Breakout Model updates into separate tasks 2021-10-19 00:49:39 +00:00
Ariel Rin
eddb5480e9 Merge branch 'v2.9.x' into 'master'
Bring up Master to 2.9.0

See merge request allianceauth/allianceauth!1344
2021-10-18 01:58:24 +00:00
Ariel Rin
5b26757662 Merge branch 'features/kick-from-discord-admin' into 'master'
Add override to delete the user from discord itself

See merge request allianceauth/allianceauth!1337
2021-10-17 07:34:22 +00:00
214 changed files with 9913 additions and 3687 deletions

View File

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

2
.gitignore vendored
View File

@@ -38,7 +38,6 @@ htmlcov/
.tox/ .tox/
.coverage .coverage
.cache .cache
nosetests.xml
coverage.xml coverage.xml
# Translations # Translations
@@ -77,3 +76,4 @@ celerybeat-schedule
.flake8 .flake8
.pylintrc .pylintrc
Makefile Makefile
.isort.cfg

View File

@@ -1,8 +1,15 @@
.only-default: &only-default
only:
- master
- branches
- merge_requests
stages: stages:
- pre-commit - pre-commit
- gitlab - gitlab
- test - test
- deploy - deploy
- docker
include: include:
- template: Dependency-Scanning.gitlab-ci.yml - template: Dependency-Scanning.gitlab-ci.yml
@@ -15,6 +22,7 @@ before_script:
- pip install wheel tox - pip install wheel tox
pre-commit-check: pre-commit-check:
<<: *only-default
stage: pre-commit stage: pre-commit
image: python:3.6-buster image: python:3.6-buster
variables: variables:
@@ -39,62 +47,136 @@ dependency_scanning:
- pip install wheel tox - pip install wheel tox
test-3.7-core: test-3.7-core:
<<: *only-default
image: python:3.7-bullseye image: python:3.7-bullseye
script: script:
- tox -e py37-core - tox -e py37-core
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-core: test-3.8-core:
<<: *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:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-core: test-3.9-core:
<<: *only-default
image: python:3.9-bullseye image: python:3.9-bullseye
script: script:
- tox -e py39-core - tox -e py39-core
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-core:
<<: *only-default
image: python:3.10-bullseye
script:
- tox -e py310-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.11-core:
<<: *only-default
image: python:3.11-rc-bullseye
script:
- tox -e py311-core
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
allow_failure: true
test-3.7-all: test-3.7-all:
<<: *only-default
image: python:3.7-bullseye image: python:3.7-bullseye
script: script:
- tox -e py37-all - tox -e py37-all
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.8-all: test-3.8-all:
<<: *only-default
image: python:3.8-bullseye image: python:3.8-bullseye
script: script:
- tox -e py38-all - tox -e py38-all
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.9-all: test-3.9-all:
<<: *only-default
image: python:3.9-bullseye image: python:3.9-bullseye
script: script:
- tox -e py39-all - tox -e py39-all
artifacts: artifacts:
when: always when: always
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.10-all:
<<: *only-default
image: python:3.10-bullseye
script:
- tox -e py310-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
test-3.11-all:
<<: *only-default
image: python:3.11-rc-bullseye
script:
- tox -e py311-all
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
allow_failure: true
test-docs:
<<: *only-default
image: python:3.9-bullseye
script:
- tox -e docs
deploy_production: deploy_production:
stage: deploy stage: deploy
image: python:3.9-bullseye image: python:3.10-bullseye
before_script: before_script:
- pip install twine wheel - pip install twine wheel
@@ -105,3 +187,65 @@ deploy_production:
rules: rules:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
build-image:
before_script: []
image: docker:20.10.10
stage: docker
services:
- docker:20.10.10-dind
script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_SHORT_SHA
CURRENT_TAG=$CI_REGISTRY_IMAGE/auth:$CI_COMMIT_TAG
MINOR_TAG=$CI_REGISTRY_IMAGE/auth:$(echo $CI_COMMIT_TAG | cut -d '.' -f 1-2)
MAJOR_TAG=$CI_REGISTRY_IMAGE/auth:$(echo $CI_COMMIT_TAG | cut -d '.' -f 1)
LATEST_TAG=$CI_REGISTRY_IMAGE/auth:latest
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker build . -t $IMAGE_TAG -f docker/Dockerfile --build-arg AUTH_VERSION=$(echo $CI_COMMIT_TAG | cut -c 2-)
docker tag $IMAGE_TAG $CURRENT_TAG
docker tag $IMAGE_TAG $MINOR_TAG
docker tag $IMAGE_TAG $MAJOR_TAG
docker tag $IMAGE_TAG $LATEST_TAG
docker image push --all-tags $CI_REGISTRY_IMAGE/auth
rules:
- if: $CI_COMMIT_TAG
build-image-dev:
before_script: []
image: docker:20.10.10
stage: docker
services:
- docker:20.10.10-dind
script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker build . -t $IMAGE_TAG -f docker/Dockerfile --build-arg AUTH_PACKAGE=git+https://gitlab.com/allianceauth/allianceauth@$CI_COMMIT_BRANCH
docker push $IMAGE_TAG
rules:
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == ""'
when: manual
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME != ""'
when: never
build-image-mr:
before_script: []
image: docker:20.10.10
stage: docker
services:
- docker:20.10.10-dind
script: |
CURRENT_DATE=$(echo $CI_COMMIT_TIMESTAMP | head -c 10 | tr -d -)
IMAGE_TAG=$CI_REGISTRY_IMAGE/auth:$CURRENT_DATE-$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME-$CI_COMMIT_SHORT_SHA
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker build . -t $IMAGE_TAG -f docker/Dockerfile --build-arg AUTH_PACKAGE=git+$CI_MERGE_REQUEST_SOURCE_PROJECT_URL@$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
docker push $IMAGE_TAG
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: manual
- if: '$CI_PIPELINE_SOURCE != "merge_request_event"'
when: never

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__ = '2.9.1' __version__ = '2.15.1'
__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

@@ -1,5 +1,6 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from .models import AnalyticsTokens, AnalyticsIdentifier from .models import AnalyticsTokens, AnalyticsIdentifier
from .tasks import send_ga_tracking_web_view from .tasks import send_ga_tracking_web_view
@@ -10,6 +11,8 @@ import re
class AnalyticsMiddleware(MiddlewareMixin): class AnalyticsMiddleware(MiddlewareMixin):
def process_response(self, request, response): def process_response(self, request, response):
"""Django Middleware: Process Page Views and creates Analytics Celery Tasks""" """Django Middleware: Process Page Views and creates Analytics Celery Tasks"""
if getattr(settings, "ANALYTICS_DISABLED", False):
return response
analyticstokens = AnalyticsTokens.objects.all() analyticstokens = AnalyticsTokens.objects.all()
client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
try: try:

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
from allianceauth.analytics.tasks import analytics_event
from celery.signals import task_failure, task_success
import logging import logging
from celery.signals import task_failure, task_success
from django.conf import settings
from allianceauth.analytics.tasks import analytics_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,6 +12,8 @@ def process_failure_signal(
sender, task_id, signal, sender, task_id, signal,
args, kwargs, einfo, **kw): args, kwargs, einfo, **kw):
logger.debug("Celery task_failure signal %s" % sender.__class__.__name__) logger.debug("Celery task_failure signal %s" % sender.__class__.__name__)
if getattr(settings, "ANALYTICS_DISABLED", False):
return
category = sender.__module__ category = sender.__module__
@@ -30,6 +33,8 @@ def process_failure_signal(
@task_success.connect @task_success.connect
def celery_success_signal(sender, result=None, **kw): def celery_success_signal(sender, result=None, **kw):
logger.debug("Celery task_success signal %s" % sender.__class__.__name__) logger.debug("Celery task_success signal %s" % sender.__class__.__name__)
if getattr(settings, "ANALYTICS_DISABLED", False):
return
category = sender.__module__ category = sender.__module__

View File

@@ -21,8 +21,8 @@ if getattr(settings, "ANALYTICS_ENABLE_DEBUG", False) and settings.DEBUG:
# Force sending of analytics data during in a debug/test environemt # Force sending of analytics data during in a debug/test environemt
# Usefull for developers working on this feature. # Usefull for developers working on this feature.
logger.warning( logger.warning(
"You have 'ANALYTICS_ENABLE_DEBUG' Enabled! " "You have 'ANALYTICS_ENABLE_DEBUG' Enabled! "
"This debug instance will send analytics data!") "This debug instance will send analytics data!")
DEBUG_URL = COLLECTION_URL DEBUG_URL = COLLECTION_URL
ANALYTICS_URL = COLLECTION_URL ANALYTICS_URL = COLLECTION_URL
@@ -40,13 +40,12 @@ def analytics_event(category: str,
Send a Google Analytics Event for each token stored Send a Google Analytics Event for each token stored
Includes check for if its enabled/disabled Includes check for if its enabled/disabled
Parameters Args:
------- `category` (str): Celery Namespace
`category` (str): Celery Namespace `action` (str): Task Name
`action` (str): Task Name `label` (str): Optional, Task Success/Exception
`label` (str): Optional, Task Success/Exception `value` (int): Optional, If bulk, Query size, can be a binary True/False
`value` (int): Optional, If bulk, Query size, can be a binary True/False `event_type` (str): Optional, Celery or Stats only, Default to Celery
`event_type` (str): Optional, Celery or Stats only, Default to Celery
""" """
analyticstokens = AnalyticsTokens.objects.all() analyticstokens = AnalyticsTokens.objects.all()
client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex
@@ -60,20 +59,21 @@ def analytics_event(category: str,
if allowed is True: if allowed is True:
tracking_id = token.token tracking_id = token.token
send_ga_tracking_celery_event.s(tracking_id=tracking_id, send_ga_tracking_celery_event.s(
client_id=client_id, tracking_id=tracking_id,
category=category, client_id=client_id,
action=action, category=category,
label=label, action=action,
value=value).\ label=label,
apply_async(priority=9) value=value).apply_async(priority=9)
@shared_task() @shared_task()
def analytics_daily_stats(): def analytics_daily_stats():
"""Celery Task: Do not call directly """Celery Task: Do not call directly
Gathers a series of daily statistics and sends analytics events containing them""" Gathers a series of daily statistics and sends analytics events containing them
"""
users = install_stat_users() users = install_stat_users()
tokens = install_stat_tokens() tokens = install_stat_tokens()
addons = install_stat_addons() addons = install_stat_addons()

View File

@@ -0,0 +1,109 @@
from unittest.mock import patch
from urllib.parse import parse_qs
import requests_mock
from django.test import override_settings
from allianceauth.analytics.tasks import ANALYTICS_URL
from allianceauth.eveonline.tasks import update_character
from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.utils.testing import NoSocketsTestCase
@override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock()
class TestAnalyticsForViews(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False)
def test_should_run_analytics(self, requests_mocker):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
self.client.force_login(user)
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, 200)
self.assertTrue(requests_mocker.called)
@override_settings(ANALYTICS_DISABLED=True)
def test_should_not_run_analytics(self, requests_mocker):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
self.client.force_login(user)
# when
response = self.client.get("/dashboard/")
# then
self.assertEqual(response.status_code, 200)
self.assertFalse(requests_mocker.called)
@override_settings(CELERY_ALWAYS_EAGER=True)
@requests_mock.mock()
class TestAnalyticsForTasks(NoSocketsTestCase):
@override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_successful_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertTrue(requests_mocker.called)
payload = parse_qs(requests_mocker.last_request.text)
self.assertListEqual(payload["el"], ["Success"])
@override_settings(ANALYTICS_DISABLED=True)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_not_run_analytics_for_successful_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertFalse(requests_mocker.called)
@override_settings(ANALYTICS_DISABLED=False)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_run_analytics_for_failed_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
mock_update_character.side_effect = RuntimeError
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertTrue(requests_mocker.called)
payload = parse_qs(requests_mocker.last_request.text)
self.assertNotEqual(payload["el"], ["Success"])
@override_settings(ANALYTICS_DISABLED=True)
@patch("allianceauth.eveonline.models.EveCharacter.objects.update_character")
def test_should_not_run_analytics_for_failed_task(
self, requests_mocker, mock_update_character
):
# given
requests_mocker.post(ANALYTICS_URL)
mock_update_character.side_effect = RuntimeError
user = AuthUtils.create_user("Bruce Wayne")
character = AuthUtils.add_main_character_2(user, "Bruce Wayne", 1001)
# when
update_character.delay(character.character_id)
# then
self.assertTrue(mock_update_character.called)
self.assertFalse(requests_mocker.called)

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

@@ -1,26 +1,44 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User as BaseUser, \ from django.contrib.auth.models import Group
Permission as BasePermission, Group from django.contrib.auth.models import Permission as BasePermission
from django.contrib.auth.models import User as BaseUser
from django.db.models import Count, Q from django.db.models import Count, Q
from allianceauth.services.hooks import ServicesHook
from django.db.models.signals import pre_save, post_save, pre_delete, \
post_delete, m2m_changed
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save
)
from django.dispatch import receiver from django.dispatch import receiver
from django.forms import ModelForm
from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html
from django.utils.text import slugify from django.utils.text import slugify
from allianceauth.authentication.models import State, get_guest_state,\ from allianceauth.authentication.models import (
CharacterOwnership, UserProfile, OwnershipRecord CharacterOwnership,
from allianceauth.hooks import get_hooks OwnershipRecord,
from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\ State,
EveAllianceInfo, EveFactionInfo UserProfile,
get_guest_state
)
from allianceauth.eveonline.models import (
EveAllianceInfo,
EveCharacter,
EveCorporationInfo,
EveFactionInfo
)
from allianceauth.eveonline.tasks import update_character from allianceauth.eveonline.tasks import update_character
from .app_settings import AUTHENTICATION_ADMIN_USERS_MAX_GROUPS, \ from allianceauth.hooks import get_hooks
AUTHENTICATION_ADMIN_USERS_MAX_CHARS from allianceauth.services.hooks import ServicesHook
from .app_settings import (
AUTHENTICATION_ADMIN_USERS_MAX_CHARS,
AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
)
from .forms import UserChangeForm, UserProfileForm
def make_service_hooks_update_groups_action(service): def make_service_hooks_update_groups_action(service):
@@ -59,19 +77,10 @@ def make_service_hooks_sync_nickname_action(service):
return sync_nickname return sync_nickname
class QuerysetModelForm(ModelForm):
# allows specifying FK querysets through kwarg
def __init__(self, querysets=None, *args, **kwargs):
querysets = querysets or {}
super().__init__(*args, **kwargs)
for field, qs in querysets.items():
self.fields[field].queryset = qs
class UserProfileInline(admin.StackedInline): class UserProfileInline(admin.StackedInline):
model = UserProfile model = UserProfile
readonly_fields = ('state',) readonly_fields = ('state',)
form = QuerysetModelForm form = UserProfileForm
verbose_name = '' verbose_name = ''
verbose_name_plural = 'Profile' verbose_name_plural = 'Profile'
@@ -99,6 +108,7 @@ class UserProfileInline(admin.StackedInline):
return False return False
@admin.display(description="")
def user_profile_pic(obj): def user_profile_pic(obj):
"""profile pic column data for user objects """profile pic column data for user objects
@@ -111,13 +121,10 @@ def user_profile_pic(obj):
'<img src="{}" class="img-circle">', '<img src="{}" class="img-circle">',
user_obj.profile.main_character.portrait_url(size=32) user_obj.profile.main_character.portrait_url(size=32)
) )
else: return None
return None
user_profile_pic.short_description = ''
@admin.display(description="user / main", ordering="username")
def user_username(obj): def user_username(obj):
"""user column data for user objects """user column data for user objects
@@ -139,18 +146,17 @@ def user_username(obj):
user_obj.username, user_obj.username,
user_obj.profile.main_character.character_name user_obj.profile.main_character.character_name
) )
else: return format_html(
return format_html( '<strong><a href="{}">{}</a></strong>',
'<strong><a href="{}">{}</a></strong>', link,
link, user_obj.username,
user_obj.username, )
)
user_username.short_description = 'user / main'
user_username.admin_order_field = 'username'
@admin.display(
description="Corporation / Alliance (Main)",
ordering="profile__main_character__corporation_name"
)
def user_main_organization(obj): def user_main_organization(obj):
"""main organization column data for user objects """main organization column data for user objects
@@ -159,21 +165,15 @@ def user_main_organization(obj):
""" """
user_obj = obj.user if hasattr(obj, 'user') else obj user_obj = obj.user if hasattr(obj, 'user') else obj
if not user_obj.profile.main_character: if not user_obj.profile.main_character:
result = '' return ''
else: result = user_obj.profile.main_character.corporation_name
result = user_obj.profile.main_character.corporation_name if user_obj.profile.main_character.alliance_id:
if user_obj.profile.main_character.alliance_id: result += f'<br>{user_obj.profile.main_character.alliance_name}'
result += f'<br>{user_obj.profile.main_character.alliance_name}' elif user_obj.profile.main_character.faction_name:
elif user_obj.profile.main_character.faction_name: result += f'<br>{user_obj.profile.main_character.faction_name}'
result += f'<br>{user_obj.profile.main_character.faction_name}'
return format_html(result) return format_html(result)
user_main_organization.short_description = 'Corporation / Alliance (Main)'
user_main_organization.admin_order_field = \
'profile__main_character__corporation_name'
class MainCorporationsFilter(admin.SimpleListFilter): class MainCorporationsFilter(admin.SimpleListFilter):
"""Custom filter to filter on corporations from mains only """Custom filter to filter on corporations from mains only
@@ -196,15 +196,13 @@ class MainCorporationsFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(
return qs.filter( profile__main_character__corporation_id=self.value()
profile__main_character__corporation_id=self.value() )
) return qs.filter(
else: user__profile__main_character__corporation_id=self.value()
return qs.filter( )
user__profile__main_character__corporation_id=self.value()
)
class MainAllianceFilter(admin.SimpleListFilter): class MainAllianceFilter(admin.SimpleListFilter):
@@ -217,12 +215,14 @@ class MainAllianceFilter(admin.SimpleListFilter):
parameter_name = 'main_alliance_id__exact' parameter_name = 'main_alliance_id__exact'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = EveCharacter.objects\ qs = (
.exclude(alliance_id=None)\ EveCharacter.objects
.exclude(userprofile=None)\ .exclude(alliance_id=None)
.values('alliance_id', 'alliance_name')\ .exclude(userprofile=None)
.distinct()\ .values('alliance_id', 'alliance_name')
.distinct()
.order_by(Lower('alliance_name')) .order_by(Lower('alliance_name'))
)
return tuple( return tuple(
(x['alliance_id'], x['alliance_name']) for x in qs (x['alliance_id'], x['alliance_name']) for x in qs
) )
@@ -230,13 +230,11 @@ class MainAllianceFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(profile__main_character__alliance_id=self.value())
return qs.filter(profile__main_character__alliance_id=self.value()) return qs.filter(
else: user__profile__main_character__alliance_id=self.value()
return qs.filter( )
user__profile__main_character__alliance_id=self.value()
)
class MainFactionFilter(admin.SimpleListFilter): class MainFactionFilter(admin.SimpleListFilter):
@@ -249,12 +247,14 @@ class MainFactionFilter(admin.SimpleListFilter):
parameter_name = 'main_faction_id__exact' parameter_name = 'main_faction_id__exact'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
qs = EveCharacter.objects\ qs = (
.exclude(faction_id=None)\ EveCharacter.objects
.exclude(userprofile=None)\ .exclude(faction_id=None)
.values('faction_id', 'faction_name')\ .exclude(userprofile=None)
.distinct()\ .values('faction_id', 'faction_name')
.distinct()
.order_by(Lower('faction_name')) .order_by(Lower('faction_name'))
)
return tuple( return tuple(
(x['faction_id'], x['faction_name']) for x in qs (x['faction_id'], x['faction_name']) for x in qs
) )
@@ -262,15 +262,14 @@ class MainFactionFilter(admin.SimpleListFilter):
def queryset(self, request, qs): def queryset(self, request, qs):
if self.value() is None: if self.value() is None:
return qs.all() return qs.all()
else: if qs.model == User:
if qs.model == User: return qs.filter(profile__main_character__faction_id=self.value())
return qs.filter(profile__main_character__faction_id=self.value()) return qs.filter(
else: user__profile__main_character__faction_id=self.value()
return qs.filter( )
user__profile__main_character__faction_id=self.value()
)
@admin.display(description="Update main character model from ESI")
def update_main_character_model(modeladmin, request, queryset): def update_main_character_model(modeladmin, request, queryset):
tasks_count = 0 tasks_count = 0
for obj in queryset: for obj in queryset:
@@ -279,21 +278,48 @@ def update_main_character_model(modeladmin, request, queryset):
tasks_count += 1 tasks_count += 1
modeladmin.message_user( modeladmin.message_user(
request, request, f'Update from ESI started for {tasks_count} characters'
f'Update from ESI started for {tasks_count} characters'
) )
update_main_character_model.short_description = \
'Update main character model from ESI'
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
"""Extending Django's UserAdmin model """Extending Django's UserAdmin model
Behavior of groups and characters columns can be configured via settings Behavior of groups and characters columns can be configured via settings
""" """
inlines = BaseUserAdmin.inlines + [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
MainFactionFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = ('username', 'character_ownerships__character__character_name')
readonly_fields = ('date_joined', 'last_login')
filter_horizontal = ('groups', 'user_permissions',)
form = UserChangeForm
class Media: class Media:
css = { css = {
"all": ("authentication/css/admin.css",) "all": ("authentication/css/admin.css",)
@@ -303,9 +329,21 @@ class UserAdmin(BaseUserAdmin):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("character_ownerships__character", "groups") return qs.prefetch_related("character_ownerships__character", "groups")
def get_actions(self, request): def get_form(self, request, obj=None, **kwargs):
actions = super(BaseUserAdmin, self).get_actions(request) """Inject current request into change form object."""
MyForm = super().get_form(request, obj, **kwargs)
if obj:
class MyFormInjected(MyForm):
def __new__(cls, *args, **kwargs):
kwargs['request'] = request
return MyForm(*args, **kwargs)
return MyFormInjected
return MyForm
def get_actions(self, request):
actions = super().get_actions(request)
actions[update_main_character_model.__name__] = ( actions[update_main_character_model.__name__] = (
update_main_character_model, update_main_character_model,
update_main_character_model.__name__, update_main_character_model.__name__,
@@ -349,38 +387,6 @@ class UserAdmin(BaseUserAdmin):
) )
return result return result
inlines = BaseUserAdmin.inlines + [UserProfileInline]
ordering = ('username', )
list_select_related = ('profile__state', 'profile__main_character')
show_full_result_count = True
list_display = (
user_profile_pic,
user_username,
'_state',
'_groups',
user_main_organization,
'_characters',
'is_active',
'date_joined',
'_role'
)
list_display_links = None
list_filter = (
'profile__state',
'groups',
MainCorporationsFilter,
MainAllianceFilter,
MainFactionFilter,
'is_active',
'date_joined',
'is_staff',
'is_superuser'
)
search_fields = (
'username',
'character_ownerships__character__character_name'
)
def _characters(self, obj): def _characters(self, obj):
character_ownerships = list(obj.character_ownerships.all()) character_ownerships = list(obj.character_ownerships.all())
characters = [obj.character.character_name for obj in character_ownerships] characters = [obj.character.character_name for obj in character_ownerships]
@@ -389,22 +395,16 @@ class UserAdmin(BaseUserAdmin):
AUTHENTICATION_ADMIN_USERS_MAX_CHARS AUTHENTICATION_ADMIN_USERS_MAX_CHARS
) )
_characters.short_description = 'characters' @admin.display(ordering="profile__state")
def _state(self, obj): def _state(self, obj):
return obj.profile.state.name return obj.profile.state.name
_state.short_description = 'state'
_state.admin_order_field = 'profile__state'
def _groups(self, obj): def _groups(self, obj):
my_groups = sorted(group.name for group in list(obj.groups.all())) my_groups = sorted(group.name for group in list(obj.groups.all()))
return self._list_2_html_w_tooltips( return self._list_2_html_w_tooltips(
my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS my_groups, AUTHENTICATION_ADMIN_USERS_MAX_GROUPS
) )
_groups.short_description = 'groups'
def _role(self, obj): def _role(self, obj):
if obj.is_superuser: if obj.is_superuser:
role = 'Superuser' role = 'Superuser'
@@ -414,8 +414,6 @@ class UserAdmin(BaseUserAdmin):
role = 'User' role = 'User'
return role return role
_role.short_description = 'role'
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_user') return request.user.has_perm('auth.change_user')
@@ -425,12 +423,28 @@ class UserAdmin(BaseUserAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return request.user.has_perm('auth.delete_user') return request.user.has_perm('auth.delete_user')
def get_object(self, *args , **kwargs):
obj = super().get_object(*args , **kwargs)
self.obj = obj # storing current object for use in formfield_for_manytomany
return obj
def formfield_for_manytomany(self, db_field, request, **kwargs): def formfield_for_manytomany(self, db_field, request, **kwargs):
"""overriding this formfield to have sorted lists in the form"""
if db_field.name == "groups": if db_field.name == "groups":
kwargs["queryset"] = Group.objects.all().order_by(Lower('name')) groups_qs = Group.objects.filter(authgroup__states__isnull=True)
obj_state = self.obj.profile.state
if obj_state:
matching_groups_qs = Group.objects.filter(authgroup__states=obj_state)
groups_qs = groups_qs | matching_groups_qs
kwargs["queryset"] = groups_qs.order_by(Lower("name"))
return super().formfield_for_manytomany(db_field, request, **kwargs) return super().formfield_for_manytomany(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
if obj and not request.user.is_superuser:
return self.readonly_fields + (
"is_staff", "is_superuser", "user_permissions"
)
return self.readonly_fields
@admin.register(State) @admin.register(State)
class StateAdmin(admin.ModelAdmin): class StateAdmin(admin.ModelAdmin):
@@ -441,10 +455,9 @@ class StateAdmin(admin.ModelAdmin):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.annotate(user_count=Count("userprofile__id")) return qs.annotate(user_count=Count("userprofile__id"))
@admin.display(description="Users", ordering="user_count")
def _user_count(self, obj): def _user_count(self, obj):
return obj.user_count return obj.user_count
_user_count.short_description = 'Users'
_user_count.admin_order_field = 'user_count'
fieldsets = ( fieldsets = (
(None, { (None, {
@@ -500,13 +513,13 @@ class StateAdmin(admin.ModelAdmin):
) )
return super().get_fieldsets(request, obj=obj) return super().get_fieldsets(request, obj=obj)
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("permissions",)
return self.readonly_fields
class BaseOwnershipAdmin(admin.ModelAdmin): class BaseOwnershipAdmin(admin.ModelAdmin):
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
list_select_related = ( list_select_related = (
'user__profile__state', 'user__profile__main_character', 'character') 'user__profile__state', 'user__profile__main_character', 'character')
list_display = ( list_display = (
@@ -527,6 +540,11 @@ class BaseOwnershipAdmin(admin.ModelAdmin):
MainAllianceFilter, MainAllianceFilter,
) )
class Media:
css = {
"all": ("authentication/css/admin.css",)
}
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj and obj.pk: if obj and obj.pk:
return 'owner_hash', 'character' return 'owner_hash', 'character'

View File

@@ -3,10 +3,14 @@ from django.core.checks import register, Tags
class AuthenticationConfig(AppConfig): class AuthenticationConfig(AppConfig):
name = 'allianceauth.authentication' name = "allianceauth.authentication"
label = 'authentication' label = "authentication"
def ready(self): def ready(self):
super().ready() from allianceauth.authentication import checks, signals # noqa: F401
from allianceauth.authentication import checks, signals from allianceauth.authentication.task_statistics import (
signals as celery_signals,
)
register(Tags.security)(checks.check_login_scopes_setting) register(Tags.security)(checks.check_login_scopes_setting)
celery_signals.reset_counters()

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
import datetime as dt
import logging
from typing import List, Optional
from pytz import utc
from redis import Redis, RedisError
from allianceauth.utils.cache import get_redis_client
logger = logging.getLogger(__name__)
class _RedisStub:
"""Stub of a Redis client.
It's purpose is to prevent EventSeries objects from trying to access Redis
when it is not available. e.g. when the Sphinx docs are rendered by readthedocs.org.
"""
def delete(self, *args, **kwargs):
pass
def incr(self, *args, **kwargs):
return 0
def zadd(self, *args, **kwargs):
pass
def zcount(self, *args, **kwargs):
pass
def zrangebyscore(self, *args, **kwargs):
pass
class EventSeries:
"""API for recording and analyzing a series of events."""
_ROOT_KEY = "ALLIANCEAUTH_EVENT_SERIES"
def __init__(self, key_id: str, redis: Redis = None) -> None:
self._redis = get_redis_client() if not redis else redis
try:
if not self._redis.ping():
raise RuntimeError()
except (AttributeError, RedisError, RuntimeError):
logger.exception(
"Failed to establish a connection with Redis. "
"This EventSeries object is disabled.",
)
self._redis = _RedisStub()
self._key_id = str(key_id)
self.clear()
@property
def is_disabled(self):
"""True when this object is disabled, e.g. Redis was not available at startup."""
return isinstance(self._redis, _RedisStub)
@property
def _key_counter(self):
return f"{self._ROOT_KEY}_{self._key_id}_COUNTER"
@property
def _key_sorted_set(self):
return f"{self._ROOT_KEY}_{self._key_id}_SORTED_SET"
def add(self, event_time: dt.datetime = None) -> None:
"""Add event.
Args:
- event_time: timestamp of event. Will use current time if not specified.
"""
if not event_time:
event_time = dt.datetime.utcnow()
id = self._redis.incr(self._key_counter)
self._redis.zadd(self._key_sorted_set, {id: event_time.timestamp()})
def all(self) -> List[dt.datetime]:
"""List of all known events."""
return [
event[1]
for event in self._redis.zrangebyscore(
self._key_sorted_set,
"-inf",
"+inf",
withscores=True,
score_cast_func=self._cast_scores_to_dt,
)
]
def clear(self) -> None:
"""Clear all events."""
self._redis.delete(self._key_sorted_set)
self._redis.delete(self._key_counter)
def count(self, earliest: dt.datetime = None, latest: dt.datetime = None) -> int:
"""Count of events, can be restricted to given timeframe.
Args:
- earliest: Date of first events to count(inclusive), or -infinite if not specified
- latest: Date of last events to count(inclusive), or +infinite if not specified
"""
min = "-inf" if not earliest else earliest.timestamp()
max = "+inf" if not latest else latest.timestamp()
return self._redis.zcount(self._key_sorted_set, min=min, max=max)
def first_event(self, earliest: dt.datetime = None) -> Optional[dt.datetime]:
"""Date/Time of first event. Returns `None` if series has no events.
Args:
- earliest: Date of first events to count(inclusive), or any if not specified
"""
min = "-inf" if not earliest else earliest.timestamp()
event = self._redis.zrangebyscore(
self._key_sorted_set,
min,
"+inf",
withscores=True,
start=0,
num=1,
score_cast_func=self._cast_scores_to_dt,
)
if not event:
return None
return event[0][1]
@staticmethod
def _cast_scores_to_dt(score) -> dt.datetime:
return dt.datetime.fromtimestamp(float(score), tz=utc)

View File

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

View File

@@ -0,0 +1,51 @@
import datetime as dt
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.counters import (
dashboard_results,
succeeded_tasks,
retried_tasks,
failed_tasks,
)
class TestDashboardResults(TestCase):
def test_should_return_counts_for_given_timeframe_only(self):
# given
earliest_task = now() - dt.timedelta(minutes=15)
succeeded_tasks.clear()
succeeded_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
succeeded_tasks.add(earliest_task)
succeeded_tasks.add()
succeeded_tasks.add()
retried_tasks.clear()
retried_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
retried_tasks.add(now() - dt.timedelta(seconds=30))
retried_tasks.add()
failed_tasks.clear()
failed_tasks.add(now() - dt.timedelta(hours=1, seconds=1))
failed_tasks.add()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 3)
self.assertEqual(results.retried, 2)
self.assertEqual(results.failed, 1)
self.assertEqual(results.total, 6)
self.assertEqual(results.earliest_task, earliest_task)
def test_should_work_with_no_data(self):
# given
succeeded_tasks.clear()
retried_tasks.clear()
failed_tasks.clear()
# when
results = dashboard_results(hours=1)
# then
self.assertEqual(results.succeeded, 0)
self.assertEqual(results.retried, 0)
self.assertEqual(results.failed, 0)
self.assertEqual(results.total, 0)
self.assertIsNone(results.earliest_task)

View File

@@ -0,0 +1,168 @@
import datetime as dt
from unittest.mock import patch
from pytz import utc
from redis import RedisError
from django.test import TestCase
from django.utils.timezone import now
from allianceauth.authentication.task_statistics.event_series import (
EventSeries,
_RedisStub,
)
MODULE_PATH = "allianceauth.authentication.task_statistics.event_series"
class TestEventSeries(TestCase):
def test_should_abort_without_redis_client(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock:
mock.return_value = None
events = EventSeries("dummy")
# then
self.assertTrue(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
def test_should_disable_itself_if_redis_not_available_1(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.side_effect = RedisError
events = EventSeries("dummy")
# then
self.assertIsInstance(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
def test_should_disable_itself_if_redis_not_available_2(self):
# when
with patch(MODULE_PATH + ".get_redis_client") as mock_get_master_client:
mock_get_master_client.return_value.ping.return_value = False
events = EventSeries("dummy")
# then
self.assertIsInstance(events._redis, _RedisStub)
self.assertTrue(events.is_disabled)
def test_should_add_event(self):
# given
events = EventSeries("dummy")
# when
events.add()
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], now(), delta=dt.timedelta(seconds=30))
def test_should_add_event_with_specified_time(self):
# given
events = EventSeries("dummy")
my_time = dt.datetime(2021, 11, 1, 12, 15, tzinfo=utc)
# when
events.add(my_time)
# then
result = events.all()
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0], my_time, delta=dt.timedelta(seconds=30))
def test_should_count_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
result = events.count()
# then
self.assertEqual(result, 2)
def test_should_count_zero(self):
# given
events = EventSeries("dummy")
# when
result = events.count()
# then
self.assertEqual(result, 0)
def test_should_count_events_within_timeframe_1(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc),
latest=dt.datetime(2021, 12, 1, 12, 17, tzinfo=utc),
)
# then
self.assertEqual(result, 2)
def test_should_count_events_within_timeframe_2(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(earliest=dt.datetime(2021, 12, 1, 12, 8))
# then
self.assertEqual(result, 3)
def test_should_count_events_within_timeframe_3(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.count(latest=dt.datetime(2021, 12, 1, 12, 12))
# then
self.assertEqual(result, 2)
def test_should_clear_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
events.clear()
# then
self.assertEqual(events.count(), 0)
def test_should_return_date_of_first_event(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event()
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
def test_should_return_date_of_first_event_with_range(self):
# given
events = EventSeries("dummy")
events.add(dt.datetime(2021, 12, 1, 12, 0, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 15, tzinfo=utc))
events.add(dt.datetime(2021, 12, 1, 12, 30, tzinfo=utc))
# when
result = events.first_event(
earliest=dt.datetime(2021, 12, 1, 12, 8, tzinfo=utc)
)
# then
self.assertEqual(result, dt.datetime(2021, 12, 1, 12, 10, tzinfo=utc))
def test_should_return_all_events(self):
# given
events = EventSeries("dummy")
events.add()
events.add()
# when
results = events.all()
# then
self.assertEqual(len(results), 2)

View File

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

View File

@@ -1,6 +1,9 @@
from bs4 import BeautifulSoup
from urllib.parse import quote from urllib.parse import quote
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from django_webtest import WebTest
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
@@ -188,7 +191,7 @@ class TestCaseWithTestData(TestCase):
corporation_id=5432, corporation_id=5432,
corporation_name="Xavier's School for Gifted Youngsters", corporation_name="Xavier's School for Gifted Youngsters",
corporation_ticker='MUTNT', corporation_ticker='MUTNT',
alliance_id = None, alliance_id=None,
faction_id=999, faction_id=999,
faction_name='The X-Men', faction_name='The X-Men',
) )
@@ -206,6 +209,7 @@ class TestCaseWithTestData(TestCase):
cls.user_4.profile.save() cls.user_4.profile.save()
EveFactionInfo.objects.create(faction_id=999, faction_name='The X-Men') EveFactionInfo.objects.create(faction_id=999, faction_name='The X-Men')
def make_generic_search_request(ModelClass: type, search_term: str): def make_generic_search_request(ModelClass: type, search_term: str):
User.objects.create_superuser( User.objects.create_superuser(
username='superuser', password='secret', email='admin@example.com' username='superuser', password='secret', email='admin@example.com'
@@ -218,6 +222,7 @@ def make_generic_search_request(ModelClass: type, search_term: str):
class TestCharacterOwnershipAdmin(TestCaseWithTestData): class TestCharacterOwnershipAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.modeladmin = CharacterOwnershipAdmin( self.modeladmin = CharacterOwnershipAdmin(
@@ -244,6 +249,7 @@ class TestCharacterOwnershipAdmin(TestCaseWithTestData):
class TestOwnershipRecordAdmin(TestCaseWithTestData): class TestOwnershipRecordAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.modeladmin = OwnershipRecordAdmin( self.modeladmin = OwnershipRecordAdmin(
@@ -270,11 +276,12 @@ class TestOwnershipRecordAdmin(TestCaseWithTestData):
class TestStateAdmin(TestCaseWithTestData): class TestStateAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): @classmethod
self.modeladmin = StateAdmin( def setUpClass(cls) -> None:
model=User, admin_site=AdminSite() super().setUpClass()
) cls.modeladmin = StateAdmin(model=User, admin_site=AdminSite())
def test_change_view_loads_normally(self): def test_change_view_loads_normally(self):
User.objects.create_superuser( User.objects.create_superuser(
@@ -299,6 +306,7 @@ class TestStateAdmin(TestCaseWithTestData):
class TestUserAdmin(TestCaseWithTestData): class TestUserAdmin(TestCaseWithTestData):
fixtures = ["disable_analytics"]
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
@@ -344,7 +352,7 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(user_main_organization(self.user_3), expected) self.assertEqual(user_main_organization(self.user_3), expected)
def test_user_main_organization_u4(self): def test_user_main_organization_u4(self):
expected="Xavier's School for Gifted Youngsters<br>The X-Men" expected = "Xavier's School for Gifted Youngsters<br>The X-Men"
self.assertEqual(user_main_organization(self.user_4), expected) self.assertEqual(user_main_organization(self.user_4), expected)
def test_characters_u1(self): def test_characters_u1(self):
@@ -419,7 +427,7 @@ class TestUserAdmin(TestCaseWithTestData):
# actions # actions
@patch(MODULE_PATH + '.UserAdmin.message_user', auto_spec=True) @patch(MODULE_PATH + '.UserAdmin.message_user', auto_spec=True, unsafe=True)
@patch(MODULE_PATH + '.update_character') @patch(MODULE_PATH + '.update_character')
def test_action_update_main_character_model( def test_action_update_main_character_model(
self, mock_task, mock_message_user self, mock_task, mock_message_user
@@ -537,6 +545,229 @@ class TestUserAdmin(TestCaseWithTestData):
self.assertEqual(response.status_code, expected) self.assertEqual(response.status_code, expected)
class TestStateAdminChangeFormSuperuserExclusiveEdits(WebTest):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"authentication.add_state",
"authentication.change_state",
"authentication.view_state",
],
cls.staff_admin
)
cls.superuser_exclusive_fields = ["permissions",]
def test_should_show_all_fields_to_superuser_for_add(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get("/admin/authentication/state/add/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get("/admin/authentication/state/add/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
state = AuthUtils.get_member_state()
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
state = AuthUtils.get_member_state()
page = self.app.get(f"/admin/authentication/state/{state.pk}/change/")
# when
form = page.forms["state_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
class TestUserAdminChangeForm(TestCase):
fixtures = ["disable_analytics"]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.modeladmin = UserAdmin(model=User, admin_site=AdminSite())
def test_should_show_groups_available_to_user_with_blue_state_only(self):
# given
superuser = User.objects.create_superuser("Super")
user = AuthUtils.create_user("bruce_wayne")
character = AuthUtils.add_main_character_2(
user,
name="Bruce Wayne",
character_id=1001,
corp_id=2001,
corp_name="Wayne Technologies"
)
blue_state = State.objects.get(name="Blue")
blue_state.member_characters.add(character)
member_state = AuthUtils.get_member_state()
group_1 = Group.objects.create(name="Group 1")
group_2 = Group.objects.create(name="Group 2")
group_2.authgroup.states.add(blue_state)
group_3 = Group.objects.create(name="Group 3")
group_3.authgroup.states.add(member_state)
self.client.force_login(superuser)
# when
response = self.client.get(f"/admin/authentication/user/{user.pk}/change/")
# then
self.assertEqual(response.status_code, 200)
soup = BeautifulSoup(response.rendered_content, features="html.parser")
groups_select = soup.find("select", {"id": "id_groups"}).find_all('option')
group_ids = {int(option["value"]) for option in groups_select}
self.assertSetEqual(group_ids, {group_1.pk, group_2.pk})
class TestUserAdminChangeFormSuperuserExclusiveEdits(WebTest):
fixtures = ["disable_analytics"]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"auth.change_user",
"auth.view_user",
"authentication.change_user",
"authentication.change_userprofile",
"authentication.view_user"
],
cls.staff_admin
)
cls.superuser_exclusive_fields = [
"is_staff", "is_superuser", "user_permissions"
]
def setUp(self) -> None:
self.user = AuthUtils.create_user("bruce_wayne")
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
# when
form = page.forms["user_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
# when
form = page.forms["user_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_allow_super_admin_to_add_restricted_group_to_user(self):
# given
self.app.set_user(self.super_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["restricted group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 302)
self.user.refresh_from_db()
self.assertIn(
"restricted group", self.user.groups.values_list("name", flat=True)
)
def test_should_not_allow_staff_admin_to_add_restricted_group_to_user(self):
# given
self.app.set_user(self.staff_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["restricted group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 200)
self.assertIn(
"You are not allowed to add or remove these restricted groups",
response.text
)
def test_should_not_allow_staff_admin_to_remove_restricted_group_from_user(self):
# given
self.app.set_user(self.staff_admin)
group_restricted = Group.objects.create(name="restricted group")
group_restricted.authgroup.restricted = True
group_restricted.authgroup.save()
self.user.groups.add(group_restricted)
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=[])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 200)
self.assertIn(
"You are not allowed to add or remove these restricted groups",
response.text
)
def test_should_allow_staff_admin_to_add_normal_group_to_user(self):
# given
self.app.set_user(self.super_admin)
Group.objects.create(name="normal group")
page = self.app.get(f"/admin/authentication/user/{self.user.pk}/change/")
form = page.forms["user_form"]
# when
form["groups"].select_multiple(texts=["normal group"])
response = form.submit("save")
# then
self.assertEqual(response.status_code, 302)
self.user.refresh_from_db()
self.assertIn("normal group", self.user.groups.values_list("name", flat=True))
class TestMakeServicesHooksActions(TestCaseWithTestData): class TestMakeServicesHooksActions(TestCaseWithTestData):
class MyServicesHookTypeA(ServicesHook): class MyServicesHookTypeA(ServicesHook):

View File

@@ -1,6 +1,7 @@
from math import ceil from math import ceil
from unittest.mock import patch from unittest.mock import patch
import requests
import requests_mock import requests_mock
from packaging.version import Version as Pep440Version from packaging.version import Version as Pep440Version
@@ -54,7 +55,6 @@ TEST_VERSION = '2.6.5'
class TestStatusOverviewTag(TestCase): class TestStatusOverviewTag(TestCase):
@patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION) @patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
@patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length') @patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length')
@patch(MODULE_PATH + '.admin_status._current_version_summary') @patch(MODULE_PATH + '.admin_status._current_version_summary')
@@ -65,6 +65,7 @@ class TestStatusOverviewTag(TestCase):
mock_current_version_info, mock_current_version_info,
mock_fetch_celery_queue_length mock_fetch_celery_queue_length
): ):
# given
notifications = { notifications = {
'notifications': GITHUB_NOTIFICATION_ISSUES[:5] 'notifications': GITHUB_NOTIFICATION_ISSUES[:5]
} }
@@ -82,22 +83,20 @@ class TestStatusOverviewTag(TestCase):
} }
mock_current_version_info.return_value = version_info mock_current_version_info.return_value = version_info
mock_fetch_celery_queue_length.return_value = 3 mock_fetch_celery_queue_length.return_value = 3
# when
result = status_overview() result = status_overview()
expected = { # then
'notifications': GITHUB_NOTIFICATION_ISSUES[:5], self.assertEqual(result["notifications"], GITHUB_NOTIFICATION_ISSUES[:5])
'latest_major': True, self.assertTrue(result["latest_major"])
'latest_minor': True, self.assertTrue(result["latest_minor"])
'latest_patch': True, self.assertTrue(result["latest_patch"])
'latest_beta': False, self.assertFalse(result["latest_beta"])
'current_version': TEST_VERSION, self.assertEqual(result["current_version"], TEST_VERSION)
'latest_major_version': '2.4.5', self.assertEqual(result["latest_major_version"], '2.4.5')
'latest_minor_version': '2.4.0', self.assertEqual(result["latest_minor_version"], '2.4.0')
'latest_patch_version': '2.4.5', self.assertEqual(result["latest_patch_version"], '2.4.5')
'latest_beta_version': '2.4.4a1', self.assertEqual(result["latest_beta_version"], '2.4.4a1')
'task_queue_length': 3, self.assertEqual(result["task_queue_length"], 3)
}
self.assertEqual(result, expected)
class TestNotifications(TestCase): class TestNotifications(TestCase):
@@ -307,3 +306,25 @@ class TestFetchListFromGitlab(TestCase):
result = _fetch_list_from_gitlab(self.url, max_pages=max_pages) result = _fetch_list_from_gitlab(self.url, max_pages=max_pages)
self.assertEqual(result, GITHUB_TAGS[:4]) self.assertEqual(result, GITHUB_TAGS[:4])
self.assertEqual(requests_mocker.call_count, max_pages) self.assertEqual(requests_mocker.call_count, max_pages)
@requests_mock.mock()
@patch(MODULE_PATH + '.admin_status.logger')
def test_should_not_raise_any_exception_from_github_request_but_log_as_warning(
self, requests_mocker, mock_logger
):
for my_exception in [
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
requests.exceptions.URLRequired,
requests.exceptions.TooManyRedirects,
requests.exceptions.ConnectTimeout,
requests.exceptions.Timeout,
]:
requests_mocker.get(self.url, exc=my_exception)
try:
result = _fetch_list_from_gitlab(self.url)
except Exception as ex:
self.fail(f"Unexpected exception raised: {ex}")
self.assertTrue(mock_logger.warning.called)
self.assertListEqual(result, [])

View File

@@ -193,6 +193,8 @@
"columnDefs": [ "columnDefs": [
{ "sortable": false, "targets": [1] }, { "sortable": false, "targets": [1] },
], ],
"stateSave": true,
"stateDuration": 0
}); });
$('#table-members').DataTable({ $('#table-members').DataTable({
"columnDefs": [ "columnDefs": [
@@ -200,6 +202,8 @@
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },
], ],
"order": [[ 1, "asc" ]], "order": [[ 1, "asc" ]],
"stateSave": true,
"stateDuration": 0
}); });
$('#table-unregistered').DataTable({ $('#table-unregistered').DataTable({
"columnDefs": [ "columnDefs": [
@@ -207,6 +211,8 @@
{ "sortable": false, "targets": [0, 2] }, { "sortable": false, "targets": [0, 2] },
], ],
"order": [[ 1, "asc" ]], "order": [[ 1, "asc" ]],
"stateSave": true,
"stateDuration": 0
}); });
}); });

View File

@@ -43,6 +43,9 @@
{% endblock %} {% endblock %}
{% block extra_script %} {% block extra_script %}
$(document).ready(function(){ $(document).ready(function(){
$('#table-search').DataTable(); $('#table-search').DataTable({
"stateSave": true,
"stateDuration": 0
});
}); });
{% endblock %} {% endblock %}

View File

@@ -1,13 +1,27 @@
from django.db import models import logging
from typing import Union from typing import Union
from .managers import EveCharacterManager, EveCharacterProviderManager from django.core.exceptions import ObjectDoesNotExist
from .managers import EveCorporationManager, EveCorporationProviderManager from django.db import models
from .managers import EveAllianceManager, EveAllianceProviderManager from esi.models import Token
from allianceauth.notifications import notify
from . import providers from . import providers
from .evelinks import eveimageserver from .evelinks import eveimageserver
from .managers import (
EveAllianceManager,
EveAllianceProviderManager,
EveCharacterManager,
EveCharacterProviderManager,
EveCorporationManager,
EveCorporationProviderManager,
)
logger = logging.getLogger(__name__)
_DEFAULT_IMAGE_SIZE = 32 _DEFAULT_IMAGE_SIZE = 32
DOOMHEIM_CORPORATION_ID = 1000001
class EveFactionInfo(models.Model): class EveFactionInfo(models.Model):
@@ -68,13 +82,12 @@ class EveAllianceInfo(models.Model):
for corp_id in alliance.corp_ids: for corp_id in alliance.corp_ids:
if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists(): if not EveCorporationInfo.objects.filter(corporation_id=corp_id).exists():
EveCorporationInfo.objects.create_corporation(corp_id) EveCorporationInfo.objects.create_corporation(corp_id)
EveCorporationInfo.objects.filter( EveCorporationInfo.objects.filter(corporation_id__in=alliance.corp_ids).update(
corporation_id__in=alliance.corp_ids).update(alliance=self alliance=self
) )
EveCorporationInfo.objects\ EveCorporationInfo.objects.filter(alliance=self).exclude(
.filter(alliance=self)\ corporation_id__in=alliance.corp_ids
.exclude(corporation_id__in=alliance.corp_ids)\ ).update(alliance=None)
.update(alliance=None)
def update_alliance(self, alliance: providers.Alliance = None): def update_alliance(self, alliance: providers.Alliance = None):
if alliance is None: if alliance is None:
@@ -182,6 +195,7 @@ class EveCorporationInfo(models.Model):
class EveCharacter(models.Model): class EveCharacter(models.Model):
"""Character in Eve Online"""
character_id = models.PositiveIntegerField(unique=True) character_id = models.PositiveIntegerField(unique=True)
character_name = models.CharField(max_length=254, unique=True) character_name = models.CharField(max_length=254, unique=True)
corporation_id = models.PositiveIntegerField() corporation_id = models.PositiveIntegerField()
@@ -198,12 +212,20 @@ class EveCharacter(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=['corporation_id',]), models.Index(fields=['corporation_id',]),
models.Index(fields=['alliance_id',]), models.Index(fields=['alliance_id',]),
models.Index(fields=['corporation_name',]), models.Index(fields=['corporation_name',]),
models.Index(fields=['alliance_name',]), models.Index(fields=['alliance_name',]),
models.Index(fields=['faction_id',]), models.Index(fields=['faction_id',]),
] ]
def __str__(self):
return self.character_name
@property
def is_biomassed(self) -> bool:
"""Whether this character is dead or not."""
return self.corporation_id == DOOMHEIM_CORPORATION_ID
@property @property
def alliance(self) -> Union[EveAllianceInfo, None]: def alliance(self) -> Union[EveAllianceInfo, None]:
@@ -249,10 +271,36 @@ class EveCharacter(models.Model):
self.faction_id = character.faction.id self.faction_id = character.faction.id
self.faction_name = character.faction.name self.faction_name = character.faction.name
self.save() self.save()
if self.is_biomassed:
self._remove_tokens_of_biomassed_character()
return self return self
def __str__(self): def _remove_tokens_of_biomassed_character(self) -> None:
return self.character_name """Remove tokens of this biomassed character."""
try:
user = self.character_ownership.user
except ObjectDoesNotExist:
return
tokens_to_delete = Token.objects.filter(character_id=self.character_id)
tokens_count = tokens_to_delete.count()
if not tokens_count:
return
tokens_to_delete.delete()
logger.info(
"%d tokens from user %s for biomassed character %s [id:%s] deleted.",
tokens_count,
user,
self,
self.character_id,
)
notify(
user=user,
title=f"Character {self} biomassed",
message=(
f"Your former character {self} has been biomassed "
"and has been removed from the list of your alts."
)
)
@staticmethod @staticmethod
def generic_portrait_url( def generic_portrait_url(
@@ -336,7 +384,6 @@ class EveCharacter(models.Model):
"""image URL for alliance of this character or empty string""" """image URL for alliance of this character or empty string"""
return self.alliance_logo_url(256) return self.alliance_logo_url(256)
def faction_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str: def faction_logo_url(self, size=_DEFAULT_IMAGE_SIZE) -> str:
"""image URL for alliance of this character or empty string""" """image URL for alliance of this character or empty string"""
if self.faction_id: if self.faction_id:

View File

@@ -13,17 +13,18 @@ from allianceauth import __version__
SWAGGER_SPEC_PATH = os.path.join(os.path.dirname( SWAGGER_SPEC_PATH = os.path.join(os.path.dirname(
os.path.abspath(__file__)), 'swagger.json' os.path.abspath(__file__)), 'swagger.json'
) )
"""
Swagger spec operations:
get_alliances_alliance_id # for the love of Bob please add operations you use here. I'm tired of breaking undocumented things.
get_alliances_alliance_id_corporations ESI_OPERATIONS=[
get_corporations_corporation_id 'get_alliances_alliance_id',
get_characters_character_id 'get_alliances_alliance_id_corporations',
get_universe_types_type_id 'get_corporations_corporation_id',
post_character_affiliation 'get_characters_character_id',
get_universe_factions 'post_characters_affiliation',
""" 'get_universe_types_type_id',
'get_universe_factions',
'post_universe_names',
]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -169,7 +170,7 @@ class EveProvider:
""" """
:return: an ItemType object for the given ID :return: an ItemType object for the given ID
""" """
raise NotImplemented() raise NotImplementedError()
class EveSwaggerProvider(EveProvider): class EveSwaggerProvider(EveProvider):
@@ -206,7 +207,8 @@ class EveSwaggerProvider(EveProvider):
def __str__(self): def __str__(self):
return 'esi' return 'esi'
def get_alliance(self, alliance_id): def get_alliance(self, alliance_id: int) -> Alliance:
"""Fetch alliance from ESI."""
try: try:
data = self.client.Alliance.get_alliances_alliance_id(alliance_id=alliance_id).result() data = self.client.Alliance.get_alliances_alliance_id(alliance_id=alliance_id).result()
corps = self.client.Alliance.get_alliances_alliance_id_corporations(alliance_id=alliance_id).result() corps = self.client.Alliance.get_alliances_alliance_id_corporations(alliance_id=alliance_id).result()
@@ -222,7 +224,8 @@ class EveSwaggerProvider(EveProvider):
except HTTPNotFound: except HTTPNotFound:
raise ObjectNotFound(alliance_id, 'alliance') raise ObjectNotFound(alliance_id, 'alliance')
def get_corp(self, corp_id): def get_corp(self, corp_id: int) -> Corporation:
"""Fetch corporation from ESI."""
try: try:
data = self.client.Corporation.get_corporations_corporation_id(corporation_id=corp_id).result() data = self.client.Corporation.get_corporations_corporation_id(corporation_id=corp_id).result()
model = Corporation( model = Corporation(
@@ -238,29 +241,43 @@ class EveSwaggerProvider(EveProvider):
except HTTPNotFound: except HTTPNotFound:
raise ObjectNotFound(corp_id, 'corporation') raise ObjectNotFound(corp_id, 'corporation')
def get_character(self, character_id): def get_character(self, character_id: int) -> Character:
"""Fetch character from ESI."""
try: try:
data = self.client.Character.get_characters_character_id(character_id=character_id).result() character_name = self._fetch_character_name(character_id)
affiliation = self.client.Character.post_characters_affiliation(characters=[character_id]).result()[0] affiliation = self.client.Character.post_characters_affiliation(characters=[character_id]).result()[0]
model = Character( model = Character(
id=character_id, id=character_id,
name=data['name'], name=character_name,
corp_id=affiliation['corporation_id'], corp_id=affiliation['corporation_id'],
alliance_id=affiliation['alliance_id'] if 'alliance_id' in affiliation else None, alliance_id=affiliation['alliance_id'] if 'alliance_id' in affiliation else None,
faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None, faction_id=affiliation['faction_id'] if 'faction_id' in affiliation else None,
) )
return model return model
except (HTTPNotFound, HTTPUnprocessableEntity): except (HTTPNotFound, HTTPUnprocessableEntity, ObjectNotFound):
raise ObjectNotFound(character_id, 'character') raise ObjectNotFound(character_id, 'character')
def _fetch_character_name(self, character_id: int) -> str:
"""Fetch character name from ESI."""
data = self.client.Universe.post_universe_names(ids=[character_id]).result()
character = data.pop() if data else None
if (
not character
or character["category"] != "character"
or character["id"] != character_id
):
raise ObjectNotFound(character_id, 'character')
return character["name"]
def get_all_factions(self): def get_all_factions(self):
"""Fetch all factions from ESI."""
if not self._faction_list: if not self._faction_list:
self._faction_list = self.client.Universe.get_universe_factions().result() self._faction_list = self.client.Universe.get_universe_factions().result()
return self._faction_list return self._faction_list
def get_faction(self, faction_id): def get_faction(self, faction_id: int):
faction_id=int(faction_id) """Fetch faction from ESI."""
faction_id = int(faction_id)
try: try:
if not self._faction_list: if not self._faction_list:
_ = self.get_all_factions() _ = self.get_all_factions()
@@ -272,7 +289,8 @@ class EveSwaggerProvider(EveProvider):
except (HTTPNotFound, HTTPUnprocessableEntity, KeyError): except (HTTPNotFound, HTTPUnprocessableEntity, KeyError):
raise ObjectNotFound(faction_id, 'faction') raise ObjectNotFound(faction_id, 'faction')
def get_itemtype(self, type_id): def get_itemtype(self, type_id: int) -> ItemType:
"""Fetch inventory item from ESI."""
try: try:
data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result() data = self.client.Universe.get_universe_types_type_id(type_id=type_id).result()
return ItemType(id=type_id, name=data['name']) return ItemType(id=type_id, name=data['name'])

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,11 @@
import logging import logging
from celery import shared_task from celery import shared_task
from .models import EveAllianceInfo
from .models import EveCharacter
from .models import EveCorporationInfo
from .models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from . import providers from . import providers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TASK_PRIORITY = 7 TASK_PRIORITY = 7
@@ -32,8 +31,8 @@ def update_alliance(alliance_id):
@shared_task @shared_task
def update_character(character_id): def update_character(character_id: int) -> None:
"""Update given character from ESI""" """Update given character from ESI."""
EveCharacter.objects.update_character(character_id) EveCharacter.objects.update_character(character_id)
@@ -49,7 +48,6 @@ def run_model_update():
for alliance in EveAllianceInfo.objects.all().values('alliance_id'): for alliance in EveAllianceInfo.objects.all().values('alliance_id'):
update_alliance.apply_async(args=[alliance['alliance_id']], priority=TASK_PRIORITY) update_alliance.apply_async(args=[alliance['alliance_id']], priority=TASK_PRIORITY)
#update existing character models if required
# update existing character models # update existing character models
character_ids = EveCharacter.objects.all().values_list('character_id', flat=True) character_ids = EveCharacter.objects.all().values_list('character_id', flat=True)
for character_ids_chunk in chunks(character_ids, CHUNK_SIZE): for character_ids_chunk in chunks(character_ids, CHUNK_SIZE):
@@ -66,17 +64,17 @@ def update_character_chunk(character_ids_chunk: list):
.post_characters_affiliation(characters=character_ids_chunk).result() .post_characters_affiliation(characters=character_ids_chunk).result()
character_names = providers.provider.client.Universe\ character_names = providers.provider.client.Universe\
.post_universe_names(ids=character_ids_chunk).result() .post_universe_names(ids=character_ids_chunk).result()
except: except OSError:
logger.error("Failed to bulk update characters. Attempting single updates") logger.info("Failed to bulk update characters. Attempting single updates")
for character_id in character_ids_chunk: for character_id in character_ids_chunk:
update_character.apply_async( update_character.apply_async(
args=[character_id], priority=TASK_PRIORITY args=[character_id], priority=TASK_PRIORITY
) )
return return
affiliations = { affiliations = {
affiliation.get('character_id'): affiliation affiliation.get('character_id'): affiliation
for affiliation in affiliations_raw for affiliation in affiliations_raw
} }
# add character names to affiliations # add character names to affiliations
for character in character_names: for character in character_names:
@@ -109,5 +107,5 @@ def update_character_chunk(character_ids_chunk: list):
if corp_changed or alliance_changed or name_changed: if corp_changed or alliance_changed or name_changed:
update_character.apply_async( update_character.apply_async(
args=[character.get('character_id')], priority=TASK_PRIORITY args=[character.get('character_id')], priority=TASK_PRIORITY
) )

View File

@@ -0,0 +1,168 @@
from bravado.exception import HTTPNotFound
class BravadoResponseStub:
"""Stub for IncomingResponse in bravado, e.g. for HTTPError exceptions"""
def __init__(
self, status_code, reason="", text="", headers=None, raw_bytes=None
) -> None:
self.reason = reason
self.status_code = status_code
self.text = text
self.headers = headers if headers else dict()
self.raw_bytes = raw_bytes
def __str__(self):
return f"{self.status_code} {self.reason}"
class BravadoOperationStub:
"""Stub to simulate the operation object return from bravado via django-esi"""
class RequestConfig:
def __init__(self, also_return_response):
self.also_return_response = also_return_response
class ResponseStub:
def __init__(self, headers):
self.headers = headers
def __init__(self, data, headers: dict = None, also_return_response: bool = False):
self._data = data
self._headers = headers if headers else {"x-pages": 1}
self.request_config = BravadoOperationStub.RequestConfig(also_return_response)
def result(self, **kwargs):
if self.request_config.also_return_response:
return [self._data, self.ResponseStub(self._headers)]
else:
return self._data
def results(self, **kwargs):
return self.result(**kwargs)
class EsiClientStub:
"""Stub for an ESI client."""
class Alliance:
@staticmethod
def get_alliances_alliance_id(alliance_id):
data = {
3001: {
"name": "Wayne Enterprises",
"ticker": "WYE",
"executor_corporation_id": 2001
}
}
try:
return BravadoOperationStub(data[int(alliance_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Alliance with ID {alliance_id} not found"
)
raise HTTPNotFound(response)
@staticmethod
def get_alliances_alliance_id_corporations(alliance_id):
data = [2001, 2002, 2003]
return BravadoOperationStub(data)
class Character:
@staticmethod
def get_characters_character_id(character_id):
data = {
1001: {
"corporation_id": 2001,
"name": "Bruce Wayne",
},
1002: {
"corporation_id": 2001,
"name": "Peter Parker",
},
1011: {
"corporation_id": 2011,
"name": "Lex Luthor",
}
}
try:
return BravadoOperationStub(data[int(character_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Character with ID {character_id} not found"
)
raise HTTPNotFound(response)
@staticmethod
def post_characters_affiliation(characters: list):
data = [
{'character_id': 1001, 'corporation_id': 2001, 'alliance_id': 3001},
{'character_id': 1002, 'corporation_id': 2001, 'alliance_id': 3001},
{'character_id': 1011, 'corporation_id': 2011},
{'character_id': 1666, 'corporation_id': 1000001},
]
return BravadoOperationStub(
[x for x in data if x['character_id'] in characters]
)
class Corporation:
@staticmethod
def get_corporations_corporation_id(corporation_id):
data = {
2001: {
"ceo_id": 1091,
"member_count": 10,
"name": "Wayne Technologies",
"ticker": "WTE",
"alliance_id": 3001
},
2002: {
"ceo_id": 1092,
"member_count": 10,
"name": "Wayne Food",
"ticker": "WFO",
"alliance_id": 3001
},
2003: {
"ceo_id": 1093,
"member_count": 10,
"name": "Wayne Energy",
"ticker": "WEG",
"alliance_id": 3001
},
2011: {
"ceo_id": 1,
"member_count": 3,
"name": "LexCorp",
"ticker": "LC",
},
1000001: {
"ceo_id": 3000001,
"creator_id": 1,
"description": "The internal corporation used for characters in graveyard.",
"member_count": 6329026,
"name": "Doomheim",
"ticker": "666",
}
}
try:
return BravadoOperationStub(data[int(corporation_id)])
except KeyError:
response = BravadoResponseStub(
404, f"Corporation with ID {corporation_id} not found"
)
raise HTTPNotFound(response)
class Universe:
@staticmethod
def post_universe_names(ids: list):
data = [
{"category": "character", "id": 1001, "name": "Bruce Wayne"},
{"category": "character", "id": 1002, "name": "Peter Parker"},
{"category": "character", "id": 1011, "name": "Lex Luthor"},
{"category": "character", "id": 1666, "name": "Hal Jordan"},
{"category": "corporation", "id": 2001, "name": "Wayne Technologies"},
{"category": "corporation","id": 2002, "name": "Wayne Food"},
{"category": "corporation","id": 1000001, "name": "Doomheim"},
]
return BravadoOperationStub([x for x in data if x['id'] in ids])

View File

@@ -1,12 +1,15 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase from django.test import TestCase
from esi.models import Token
from allianceauth.tests.auth_utils import AuthUtils
from ..models import (
EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo
)
from ..providers import Alliance, Corporation, Character
from ..evelinks import eveimageserver from ..evelinks import eveimageserver
from ..models import EveAllianceInfo, EveCharacter, EveCorporationInfo, EveFactionInfo
from ..providers import Alliance, Character, Corporation
from .esi_client_stub import EsiClientStub
class EveCharacterTestCase(TestCase): class EveCharacterTestCase(TestCase):
@@ -402,8 +405,8 @@ class EveAllianceTestCase(TestCase):
my_alliance.save() my_alliance.save()
my_alliance.populate_alliance() my_alliance.populate_alliance()
for corporation in EveCorporationInfo.objects\ for corporation in (
.filter(corporation_id__in=[2001, 2002] EveCorporationInfo.objects.filter(corporation_id__in=[2001, 2002])
): ):
self.assertEqual(corporation.alliance, my_alliance) self.assertEqual(corporation.alliance, my_alliance)
@@ -587,3 +590,98 @@ class EveCorporationTestCase(TestCase):
self.my_corp.logo_url_256, self.my_corp.logo_url_256,
'https://images.evetech.net/corporations/2001/logo?size=256' 'https://images.evetech.net/corporations/2001/logo?size=256'
) )
@patch('allianceauth.eveonline.providers.esi_client_factory')
@patch("allianceauth.eveonline.models.notify")
class TestCharacterUpdate(TestCase):
def test_should_update_normal_character(self, mock_notify, mock_esi_client_factory):
# given
mock_esi_client_factory.return_value = EsiClientStub()
my_character = EveCharacter.objects.create(
character_id=1001,
character_name="not my name",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None
)
# when
my_character.update_character()
# then
my_character.refresh_from_db()
self.assertEqual(my_character.character_name, "Bruce Wayne")
self.assertEqual(my_character.corporation_id, 2001)
self.assertEqual(my_character.corporation_name, "Wayne Technologies")
self.assertEqual(my_character.corporation_ticker, "WTE")
self.assertEqual(my_character.alliance_id, 3001)
self.assertEqual(my_character.alliance_name, "Wayne Enterprises")
self.assertEqual(my_character.alliance_ticker, "WYE")
self.assertFalse(mock_notify.called)
def test_should_update_dead_character_with_owner(
self, mock_notify, mock_esi_client_factory
):
# given
mock_esi_client_factory.return_value = EsiClientStub()
character_1666 = EveCharacter.objects.create(
character_id=1666,
character_name="Hal Jordan",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None
)
user = AuthUtils.create_user("Bruce Wayne")
token_1666 = Token.objects.create(
user=user,
character_id=character_1666.character_id,
character_name=character_1666.character_name,
character_owner_hash="ABC123-1666",
)
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WYT",
alliance_id=None
)
token_1001 = Token.objects.create(
user=user,
character_id=character_1001.character_id,
character_name=character_1001.character_name,
character_owner_hash="ABC123-1001",
)
# when
character_1666.update_character()
# then
character_1666.refresh_from_db()
self.assertTrue(character_1666.is_biomassed)
self.assertNotIn(token_1666, user.token_set.all())
self.assertIn(token_1001, user.token_set.all())
with self.assertRaises(ObjectDoesNotExist):
self.assertTrue(character_1666.character_ownership)
user.profile.refresh_from_db()
self.assertIsNone(user.profile.main_character)
self.assertTrue(mock_notify.called)
def test_should_handle_dead_character_without_owner(
self, mock_notify, mock_esi_client_factory
):
# given
mock_esi_client_factory.return_value = EsiClientStub()
character_1666 = EveCharacter.objects.create(
character_id=1666,
character_name="Hal Jordan",
corporation_id=1011,
corporation_name="LexCorp",
corporation_ticker='LC',
alliance_id=None
)
# when
character_1666.update_character()
# then
character_1666.refresh_from_db()
self.assertTrue(character_1666.is_biomassed)
self.assertFalse(mock_notify.called)

View File

@@ -7,6 +7,7 @@ from jsonschema.exceptions import RefResolutionError
from django.test import TestCase from django.test import TestCase
from . import set_logger from . import set_logger
from .esi_client_stub import EsiClientStub
from ..providers import ( from ..providers import (
ObjectNotFound, ObjectNotFound,
Entity, Entity,
@@ -632,13 +633,7 @@ class TestEveSwaggerProvider(TestCase):
@patch(MODULE_PATH + '.esi_client_factory') @patch(MODULE_PATH + '.esi_client_factory')
def test_get_character(self, mock_esi_client_factory): def test_get_character(self, mock_esi_client_factory):
mock_esi_client_factory.return_value \ mock_esi_client_factory.return_value = EsiClientStub()
.Character.get_characters_character_id \
= TestEveSwaggerProvider.esi_get_characters_character_id
mock_esi_client_factory.return_value \
.Character.post_characters_affiliation \
= TestEveSwaggerProvider.esi_post_characters_affiliation
my_provider = EveSwaggerProvider() my_provider = EveSwaggerProvider()
# character with alliance # character with alliance
@@ -649,8 +644,8 @@ class TestEveSwaggerProvider(TestCase):
self.assertEqual(my_character.alliance_id, 3001) self.assertEqual(my_character.alliance_id, 3001)
# character wo/ alliance # character wo/ alliance
my_character = my_provider.get_character(1002) my_character = my_provider.get_character(1011)
self.assertEqual(my_character.id, 1002) self.assertEqual(my_character.id, 1011)
self.assertEqual(my_character.alliance_id, None) self.assertEqual(my_character.alliance_id, None)
# character not found # character not found

View File

@@ -1,245 +1,271 @@
from unittest.mock import patch, Mock from unittest.mock import patch
from django.test import TestCase from django.test import TestCase, TransactionTestCase, override_settings
from ..models import EveCharacter, EveCorporationInfo, EveAllianceInfo from ..models import EveAllianceInfo, EveCharacter, EveCorporationInfo
from ..tasks import ( from ..tasks import (
run_model_update,
update_alliance, update_alliance,
update_corp,
update_character, update_character,
run_model_update update_character_chunk,
update_corp,
) )
from .esi_client_stub import EsiClientStub
class TestTasks(TestCase): @patch('allianceauth.eveonline.providers.esi_client_factory')
class TestUpdateTasks(TestCase):
@patch('allianceauth.eveonline.tasks.EveCorporationInfo') def test_should_update_alliance(self, mock_esi_client_factory):
def test_update_corp(self, mock_EveCorporationInfo): # given
update_corp(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( my_alliance = EveAllianceInfo.objects.create(
mock_EveCorporationInfo.objects.update_corporation.call_count, 1 alliance_id=3001,
) alliance_name="Wayne Enterprises",
self.assertEqual( alliance_ticker="WYE",
mock_EveCorporationInfo.objects.update_corporation.call_args[0][0], 42 executor_corp_id=2003
) )
# when
update_alliance(my_alliance.alliance_id)
# then
my_alliance.refresh_from_db()
self.assertEqual(my_alliance.executor_corp_id, 2001)
@patch('allianceauth.eveonline.tasks.EveAllianceInfo') def test_should_update_character(self, mock_esi_client_factory):
def test_update_alliance(self, mock_EveAllianceInfo): # given
update_alliance(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( my_character = EveCharacter.objects.create(
mock_EveAllianceInfo.objects.update_alliance.call_args[0][0], 42 character_id=1001,
) character_name="Bruce Wayne",
self.assertEqual( corporation_id=2002,
mock_EveAllianceInfo.objects corporation_name="Wayne Food",
.update_alliance.return_value.populate_alliance.call_count, 1 corporation_ticker="WYF",
alliance_id=None
) )
# when
update_character(my_character.character_id)
# then
my_character.refresh_from_db()
self.assertEqual(my_character.corporation_id, 2001)
@patch('allianceauth.eveonline.tasks.EveCharacter') def test_should_update_corp(self, mock_esi_client_factory):
def test_update_character(self, mock_EveCharacter): # given
update_character(42) mock_esi_client_factory.return_value = EsiClientStub()
self.assertEqual( EveAllianceInfo.objects.create(
mock_EveCharacter.objects.update_character.call_count, 1 alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
executor_corp_id=2003
) )
self.assertEqual( my_corporation = EveCorporationInfo.objects.create(
mock_EveCharacter.objects.update_character.call_args[0][0], 42 corporation_id=2003,
corporation_name="Wayne Food",
corporation_ticker="WFO",
member_count=1,
alliance=None,
ceo_id=1999
) )
# when
update_corp(my_corporation.corporation_id)
# then
my_corporation.refresh_from_db()
self.assertEqual(my_corporation.alliance.alliance_id, 3001)
# @patch('allianceauth.eveonline.tasks.EveCharacter')
# def test_update_character(self, mock_EveCharacter):
# update_character(42)
# self.assertEqual(
# mock_EveCharacter.objects.update_character.call_count, 1
# )
# self.assertEqual(
# mock_EveCharacter.objects.update_character.call_args[0][0], 42
# )
@patch('allianceauth.eveonline.tasks.update_character') @override_settings(CELERY_ALWAYS_EAGER=True)
@patch('allianceauth.eveonline.tasks.update_alliance') @patch('allianceauth.eveonline.providers.esi_client_factory')
@patch('allianceauth.eveonline.tasks.update_corp') @patch('allianceauth.eveonline.tasks.providers')
@patch('allianceauth.eveonline.providers.provider')
@patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2) @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
class TestRunModelUpdate(TestCase): class TestRunModelUpdate(TransactionTestCase):
def test_should_run_updates(self, mock_providers, mock_esi_client_factory):
@classmethod # given
def setUpClass(cls): mock_providers.provider.client = EsiClientStub()
super().setUpClass() mock_esi_client_factory.return_value = EsiClientStub()
EveCorporationInfo.objects.all().delete()
EveAllianceInfo.objects.all().delete()
EveCharacter.objects.all().delete()
EveCorporationInfo.objects.create( EveCorporationInfo.objects.create(
corporation_id=2345, corporation_id=2001,
corporation_name='corp.name', corporation_name="Wayne Technologies",
corporation_ticker='c.c.t', corporation_ticker="WTE",
member_count=10, member_count=10,
alliance=None, alliance=None,
) )
EveAllianceInfo.objects.create( alliance_3001 = EveAllianceInfo.objects.create(
alliance_id=3456, alliance_id=3001,
alliance_name='alliance.name', alliance_name="Wayne Enterprises",
alliance_ticker='a.t', alliance_ticker="WYE",
executor_corp_id=5, executor_corp_id=2003
) )
EveCharacter.objects.create( corporation_2003 = EveCorporationInfo.objects.create(
character_id=1, corporation_id=2003,
character_name='character.name1', corporation_name="Wayne Energy",
corporation_id=2345, corporation_ticker="WEG",
corporation_name='character.corp.name', member_count=99,
corporation_ticker='c.c.t', # max 5 chars alliance=None,
)
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2002,
corporation_name="Wayne Food",
corporation_ticker="WYF",
alliance_id=None alliance_id=None
) )
EveCharacter.objects.create( # when
character_id=2,
character_name='character.name2',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
EveCharacter.objects.create(
character_id=3,
character_name='character.name3',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
EveCharacter.objects.create(
character_id=4,
character_name='character.name4',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
"""
EveCharacter.objects.create(
character_id=5,
character_name='character.name5',
corporation_id=9876,
corporation_name='character.corp.name',
corporation_ticker='c.c.t', # max 5 chars
alliance_id=3456,
alliance_name='character.alliance.name',
)
"""
def setUp(self):
self.affiliations = [
{'character_id': 1, 'corporation_id': 5},
{'character_id': 2, 'corporation_id': 9876, 'alliance_id': 3456},
{'character_id': 3, 'corporation_id': 9876, 'alliance_id': 7456},
{'character_id': 4, 'corporation_id': 9876, 'alliance_id': 3456}
]
self.names = [
{'id': 1, 'name': 'character.name1'},
{'id': 2, 'name': 'character.name2'},
{'id': 3, 'name': 'character.name3'},
{'id': 4, 'name': 'character.name4_new'}
]
def test_normal_run(
self,
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
):
def get_affiliations(characters: list):
response = [x for x in self.affiliations if x['character_id'] in characters]
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
def get_names(ids: list):
response = [x for x in self.names if x['id'] in ids]
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
mock_provider.client.Character.post_characters_affiliation.side_effect \
= get_affiliations
mock_provider.client.Universe.post_universe_names.side_effect = get_names
run_model_update() run_model_update()
# then
character_1001.refresh_from_db()
self.assertEqual( self.assertEqual(
mock_provider.client.Character.post_characters_affiliation.call_count, 2 character_1001.corporation_id, 2001 # char has new corp
) )
corporation_2003.refresh_from_db()
self.assertEqual( self.assertEqual(
mock_provider.client.Universe.post_universe_names.call_count, 2 corporation_2003.alliance.alliance_id, 3001 # corp has new alliance
)
alliance_3001.refresh_from_db()
self.assertEqual(
alliance_3001.executor_corp_id, 2001 # alliance has been updated
) )
# character 1 has changed corp
# character 2 no change @override_settings(CELERY_ALWAYS_EAGER=True)
# character 3 has changed alliance @patch('allianceauth.eveonline.tasks.update_character', wraps=update_character)
# character 4 has changed name @patch('allianceauth.eveonline.providers.esi_client_factory')
self.assertEqual(mock_update_corp.apply_async.call_count, 1) @patch('allianceauth.eveonline.tasks.providers')
self.assertEqual( @patch('allianceauth.eveonline.tasks.CHUNK_SIZE', 2)
int(mock_update_corp.apply_async.call_args[1]['args'][0]), 2345 class TestUpdateCharacterChunk(TestCase):
) @staticmethod
self.assertEqual(mock_update_alliance.apply_async.call_count, 1) def _updated_character_ids(spy_update_character) -> set:
self.assertEqual( """Character IDs passed to update_character task for update."""
int(mock_update_alliance.apply_async.call_args[1]['args'][0]), 3456 return {
) x[1]["args"][0] for x in spy_update_character.apply_async.call_args_list
characters_updated = {
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list
} }
excepted = {1, 3, 4}
self.assertSetEqual(characters_updated, excepted)
def test_ignore_character_not_in_affiliations( def test_should_update_corp_change(
self, self, mock_providers, mock_esi_client_factory, spy_update_character
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
): ):
def get_affiliations(characters: list): # given
response = [x for x in self.affiliations if x['character_id'] in characters] mock_providers.provider.client = EsiClientStub()
mock_operator = Mock(**{'result.return_value': response}) mock_esi_client_factory.return_value = EsiClientStub()
return mock_operator character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2003,
corporation_name="Wayne Energy",
corporation_ticker="WEG",
alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
)
character_1002 = EveCharacter.objects.create(
character_id=1002,
character_name="Peter Parker",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Enterprises",
alliance_ticker="WYE",
)
# when
update_character_chunk([
character_1001.character_id, character_1002.character_id
])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.corporation_id, 2001)
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
def get_names(ids: list): def test_should_update_name_change(
response = [x for x in self.names if x['id'] in ids] self, mock_providers, mock_esi_client_factory, spy_update_character
mock_operator = Mock(**{'result.return_value': response})
return mock_operator
del self.affiliations[0]
mock_provider.client.Character.post_characters_affiliation.side_effect \
= get_affiliations
mock_provider.client.Universe.post_universe_names.side_effect = get_names
run_model_update()
characters_updated = {
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list
}
excepted = {3, 4}
self.assertSetEqual(characters_updated, excepted)
def test_ignore_character_not_in_names(
self,
mock_provider,
mock_update_corp,
mock_update_alliance,
mock_update_character,
): ):
def get_affiliations(characters: list): # given
response = [x for x in self.affiliations if x['character_id'] in characters] mock_providers.provider.client = EsiClientStub()
mock_operator = Mock(**{'result.return_value': response}) mock_esi_client_factory.return_value = EsiClientStub()
return mock_operator character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Batman",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.character_name, "Bruce Wayne")
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
def get_names(ids: list): def test_should_update_alliance_change(
response = [x for x in self.names if x['id'] in ids] self, mock_providers, mock_esi_client_factory, spy_update_character
mock_operator = Mock(**{'result.return_value': response}) ):
return mock_operator # given
mock_providers.provider.client = EsiClientStub()
mock_esi_client_factory.return_value = EsiClientStub()
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=None,
)
# when
update_character_chunk([character_1001.character_id])
# then
character_1001.refresh_from_db()
self.assertEqual(character_1001.alliance_id, 3001)
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})
del self.names[3] def test_should_not_update_when_not_changed(
self, mock_providers, mock_esi_client_factory, spy_update_character
):
# given
mock_providers.provider.client = EsiClientStub()
mock_esi_client_factory.return_value = EsiClientStub()
character_1001 = EveCharacter.objects.create(
character_id=1001,
character_name="Bruce Wayne",
corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
self.assertSetEqual(self._updated_character_ids(spy_update_character), set())
mock_provider.client.Character.post_characters_affiliation.side_effect \ def test_should_fall_back_to_single_updates_when_bulk_update_failed(
= get_affiliations self, mock_providers, mock_esi_client_factory, spy_update_character
):
mock_provider.client.Universe.post_universe_names.side_effect = get_names # given
mock_providers.provider.client.Character.post_characters_affiliation\
run_model_update() .side_effect = OSError
characters_updated = { mock_esi_client_factory.return_value = EsiClientStub()
x[1]['args'][0] for x in mock_update_character.apply_async.call_args_list character_1001 = EveCharacter.objects.create(
} character_id=1001,
excepted = {1, 3} character_name="Bruce Wayne",
self.assertSetEqual(characters_updated, excepted) corporation_id=2001,
corporation_name="Wayne Technologies",
corporation_ticker="WTE",
alliance_id=3001,
alliance_name="Wayne Technologies",
alliance_ticker="WYT",
)
# when
update_character_chunk([character_1001.character_id])
# then
self.assertSetEqual(self._updated_character_ids(spy_update_character), {1001})

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}")

View File

@@ -1,15 +1,21 @@
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import Permission
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group as BaseGroup, User
from django.db.models import Count from django.contrib.auth.models import Group as BaseGroup, Permission, User
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 pre_save, post_save, pre_delete, \ from django.db.models.signals import (
post_delete, m2m_changed m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save
)
from django.dispatch import receiver from django.dispatch import receiver
from .models import AuthGroup from .forms import GroupAdminForm, ReservedGroupNameAdminForm
from .models import GroupRequest 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
@@ -24,10 +30,12 @@ class AuthGroupInlineAdmin(admin.StackedInline):
'description', 'description',
'group_leaders', 'group_leaders',
'group_leader_groups', 'group_leader_groups',
'states', 'internal', 'states',
'internal',
'hidden', 'hidden',
'open', 'open',
'public' 'public',
'restricted',
) )
verbose_name_plural = 'Auth Settings' verbose_name_plural = 'Auth Settings'
verbose_name = '' verbose_name = ''
@@ -46,6 +54,11 @@ class AuthGroupInlineAdmin(admin.StackedInline):
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return request.user.has_perm('auth.change_group') return request.user.has_perm('auth.change_group')
def get_readonly_fields(self, request, obj=None):
if not request.user.is_superuser:
return self.readonly_fields + ("restricted",)
return self.readonly_fields
if _has_auto_groups: if _has_auto_groups:
class IsAutoGroupFilter(admin.SimpleListFilter): class IsAutoGroupFilter(admin.SimpleListFilter):
@@ -70,8 +83,7 @@ if _has_auto_groups:
managedalliancegroup__isnull=True, managedalliancegroup__isnull=True,
managedcorpgroup__isnull=True managedcorpgroup__isnull=True
) )
else: return queryset
return queryset
class HasLeaderFilter(admin.SimpleListFilter): class HasLeaderFilter(admin.SimpleListFilter):
@@ -90,19 +102,18 @@ class HasLeaderFilter(admin.SimpleListFilter):
return queryset.filter(authgroup__group_leaders__isnull=False) return queryset.filter(authgroup__group_leaders__isnull=False)
elif value == 'no': elif value == 'no':
return queryset.filter(authgroup__group_leaders__isnull=True) return queryset.filter(authgroup__group_leaders__isnull=True)
else: return queryset
return queryset
class GroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
list_select_related = ('authgroup',) form = GroupAdminForm
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',
@@ -118,34 +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')
def _member_count(self, obj): def _member_count(self, obj):
return obj.member_count return obj.member_count
_member_count.short_description = 'Members' @admin.display(boolean=True)
_member_count.admin_order_field = 'member_count'
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
has_leader.boolean = True
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')
@@ -158,11 +186,10 @@ class GroupAdmin(admin.ModelAdmin):
properties.append('Public') properties.append('Public')
if not properties: if not properties:
properties.append('Default') properties.append('Default')
if obj.authgroup.restricted:
properties.append('Restricted')
return properties return properties
_properties.short_description = "properties"
filter_horizontal = ('permissions',) filter_horizontal = ('permissions',)
inlines = (AuthGroupInlineAdmin,) inlines = (AuthGroupInlineAdmin,)
@@ -176,8 +203,15 @@ 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):
if not request.user.is_superuser:
return self.readonly_fields + ("permissions",)
return self.readonly_fields
class Group(BaseGroup): class Group(BaseGroup):
class Meta: class Meta:
@@ -202,11 +236,23 @@ class GroupRequestAdmin(admin.ModelAdmin):
'leave_request', 'leave_request',
) )
@admin.display(boolean=True, description="is leave request")
def _leave_request(self, obj) -> True: def _leave_request(self, obj) -> True:
return obj.leave_request return obj.leave_request
_leave_request.short_description = 'is leave request'
_leave_request.boolean = True @admin.register(ReservedGroupName)
class ReservedGroupNameAdmin(admin.ModelAdmin):
form = ReservedGroupNameAdminForm
list_display = ("name", "created_by", "created_at")
def get_form(self, request, *args, **kwargs):
form = super().get_form(request, *args, **kwargs)
form.current_user = request.user
return form
def has_change_permission(self, *args, **kwargs) -> bool:
return False
@receiver(pre_save, sender=Group) @receiver(pre_save, sender=Group)

View File

@@ -0,0 +1,39 @@
from django import forms
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from .models import ReservedGroupName
class GroupAdminForm(forms.ModelForm):
def clean_name(self):
my_name = self.cleaned_data['name']
if ReservedGroupName.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("This name has been reserved and can not be used for groups."),
code='reserved_name'
)
return my_name
class ReservedGroupNameAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['created_by'].initial = self.current_user.username
self.fields['created_at'].initial = _("(auto)")
created_by = forms.CharField(disabled=True)
created_at = forms.CharField(disabled=True)
def clean_name(self):
my_name = self.cleaned_data['name'].lower()
if Group.objects.filter(name__iexact=my_name).exists():
raise ValidationError(
_("There already exists a group with that name."), code='already_exists'
)
return my_name
def clean_created_at(self):
return now()

View File

@@ -14,8 +14,8 @@ class GroupManager:
@classmethod @classmethod
def get_joinable_groups_for_user( def get_joinable_groups_for_user(
cls, user: User, include_hidden = True cls, user: User, include_hidden=True
) -> QuerySet: ) -> QuerySet[Group]:
"""get groups a user could join incl. groups already joined""" """get groups a user could join incl. groups already joined"""
groups_qs = cls.get_joinable_groups(user.profile.state) groups_qs = cls.get_joinable_groups(user.profile.state)
@@ -28,24 +28,27 @@ class GroupManager:
return groups_qs return groups_qs
@staticmethod @staticmethod
def get_joinable_groups(state: State) -> QuerySet: def get_joinable_groups(state: State) -> QuerySet[Group]:
"""get groups that can be joined by user with given state""" """get groups that can be joined by user with given state"""
return Group.objects\ return (
.select_related('authgroup')\ Group.objects
.exclude(authgroup__internal=True)\
.filter(Q(authgroup__states=state) | Q(authgroup__states=None))
@staticmethod
def get_all_non_internal_groups() -> QuerySet:
"""get groups that are not internal"""
return Group.objects\
.select_related('authgroup')\
.exclude(authgroup__internal=True) .exclude(authgroup__internal=True)
.filter(Q(authgroup__states=state) | Q(authgroup__states=None))
)
@staticmethod @staticmethod
def get_group_leaders_groups(user: User): def get_all_non_internal_groups() -> QuerySet[Group]:
return Group.objects.select_related('authgroup').filter(authgroup__group_leaders__in=[user]) | \ """get groups that are not internal"""
Group.objects.select_related('authgroup').filter(authgroup__group_leader_groups__in=user.groups.all()) return Group.objects.exclude(authgroup__internal=True)
@staticmethod
def get_group_leaders_groups(user: User) -> QuerySet[Group]:
return (
Group.objects.filter(authgroup__group_leaders=user)
| Group.objects.filter(
authgroup__group_leader_groups__in=list(user.groups.all())
)
)
@staticmethod @staticmethod
def joinable_group(group: Group, state: State) -> bool: def joinable_group(group: Group, state: State) -> bool:
@@ -57,12 +60,12 @@ class GroupManager:
:param state: allianceauth.authentication.State object :param state: allianceauth.authentication.State object
:return: bool True if its joinable, False otherwise :return: bool True if its joinable, False otherwise
""" """
if (len(group.authgroup.states.all()) != 0 if (
len(group.authgroup.states.all()) != 0
and state not in group.authgroup.states.all() and state not in group.authgroup.states.all()
): ):
return False return False
else: return not group.authgroup.internal
return not group.authgroup.internal
@staticmethod @staticmethod
def check_internal_group(group: Group) -> bool: def check_internal_group(group: Group) -> bool:
@@ -78,7 +81,7 @@ class GroupManager:
return user.has_perm('auth.group_management') return user.has_perm('auth.group_management')
@classmethod @classmethod
def can_manage_groups(cls, user:User ) -> bool: def can_manage_groups(cls, user:User) -> bool:
""" """
For use with user_passes_test decorator. For use with user_passes_test decorator.
Check if the user can manage groups. Either has the Check if the user can manage groups. Either has the
@@ -88,7 +91,10 @@ class GroupManager:
:return: bool True if user can manage groups, False otherwise :return: bool True if user can manage groups, False otherwise
""" """
if user.is_authenticated: if user.is_authenticated:
return cls.has_management_permission(user) or cls.get_group_leaders_groups(user) return (
cls.has_management_permission(user)
or cls.get_group_leaders_groups(user)
)
return False return False
@classmethod @classmethod
@@ -100,19 +106,19 @@ class GroupManager:
:return: True if the user can manage the group :return: True if the user can manage the group
""" """
if user.is_authenticated: if user.is_authenticated:
return cls.has_management_permission(user) or cls.get_group_leaders_groups(user).filter(pk=group.pk).exists() return (
cls.has_management_permission(user)
or cls.get_group_leaders_groups(user).filter(pk=group.pk).exists()
)
return False return False
@classmethod @classmethod
def pending_requests_count_for_user(cls, user: User) -> int: def pending_requests_count_for_user(cls, user: User) -> int:
"""Returns the number of pending group requests for the given user""" """Returns the number of pending group requests for the given user"""
if cls.has_management_permission(user): if cls.has_management_permission(user):
return GroupRequest.objects.all().count() return GroupRequest.objects.all().count()
else: return (
return ( GroupRequest.objects
GroupRequest.objects .filter(group__in=list(cls.get_group_leaders_groups(user)))
.filter(group__authgroup__group_leaders__exact=user) .count()
.select_related("group__authgroup__group_leaders") )
.count()
)

View File

@@ -0,0 +1,42 @@
# Generated by Django 3.2.9 on 2021-11-11 15:56
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth', '0012_alter_user_first_name_max_length'),
('authentication', '0019_merge_20211026_0919'),
('groupmanagement', '0016_remove_grouprequest_status_field'),
]
operations = [
migrations.AlterField(
model_name='authgroup',
name='group_leader_groups',
field=models.ManyToManyField(blank=True, help_text='Members of leader groups can process requests for this group. Use the <code>auth.group_management</code> permission to allow a user to manage all groups.<br>', related_name='leads_group_groups', to='auth.Group'),
),
migrations.AlterField(
model_name='authgroup',
name='group_leaders',
field=models.ManyToManyField(blank=True, help_text='Group leaders can process requests for this group. Use the <code>auth.group_management</code> permission to allow a user to manage all groups.<br>', related_name='leads_groups', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='authgroup',
name='open',
field=models.BooleanField(default=False, help_text='Group is open and users will be automatically added upon request.<br>If the group is not open users will need their request manually approved.'),
),
migrations.AlterField(
model_name='authgroup',
name='public',
field=models.BooleanField(default=False, help_text='Group is public. Any registered user is able to join this group, with visibility based on the other options set for this group.<br>Auth will not remove users from this group automatically when they are no longer authenticated.'),
),
migrations.AlterField(
model_name='authgroup',
name='states',
field=models.ManyToManyField(blank=True, help_text='States listed here will have the ability to join this group provided they have the proper permissions.<br>', related_name='valid_states', to='authentication.State'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.2.9 on 2021-11-25 18:38
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('groupmanagement', '0017_improve_groups_documentation'),
]
operations = [
migrations.CreateModel(
name='ReservedGroupName',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name that can not be used for groups.', max_length=150, unique=True, verbose_name='name')),
('reason', models.TextField(help_text='Reason why this name is reserved.', verbose_name='reason')),
('created_by', models.CharField(help_text='Name of the user who created this entry.', max_length=255, verbose_name='created by')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this entry was created', verbose_name='created at')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.10 on 2022-04-08 19:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('groupmanagement', '0018_reservedgroupname'),
]
operations = [
migrations.AddField(
model_name='authgroup',
name='restricted',
field=models.BooleanField(default=False, help_text='Group is restricted. This means that adding or removing users for this group requires a superuser admin.'),
),
]

View File

@@ -1,16 +1,26 @@
from typing import Set
from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.utils.timezone import now
from django.dispatch import receiver from django.utils.translation import gettext_lazy as _
from allianceauth.authentication.models import State from allianceauth.authentication.models import State
from allianceauth.notifications import notify
class GroupRequest(models.Model): class GroupRequest(models.Model):
"""Request from a user for joining or leaving a group."""
leave_request = models.BooleanField(default=0) leave_request = models.BooleanField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
def __str__(self):
return self.user.username + ":" + self.group.name
@property @property
def main_char(self): def main_char(self):
""" """
@@ -19,11 +29,23 @@ class GroupRequest(models.Model):
""" """
return self.user.profile.main_character return self.user.profile.main_character
def __str__(self): def notify_leaders(self) -> None:
return self.user.username + ":" + self.group.name """Send notification to all group leaders about this request.
Note: No translations, because language for each leader is unknown
"""
if not getattr(settings, 'GROUPMANAGEMENT_REQUESTS_NOTIFICATION', False):
return
keyword = "leave" if self.leave_request else "join"
title = f"Group Management: {keyword.title()} request for {self.group.name}"
message = f"{self.user} want's to {keyword} {self.group.name}."
for appover in self.group.authgroup.group_request_approvers():
notify(user=appover, title=title, message=message, level="info")
class RequestLog(models.Model): class RequestLog(models.Model):
"""Log entry about who joined and left a group and who approved it."""
request_type = models.BooleanField(null=True) request_type = models.BooleanField(null=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
request_info = models.CharField(max_length=254) request_info = models.CharField(max_length=254)
@@ -61,11 +83,12 @@ class AuthGroup(models.Model):
e.g. group.authgroup.internal e.g. group.authgroup.internal
Logic: Logic:
Internal - not requestable by users, at all. Covers Corp_, Alliance_, Members etc groups. Internal - not requestable by users, at all. Covers Corp_, Alliance_,
Groups are internal by default Members etc groups. Groups are internal by default
Public - Other options are respected, but any user will be able to become and remain a member, even if they Public - Other options are respected, but any user will be able to become
have no API etc entered. Auth will not manage these groups automatically so user removal is up to and remain a member, even if they have no API etc entered.
Auth will not manage these groups automatically so user removal is up to
group managers/leaders. group managers/leaders.
Not Internal and: Not Internal and:
@@ -74,61 +97,138 @@ class AuthGroup(models.Model):
Open - Users are automatically accepted into the group Open - Users are automatically accepted into the group
Not Open - Users requests must be approved before they are added to the group Not Open - Users requests must be approved before they are added to the group
""" """
group = models.OneToOneField(Group, on_delete=models.CASCADE, primary_key=True) group = models.OneToOneField(Group, on_delete=models.CASCADE, primary_key=True)
internal = models.BooleanField(
default=True,
help_text=_(
"Internal group, users cannot see, join or request to join this group.<br>"
"Used for groups such as Members, Corp_*, Alliance_* etc.<br>"
"<b>Overrides Hidden and Open options when selected.</b>"
)
)
hidden = models.BooleanField(
default=True,
help_text=_(
"Group is hidden from users but can still join with the correct link."
)
)
open = models.BooleanField(
default=False,
help_text=_(
"Group is open and users will be automatically added upon request.<br>"
"If the group is not open users will need their request manually approved."
)
)
public = models.BooleanField(
default=False,
help_text=_(
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>"
"Auth will not remove users from this group automatically when they "
"are no longer authenticated."
)
)
restricted = models.BooleanField(
default=False,
help_text=_(
"Group is restricted. This means that adding or removing users "
"for this group requires a superuser admin."
)
)
group_leaders = models.ManyToManyField(
User,
related_name='leads_groups',
blank=True,
help_text=_(
"Group leaders can process requests for this group. "
"Use the <code>auth.group_management</code> permission to allow "
"a user to manage all groups.<br>"
)
)
group_leader_groups = models.ManyToManyField(
Group,
related_name='leads_group_groups',
blank=True,
help_text=_(
"Members of leader groups can process requests for this group. "
"Use the <code>auth.group_management</code> permission "
"to allow a user to manage all groups.<br>")
)
states = models.ManyToManyField(
State,
related_name='valid_states',
blank=True,
help_text=_(
"States listed here will have the ability to join this group provided "
"they have the proper permissions.<br>"
)
)
description = models.TextField(
max_length=512,
blank=True,
help_text=_(
"Short description <i>(max. 512 characters)</i> "
"of the group shown to users."
)
)
internal = models.BooleanField(default=True, class Meta:
help_text="Internal group, users cannot see, join or request to join this group.<br>" permissions = (
"Used for groups such as Members, Corp_*, Alliance_* etc.<br>" ("request_groups", _("Can request non-public groups")),
"<b>Overrides Hidden and Open options when selected.</b>") )
hidden = models.BooleanField(default=True, help_text="Group is hidden from users but can still join with the correct link.") default_permissions = tuple()
open = models.BooleanField(default=False,
help_text="Group is open and users will be automatically added upon request. <br>"
"If the group is not open users will need their request manually approved.")
public = models.BooleanField(default=False,
help_text="Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br> Auth will "
"not remove users from this group automatically when they are no longer "
"authenticated.")
# Group leaders have management access to this group
group_leaders = models.ManyToManyField(User, related_name='leads_groups', blank=True,
help_text="Group leaders can process group requests for this group "
"specifically. Use the auth.group_management permission to allow "
"a user to manage all groups.")
# allow groups to be *group leads*
group_leader_groups = models.ManyToManyField(Group, related_name='leads_group_groups', blank=True,
help_text="Group leaders can process group requests for this group "
"specifically. Use the auth.group_management permission to allow "
"a user to manage all groups.")
states = models.ManyToManyField(State, related_name='valid_states', blank=True,
help_text="States listed here will have the ability to join this group provided "
"they have the proper permissions.")
description = models.TextField(max_length=512, blank=True, help_text="Short description <i>(max. 512 characters)"
"</i> of the group shown to users.")
def __str__(self): def __str__(self):
return self.group.name return self.group.name
class Meta: def group_request_approvers(self) -> Set[User]:
permissions = ( """Return all users who can approve a group request."""
("request_groups", "Can request non-public groups"), return set(
self.group_leaders.all()
| User.objects.filter(groups__in=list(self.group_leader_groups.all()))
) )
default_permissions = tuple()
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)
@receiver(post_save, sender=Group) class ReservedGroupName(models.Model):
def create_auth_group(sender, instance, created, **kwargs): """Name that can not be used for groups.
"""
Creates the AuthGroup model when a group is created
"""
if created:
AuthGroup.objects.create(group=instance)
This enables AA to ignore groups on other services (e.g. Discord) with that name.
"""
@receiver(post_save, sender=Group) name = models.CharField(
def save_auth_group(sender, instance, **kwargs): _('name'),
""" max_length=150,
Ensures AuthGroup model is saved automatically unique=True,
""" help_text=_("Name that can not be used for groups.")
instance.authgroup.save() )
reason = models.TextField(
_('reason'), help_text=_("Reason why this name is reserved.")
)
created_by = models.CharField(
_('created by'),
max_length=255,
help_text="Name of the user who created this entry."
)
created_at = models.DateTimeField(
_('created at'), default=now, help_text=_("Date when this entry was created")
)
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if Group.objects.filter(name__iexact=self.name).exists():
raise RuntimeError(
f"Save failed. There already exists a group with the name: {self.name}."
)
super().save(*args, **kwargs)

View File

@@ -1,11 +1,33 @@
import logging import logging
from django.contrib.auth.models import Group
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver from django.dispatch import receiver
from allianceauth.authentication.signals import state_changed from allianceauth.authentication.signals import state_changed
from .models import AuthGroup, ReservedGroupName
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@receiver(pre_save, sender=Group)
def find_new_name_for_conflicting_groups(sender, instance, **kwargs):
"""Find new name for a group which name is already reserved."""
new_name = instance.name
num = 0
while ReservedGroupName.objects.filter(name__iexact=new_name).exists():
num += 1
new_name = f"{instance.name}_{num}"
instance.name = new_name
@receiver(post_save, sender=Group)
def create_auth_group(sender, instance, created, **kwargs):
"""Create the AuthGroup model when a group is created."""
if created:
AuthGroup.objects.create(group=instance)
@receiver(state_changed) @receiver(state_changed)
def check_groups_on_state_change(sender, user, state, **kwargs): def check_groups_on_state_change(sender, user, state, **kwargs):
logger.debug( logger.debug(

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

@@ -127,6 +127,8 @@
], ],
bootstrap: true bootstrap: true
}, },
"stateSave": true,
"stateDuration": 0
}); });
}); });
{% endblock %} {% endblock %}

View File

@@ -104,7 +104,9 @@
"sortable": false, "sortable": false,
"targets": [2] "targets": [2]
}, },
] ],
"stateSave": true,
"stateDuration": 0
}); });
}); });
{% endblock %} {% endblock %}

View File

@@ -29,15 +29,18 @@
{% endif %} {% endif %}
</a> </a>
</li> </li>
<li>
<a data-toggle="tab" href="#leave">
{% translate "Leave Requests" %}
{% if leaverequests %} {% if not auto_leave %}
<span class="badge">{{ leaverequests|length }}</span> <li>
{% endif %} <a data-toggle="tab" href="#leave">
</a> {% translate "Leave Requests" %}
</li>
{% if leaverequests %}
<span class="badge">{{ leaverequests|length }}</span>
{% endif %}
</a>
</li>
{% endif %}
</ul> </ul>
<div class="panel panel-default panel-tabs-aa"> <div class="panel panel-default panel-tabs-aa">
@@ -100,61 +103,63 @@
{% endif %} {% endif %}
</div> </div>
<div id="leave" class="tab-pane"> {% if not auto_leave %}
{% if leaverequests %} <div id="leave" class="tab-pane">
<div class="table-responsive"> {% if leaverequests %}
<table class="table table-aa"> <div class="table-responsive">
<thead> <table class="table table-aa">
<tr> <thead>
<th>{% translate "Character" %}</th>
<th>{% translate "Organization" %}</th>
<th>{% translate "Group" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for leaverequest in leaverequests %}
<tr> <tr>
<td> <th>{% translate "Character" %}</th>
<img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;"> <th>{% translate "Organization" %}</th>
{% if leaverequest.main_char %} <th>{% translate "Group" %}</th>
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank"> <th></th>
{{ leaverequest.main_char.character_name }}
</a>
{% else %}
{{ leaverequest.user.username }}
{% endif %}
</td>
<td>
{% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|dotlan_corporation_url }}" target="_blank">
{{ leaverequest.main_char.corporation_name }}
</a><br>
{{ leaverequest.main_char.alliance_name|default_if_none:"" }}
{% else %}
{% translate "(unknown)" %}
{% endif %}
</td>
<td>{{ leaverequest.group.name }}</td>
<td class="text-right">
<a href="{% url 'groupmanagement:leave_accept_request' leaverequest.id %}" class="btn btn-success">
{% translate "Accept" %}
</a>
<a href="{% url 'groupmanagement:leave_reject_request' leaverequest.id %}" class="btn btn-danger">
{% translate "Reject" %}
</a>
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody>
</table> <tbody>
</div> {% for leaverequest in leaverequests %}
{% else %} <tr>
<div class="alert alert-warning text-center">{% translate "No group leave requests." %}</div> <td>
{% endif %} <img src="{{ leaverequest.main_char|character_portrait_url:32 }}" class="img-circle" style="margin-right: 1rem;">
</div> {% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|evewho_character_url }}" target="_blank">
{{ leaverequest.main_char.character_name }}
</a>
{% else %}
{{ leaverequest.user.username }}
{% endif %}
</td>
<td>
{% if leaverequest.main_char %}
<a href="{{ leaverequest.main_char|dotlan_corporation_url }}" target="_blank">
{{ leaverequest.main_char.corporation_name }}
</a><br>
{{ leaverequest.main_char.alliance_name|default_if_none:"" }}
{% else %}
{% translate "(unknown)" %}
{% endif %}
</td>
<td>{{ leaverequest.group.name }}</td>
<td class="text-right">
<a href="{% url 'groupmanagement:leave_accept_request' leaverequest.id %}" class="btn btn-success">
{% translate "Accept" %}
</a>
<a href="{% url 'groupmanagement:leave_reject_request' leaverequest.id %}" class="btn btn-danger">
{% translate "Reject" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-warning text-center">{% translate "No group leave requests." %}</div>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,23 @@
from unittest.mock import patch from unittest.mock import patch
from django_webtest import WebTest
from django.conf import settings 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 (
EveCharacter, EveCorporationInfo, EveAllianceInfo EveCharacter, EveCorporationInfo, EveAllianceInfo
) )
from allianceauth.tests.auth_utils import AuthUtils
from ..admin import HasLeaderFilter, GroupAdmin, Group
from . import get_admin_change_view_url from . import get_admin_change_view_url
from ..admin import HasLeaderFilter, GroupAdmin, Group
from ..models import ReservedGroupName
if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS: if 'allianceauth.eveonline.autogroups' in settings.INSTALLED_APPS:
_has_auto_groups = True _has_auto_groups = True
@@ -32,7 +37,6 @@ class MockRequest:
class TestGroupAdmin(TestCase): class TestGroupAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@@ -232,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
@@ -396,3 +444,238 @@ class TestGroupAdmin(TestCase):
c.login(username='superuser', password='secret') c.login(username='superuser', password='secret')
response = c.get(get_admin_change_view_url(self.group_1)) response = c.get(get_admin_change_view_url(self.group_1))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_should_create_new_group(self):
# given
user = User.objects.create_superuser("bruce")
self.client.force_login(user)
# when
response = self.client.post(
"/admin/groupmanagement/group/add/",
data={
"name": "new group",
"authgroup-TOTAL_FORMS": 1,
"authgroup-INITIAL_FORMS": 0,
"authgroup-MIN_NUM_FORMS": 0,
"authgroup-MAX_NUM_FORMS": 1,
}
)
# then
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/admin/groupmanagement/group/")
self.assertTrue(Group.objects.filter(name="new group").exists())
def test_should_not_allow_creating_new_group_with_reserved_name(self):
# given
ReservedGroupName.objects.create(
name="new group", reason="dummy", created_by="bruce"
)
user = User.objects.create_superuser("bruce")
self.client.force_login(user)
# when
response = self.client.post(
"/admin/groupmanagement/group/add/",
data={
"name": "New group",
"authgroup-TOTAL_FORMS": 1,
"authgroup-INITIAL_FORMS": 0,
"authgroup-MIN_NUM_FORMS": 0,
"authgroup-MAX_NUM_FORMS": 1,
}
)
# then
self.assertContains(
response, "This name has been reserved and can not be used for groups"
)
self.assertFalse(Group.objects.filter(name="new group").exists())
def test_should_not_allow_changing_name_of_existing_group_to_reserved_name(self):
# given
ReservedGroupName.objects.create(
name="new group", reason="dummy", created_by="bruce"
)
group = Group.objects.create(name="dummy")
user = User.objects.create_superuser("bruce")
self.client.force_login(user)
# when
response = self.client.post(
f"/admin/groupmanagement/group/{group.pk}/change/",
data={
"name": "new group",
"authgroup-TOTAL_FORMS": 1,
"authgroup-INITIAL_FORMS": 0,
"authgroup-MIN_NUM_FORMS": 0,
"authgroup-MAX_NUM_FORMS": 1,
}
)
# then
self.assertContains(
response, "This name has been reserved and can not be used for groups"
)
self.assertFalse(Group.objects.filter(name="new group").exists())
class TestGroupAdminChangeFormSuperuserExclusiveEdits(WebTest):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.super_admin = User.objects.create_superuser("super_admin")
cls.staff_admin = User.objects.create_user("staff_admin")
cls.staff_admin.is_staff = True
cls.staff_admin.save()
cls.staff_admin = AuthUtils.add_permissions_to_user_by_name(
[
"auth.add_group",
"auth.change_group",
"auth.view_group",
"groupmanagement.add_group",
"groupmanagement.change_group",
"groupmanagement.view_group",
],
cls.staff_admin
)
cls.superuser_exclusive_fields = ["permissions", "authgroup-0-restricted"]
def test_should_show_all_fields_to_superuser_for_add(self):
# given
self.app.set_user(self.super_admin)
page = self.app.get("/admin/groupmanagement/group/add/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admins_for_add(self):
# given
self.app.set_user(self.staff_admin)
page = self.app.get("/admin/groupmanagement/group/add/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertNotIn(field, form.fields)
def test_should_show_all_fields_to_superuser_for_change(self):
# given
self.app.set_user(self.super_admin)
group = Group.objects.create(name="Dummy group")
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
self.assertIn(field, form.fields)
def test_should_not_show_all_fields_to_staff_admin_for_change(self):
# given
self.app.set_user(self.staff_admin)
group = Group.objects.create(name="Dummy group")
page = self.app.get(f"/admin/groupmanagement/group/{group.pk}/change/")
# when
form = page.forms["group_form"]
# then
for field in self.superuser_exclusive_fields:
with self.subTest(field=field):
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):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = User.objects.create_superuser("bruce")
def test_should_create_new_entry(self):
# given
self.client.force_login(self.user)
# when
response = self.client.post(
"/admin/groupmanagement/reservedgroupname/add/",
data={"name": "Test", "reason": "dummy"}
)
# then
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/admin/groupmanagement/reservedgroupname/")
obj = ReservedGroupName.objects.get(name="test")
self.assertEqual(obj.name, "test")
self.assertEqual(obj.created_by, self.user.username)
self.assertTrue(obj.created_at)
def test_should_not_allow_names_of_existing_groups(self):
# given
Group.objects.create(name="Already taken")
self.client.force_login(self.user)
# when
response = self.client.post(
"/admin/groupmanagement/reservedgroupname/add/",
data={"name": "already taken", "reason": "dummy"}
)
# then
self.assertContains(response, "There already exists a group with that name")
self.assertFalse(ReservedGroupName.objects.filter(name="already taken").exists())

View File

@@ -1,8 +1,5 @@
from unittest.mock import Mock, patch
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from allianceauth.eveonline.models import EveCorporationInfo, EveAllianceInfo from allianceauth.eveonline.models import EveCorporationInfo, EveAllianceInfo
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
@@ -44,9 +41,9 @@ class GroupManagementVisibilityTestCase(TestCase):
self._refresh_user() self._refresh_user()
groups = GroupManager.get_group_leaders_groups(self.user) groups = GroupManager.get_group_leaders_groups(self.user)
self.assertIn(self.group1, groups) #avail due to user self.assertIn(self.group1, groups) # avail due to user
self.assertNotIn(self.group2, groups) #not avail due to group self.assertNotIn(self.group2, groups) # not avail due to group
self.assertNotIn(self.group3, groups) #not avail at all self.assertNotIn(self.group3, groups) # not avail at all
self.user.groups.add(self.group1) self.user.groups.add(self.group1)
self._refresh_user() self._refresh_user()
@@ -71,70 +68,66 @@ class GroupManagementVisibilityTestCase(TestCase):
class TestGroupManager(TestCase): class TestGroupManager(TestCase):
def setUp(self) -> None:
@classmethod
def setUpClass(cls):
super().setUpClass()
# group 1 # group 1
cls.group_default = Group.objects.create(name='default') self.group_default = Group.objects.create(name='default')
cls.group_default.authgroup.description = 'Default Group' self.group_default.authgroup.description = 'Default Group'
cls.group_default.authgroup.internal = False self.group_default.authgroup.internal = False
cls.group_default.authgroup.hidden = False self.group_default.authgroup.hidden = False
cls.group_default.authgroup.save() self.group_default.authgroup.save()
# group 2 # group 2
cls.group_internal = Group.objects.create(name='internal') self.group_internal = Group.objects.create(name='internal')
cls.group_internal.authgroup.description = 'Internal Group' self.group_internal.authgroup.description = 'Internal Group'
cls.group_internal.authgroup.internal = True self.group_internal.authgroup.internal = True
cls.group_internal.authgroup.save() self.group_internal.authgroup.save()
# group 3 # group 3
cls.group_hidden = Group.objects.create(name='hidden') self.group_hidden = Group.objects.create(name='hidden')
cls.group_hidden.authgroup.description = 'Hidden Group' self.group_hidden.authgroup.description = 'Hidden Group'
cls.group_hidden.authgroup.internal = False self.group_hidden.authgroup.internal = False
cls.group_hidden.authgroup.hidden = True self.group_hidden.authgroup.hidden = True
cls.group_hidden.authgroup.save() self.group_hidden.authgroup.save()
# group 4 # group 4
cls.group_open = Group.objects.create(name='open') self.group_open = Group.objects.create(name='open')
cls.group_open.authgroup.description = 'Open Group' self.group_open.authgroup.description = 'Open Group'
cls.group_open.authgroup.internal = False self.group_open.authgroup.internal = False
cls.group_open.authgroup.hidden = False self.group_open.authgroup.hidden = False
cls.group_open.authgroup.open = True self.group_open.authgroup.open = True
cls.group_open.authgroup.save() self.group_open.authgroup.save()
# group 5 # group 5
cls.group_public_1 = Group.objects.create(name='public 1') self.group_public_1 = Group.objects.create(name='public 1')
cls.group_public_1.authgroup.description = 'Public Group 1' self.group_public_1.authgroup.description = 'Public Group 1'
cls.group_public_1.authgroup.internal = False self.group_public_1.authgroup.internal = False
cls.group_public_1.authgroup.hidden = False self.group_public_1.authgroup.hidden = False
cls.group_public_1.authgroup.public = True self.group_public_1.authgroup.public = True
cls.group_public_1.authgroup.save() self.group_public_1.authgroup.save()
# group 6 # group 6
cls.group_public_2 = Group.objects.create(name='public 2') self.group_public_2 = Group.objects.create(name='public 2')
cls.group_public_2.authgroup.description = 'Public Group 2' self.group_public_2.authgroup.description = 'Public Group 2'
cls.group_public_2.authgroup.internal = False self.group_public_2.authgroup.internal = False
cls.group_public_2.authgroup.hidden = True self.group_public_2.authgroup.hidden = True
cls.group_public_2.authgroup.open = True self.group_public_2.authgroup.open = True
cls.group_public_2.authgroup.public = True self.group_public_2.authgroup.public = True
cls.group_public_2.authgroup.save() self.group_public_2.authgroup.save()
# group 7 # group 7
cls.group_default_member = Group.objects.create(name='default members') self.group_default_member = Group.objects.create(name='default members')
cls.group_default_member.authgroup.description = \ self.group_default_member.authgroup.description = \
'Default Group for members only' 'Default Group for members only'
cls.group_default_member.authgroup.internal = False self.group_default_member.authgroup.internal = False
cls.group_default_member.authgroup.hidden = False self.group_default_member.authgroup.hidden = False
cls.group_default_member.authgroup.open = False self.group_default_member.authgroup.open = False
cls.group_default_member.authgroup.public = False self.group_default_member.authgroup.public = False
cls.group_default_member.authgroup.states.add( self.group_default_member.authgroup.states.add(
AuthUtils.get_member_state() AuthUtils.get_member_state()
) )
cls.group_default_member.authgroup.save() self.group_default_member.authgroup.save()
def setUp(self): # user
self.user = AuthUtils.create_user('Bruce Wayne') self.user = AuthUtils.create_user('Bruce Wayne')
def test_get_joinable_group_member(self): def test_get_joinable_group_member(self):
@@ -241,7 +234,7 @@ class TestGroupManager(TestCase):
def test_get_joinable_groups_for_user_member_w_permission(self): def test_get_joinable_groups_for_user_member_w_permission(self):
AuthUtils.assign_state(self.user, AuthUtils.get_member_state(), True) AuthUtils.assign_state(self.user, AuthUtils.get_member_state(), True)
AuthUtils.add_permission_to_user_by_name( self.user = AuthUtils.add_permission_to_user_by_name(
'groupmanagement.request_groups', self.user 'groupmanagement.request_groups', self.user
) )
result = GroupManager.get_joinable_groups_for_user(self.user) result = GroupManager.get_joinable_groups_for_user(self.user)
@@ -257,7 +250,7 @@ class TestGroupManager(TestCase):
def test_get_joinable_groups_for_user_member_w_permission_no_hidden(self): def test_get_joinable_groups_for_user_member_w_permission_no_hidden(self):
AuthUtils.assign_state(self.user, AuthUtils.get_member_state(), True) AuthUtils.assign_state(self.user, AuthUtils.get_member_state(), True)
AuthUtils.add_permission_to_user_by_name( self.user = AuthUtils.add_permission_to_user_by_name(
'groupmanagement.request_groups', self.user 'groupmanagement.request_groups', self.user
) )
result = GroupManager.get_joinable_groups_for_user( result = GroupManager.get_joinable_groups_for_user(
@@ -273,7 +266,7 @@ class TestGroupManager(TestCase):
def test_has_management_permission(self): def test_has_management_permission(self):
user = AuthUtils.create_user('Clark Kent') user = AuthUtils.create_user('Clark Kent')
AuthUtils.add_permission_to_user_by_name( user = AuthUtils.add_permission_to_user_by_name(
'auth.group_management', user 'auth.group_management', user
) )
self.assertTrue(GroupManager.has_management_permission(user)) self.assertTrue(GroupManager.has_management_permission(user))
@@ -288,7 +281,7 @@ class TestGroupManager(TestCase):
def test_can_manage_groups_has_perm(self): def test_can_manage_groups_has_perm(self):
user = AuthUtils.create_user('Clark Kent') user = AuthUtils.create_user('Clark Kent')
AuthUtils.add_permission_to_user_by_name( user = AuthUtils.add_permission_to_user_by_name(
'auth.group_management', user 'auth.group_management', user
) )
self.assertTrue(GroupManager.can_manage_groups(user)) self.assertTrue(GroupManager.can_manage_groups(user))
@@ -306,7 +299,7 @@ class TestGroupManager(TestCase):
def test_can_manage_group_has_perm(self): def test_can_manage_group_has_perm(self):
user = AuthUtils.create_user('Clark Kent') user = AuthUtils.create_user('Clark Kent')
AuthUtils.add_permission_to_user_by_name( user = AuthUtils.add_permission_to_user_by_name(
'auth.group_management', user 'auth.group_management', user
) )
self.assertTrue( self.assertTrue(
@@ -433,11 +426,21 @@ class TestPendingRequestsCountForUser(TestCase):
# when user_requestor is requesting access to group 1 # when user_requestor is requesting access to group 1
# then return 1 for user_leader_4 # then return 1 for user_leader_4
user_leader_4 = AuthUtils.create_member("Lex Luther") user_leader_4 = AuthUtils.create_member("Lex Luther")
AuthUtils.add_permission_to_user_by_name("auth.group_management", user_leader_4) user_leader_4 = AuthUtils.add_permission_to_user_by_name(
user_leader_4 = User.objects.get(pk=user_leader_4.pk) "auth.group_management", user_leader_4
GroupRequest.objects.create(
user=self.user_requestor, group=self.group_1
) )
GroupRequest.objects.create(user=self.user_requestor, group=self.group_1)
self.assertEqual( self.assertEqual(
GroupManager.pending_requests_count_for_user(self.user_leader_1), 1 GroupManager.pending_requests_count_for_user(self.user_leader_1), 1
) )
def test_single_request_for_members_of_leading_group(self):
# given
leader_group = Group.objects.create(name="Leaders")
self.group_3.authgroup.group_leader_groups.add(leader_group)
self.user_leader_1.groups.add(leader_group)
GroupRequest.objects.create(user=self.user_requestor, group=self.group_3)
# when
result = GroupManager.pending_requests_count_for_user(self.user_leader_1)
# then
self.assertEqual(result, 1)

View File

@@ -1,31 +1,22 @@
from unittest import mock from unittest import mock
from django.contrib.auth.models import User, Group from django.contrib.auth.models import Group
from django.test import TestCase from django.test import TestCase, override_settings
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from allianceauth.eveonline.models import (
EveCorporationInfo, EveAllianceInfo, EveCharacter
)
from ..models import GroupRequest, RequestLog from ..models import GroupRequest, RequestLog, ReservedGroupName
MODULE_PATH = "allianceauth.groupmanagement.models"
def create_testdata(): def create_testdata():
# clear DB
User.objects.all().delete()
Group.objects.all().delete()
EveCharacter.objects.all().delete()
EveCorporationInfo.objects.all().delete()
EveAllianceInfo.objects.all().delete()
# group 1 # group 1
group = Group.objects.create(name='Superheros') group = Group.objects.create(name='Superheros')
group.authgroup.description = 'Default Group' group.authgroup.description = 'Default Group'
group.authgroup.internal = False group.authgroup.internal = False
group.authgroup.hidden = False group.authgroup.hidden = False
group.authgroup.save() group.authgroup.save()
# user 1 # user 1
user_1 = AuthUtils.create_user('Bruce Wayne') user_1 = AuthUtils.create_user('Bruce Wayne')
AuthUtils.add_main_character_2( AuthUtils.add_main_character_2(
@@ -37,7 +28,6 @@ def create_testdata():
) )
user_1.groups.add(group) user_1.groups.add(group)
group.authgroup.group_leaders.add(user_1) group.authgroup.group_leaders.add(user_1)
# user 2 # user 2
user_2 = AuthUtils.create_user('Clark Kent') user_2 = AuthUtils.create_user('Clark Kent')
AuthUtils.add_main_character_2( AuthUtils.add_main_character_2(
@@ -45,18 +35,25 @@ def create_testdata():
name='Clark Kent', name='Clark Kent',
character_id=1002, character_id=1002,
corp_id=2002, corp_id=2002,
corp_name='Wayne Technologies' corp_name='Wayne Food'
) )
return group, user_1, user_2 # user 3
user_3 = AuthUtils.create_user('Peter Parker')
AuthUtils.add_main_character_2(
user_2,
name='Peter Parker',
character_id=1003,
corp_id=2002,
corp_name='Wayne Food'
)
return group, user_1, user_2, user_3
class TestGroupRequest(TestCase): class TestGroupRequest(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.group, cls.user_1, _ = create_testdata() cls.group, cls.user_1, cls.user_2, cls.user_3 = create_testdata()
def test_main_char(self): def test_main_char(self):
group_request = GroupRequest.objects.create( group_request = GroupRequest.objects.create(
@@ -74,13 +71,85 @@ class TestGroupRequest(TestCase):
expected = 'Bruce Wayne:Superheros' expected = 'Bruce Wayne:Superheros'
self.assertEqual(str(group_request), expected) self.assertEqual(str(group_request), expected)
@override_settings(GROUPMANAGEMENT_REQUESTS_NOTIFICATION=True)
def test_should_notify_leaders_about_join_request(self):
# given
group_request = GroupRequest.objects.create(
user=self.user_2, group=self.group
)
# when
with mock.patch(MODULE_PATH + ".notify") as mock_notify:
group_request.notify_leaders()
# then
self.assertTrue(mock_notify.called)
_, kwargs = mock_notify.call_args
self.assertEqual(kwargs["user"],self.user_1)
@override_settings(GROUPMANAGEMENT_REQUESTS_NOTIFICATION=True)
def test_should_notify_leaders_about_leave_request(self):
# given
group_request = GroupRequest.objects.create(
user=self.user_2, group=self.group
)
# when
with mock.patch(MODULE_PATH + ".notify") as mock_notify:
group_request.notify_leaders()
# then
self.assertTrue(mock_notify.called)
@override_settings(GROUPMANAGEMENT_REQUESTS_NOTIFICATION=True)
def test_should_handle_notify_leaders_without_leaders(self):
# given
group = Group.objects.create(name='Dummy')
group.authgroup.internal = False
group.authgroup.hidden = False
group.authgroup.save()
group_request = GroupRequest.objects.create(
user=self.user_2, group=group
)
# when
with mock.patch(MODULE_PATH + ".notify") as mock_notify:
group_request.notify_leaders()
# then
self.assertFalse(mock_notify.called)
@override_settings(GROUPMANAGEMENT_REQUESTS_NOTIFICATION=False)
def test_should_not_notify_leaders_if_disabled(self):
# given
group_request = GroupRequest.objects.create(
user=self.user_2, group=self.group
)
# when
with mock.patch(MODULE_PATH + ".notify") as mock_notify:
group_request.notify_leaders()
# then
self.assertFalse(mock_notify.called)
@override_settings(GROUPMANAGEMENT_REQUESTS_NOTIFICATION=True)
def test_should_notify_members_of_leader_groups_about_join_request(self):
# given
child_group = Group.objects.create(name='Child')
child_group.authgroup.internal = False
child_group.authgroup.hidden = False
child_group.authgroup.save()
child_group.authgroup.group_leader_groups.add(self.group)
group_request = GroupRequest.objects.create(
user=self.user_2, group=child_group
)
# when
with mock.patch(MODULE_PATH + ".notify") as mock_notify:
group_request.notify_leaders()
# then
self.assertTrue(mock_notify.called)
_, kwargs = mock_notify.call_args
self.assertEqual(kwargs["user"],self.user_1)
class TestRequestLog(TestCase): class TestRequestLog(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.group, cls.user_1, cls.user_2 = create_testdata() cls.group, cls.user_1, cls.user_2, _ = create_testdata()
def test_requestor(self): def test_requestor(self):
request_log = RequestLog.objects.create( request_log = RequestLog.objects.create(
@@ -126,7 +195,7 @@ class TestRequestLog(TestCase):
group=self.group, group=self.group,
request_info='Clark Kent:Superheros', request_info='Clark Kent:Superheros',
request_actor=self.user_1, request_actor=self.user_1,
action = True action=True
) )
expected = 'Accept' expected = 'Accept'
self.assertEqual(request_log.action_to_str(), expected) self.assertEqual(request_log.action_to_str(), expected)
@@ -136,7 +205,7 @@ class TestRequestLog(TestCase):
group=self.group, group=self.group,
request_info='Clark Kent:Superheros', request_info='Clark Kent:Superheros',
request_actor=self.user_1, request_actor=self.user_1,
action = False action=False
) )
expected = 'Reject' expected = 'Reject'
self.assertEqual(request_log.action_to_str(), expected) self.assertEqual(request_log.action_to_str(), expected)
@@ -146,14 +215,13 @@ class TestRequestLog(TestCase):
group=self.group, group=self.group,
request_info='Clark Kent:Superheros', request_info='Clark Kent:Superheros',
request_actor=self.user_1, request_actor=self.user_1,
action = False action=False
) )
expected = self.user_2.profile.main_character expected = self.user_2.profile.main_character
self.assertEqual(request_log.req_char(), expected) self.assertEqual(request_log.req_char(), expected)
class TestAuthGroup(TestCase): class TestAuthGroup(TestCase):
def test_str(self): def test_str(self):
group = Group.objects.create(name='Superheros') group = Group.objects.create(name='Superheros')
group.authgroup.description = 'Default Group' group.authgroup.description = 'Default Group'
@@ -163,3 +231,107 @@ 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):
def setUp(self) -> None:
self.group, self.user_1, self.user_2, self.user_3 = create_testdata()
def test_should_return_leaders_of_main_group_only(self):
# when
leaders = self.group.authgroup.group_request_approvers()
# then
self.assertSetEqual(leaders, {self.user_1})
def test_should_return_members_of_leading_groups_only(self):
# given
parent_group = Group.objects.create(name='Parent')
parent_group.authgroup.group_leaders.add(self.user_2)
self.user_1.groups.add(parent_group)
child_group = Group.objects.create(name='Child')
child_group.authgroup.internal = False
child_group.authgroup.hidden = False
child_group.authgroup.save()
child_group.authgroup.group_leader_groups.add(parent_group)
# when
leaders = child_group.authgroup.group_request_approvers()
# then
self.assertSetEqual(leaders, {self.user_1})
def test_should_return_leaders_of_main_group_and_members_of_leading_groups(self):
# given
parent_group = Group.objects.create(name='Parent')
parent_group.authgroup.group_leaders.add(self.user_2)
self.user_1.groups.add(parent_group)
child_group = Group.objects.create(name='Child')
child_group.authgroup.internal = False
child_group.authgroup.hidden = False
child_group.authgroup.save()
child_group.authgroup.group_leaders.add(self.user_3)
child_group.authgroup.group_leader_groups.add(self.group)
# when
leaders = child_group.authgroup.group_request_approvers()
# then
self.assertSetEqual(leaders, {self.user_1, self.user_3})
def test_can_handle_group_without_leaders(self):
# given
child_group = Group.objects.create(name='Child')
child_group.authgroup.internal = False
child_group.authgroup.hidden = False
child_group.authgroup.save()
# when
leaders = child_group.authgroup.group_request_approvers()
# then
self.assertSetEqual(leaders, set())
class TestReservedGroupName(TestCase):
def test_should_return_name(self):
# given
obj = ReservedGroupName(name="test", reason="abc", created_by="xxx")
# when
result = str(obj)
# then
self.assertEqual(result, "test")
def test_should_not_allow_creating_reserved_name_for_existing_group(self):
# given
Group.objects.create(name="Dummy")
# when
with self.assertRaises(RuntimeError):
ReservedGroupName.objects.create(
name="dummy", reason="abc", created_by="xxx"
)

View File

@@ -6,6 +6,27 @@ from allianceauth.eveonline.autogroups.models import AutogroupsConfig
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from ..models import ReservedGroupName
class TestGroupSignals(TestCase):
def test_should_create_authgroup_when_group_is_created(self):
# when
group = Group.objects.create(name="test")
# then
self.assertEqual(group.authgroup.group, group)
def test_should_rename_group_that_conflicts_with_reserved_name(self):
# given
ReservedGroupName.objects.create(name="test", reason="dummy", created_by="xyz")
ReservedGroupName.objects.create(name="test_1", reason="dummy", created_by="xyz")
# when
group = Group.objects.create(name="Test")
# then
self.assertNotEqual(group.name, "test")
self.assertNotEqual(group.name, "test_1")
class TestCheckGroupsOnStateChange(TestCase): class TestCheckGroupsOnStateChange(TestCase):
@classmethod @classmethod

View File

@@ -1,22 +1,85 @@
from unittest.mock import Mock, patch from django.test import RequestFactory, TestCase, override_settings
from django.test import RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from allianceauth.tests.auth_utils import AuthUtils from allianceauth.tests.auth_utils import AuthUtils
from esi.models import Token
from .. import views from .. import views
def response_content_to_str(response) -> str:
return response.content.decode(response.charset)
class TestViews(TestCase): class TestViews(TestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
self.user = AuthUtils.create_user('Bruce Wayne') self.user = AuthUtils.create_user('Peter Parker')
self.user_with_manage_permission = AuthUtils.create_user('Bruce Wayne')
# set permissions
AuthUtils.add_permission_to_user_by_name(
'auth.group_management', self.user_with_manage_permission
)
def test_groups_view_can_load(self): def test_groups_view_can_load(self):
request = self.factory.get(reverse('groupmanagement:groups')) request = self.factory.get(reverse('groupmanagement:groups'))
request.user = self.user request.user = self.user
response = views.groups_view(request) response = views.groups_view(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_management_view_can_load_for_user_with_permissions(self):
"""
Test if user with management permissions can access the management view
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
self.assertEqual(response.status_code, 200)
def test_management_view_doesnt_load_for_user_without_permissions(self):
"""
Test if user without management permissions can't access the management view
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user
response = views.group_management(request)
self.assertEqual(response.status_code, 302)
@override_settings(GROUPMANAGEMENT_AUTO_LEAVE=False)
def test_leave_requests_tab_visible(self):
"""
Test if the leave requests tab is visible when GROUPMANAGEMENT_AUTO_LEAVE = False
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
content = response_content_to_str(response)
self.assertEqual(response.status_code, 200)
self.assertIn('<a data-toggle="tab" href="#leave">', content)
self.assertIn('<div id="leave" class="tab-pane">', content)
@override_settings(GROUPMANAGEMENT_AUTO_LEAVE=True)
def test_leave_requests_tab_hidden(self):
"""
Test if the leave requests tab is hidden when GROUPMANAGEMENT_AUTO_LEAVE = True
:return:
"""
request = self.factory.get(reverse('groupmanagement:management'))
request.user = self.user_with_manage_permission
response = views.group_management(request)
content = response_content_to_str(response)
self.assertEqual(response.status_code, 200)
self.assertNotIn('<a data-toggle="tab" href="#leave">', content)
self.assertNotIn('<div id="leave" class="tab-pane">', content)

View File

@@ -45,7 +45,11 @@ def group_management(request):
logger.debug("Providing user {} with {} acceptrequests and {} leaverequests.".format( logger.debug("Providing user {} with {} acceptrequests and {} leaverequests.".format(
request.user, len(acceptrequests), len(leaverequests))) request.user, len(acceptrequests), len(leaverequests)))
render_items = {'acceptrequests': acceptrequests, 'leaverequests': leaverequests} render_items = {
'acceptrequests': acceptrequests,
'leaverequests': leaverequests,
'auto_leave': getattr(settings, 'GROUPMANAGEMENT_AUTO_LEAVE', False),
}
return render(request, 'groupmanagement/index.html', context=render_items) return render(request, 'groupmanagement/index.html', context=render_items)
@@ -359,6 +363,7 @@ def group_request_add(request, group_id):
grouprequest.leave_request = False grouprequest.leave_request = False
grouprequest.save() grouprequest.save()
logger.info(f"Created group request for user {request.user} to group {Group.objects.get(id=group_id)}") logger.info(f"Created group request for user {request.user} to group {Group.objects.get(id=group_id)}")
grouprequest.notify_leaders()
messages.success(request, _('Applied to group %(group)s.') % {"group": group}) messages.success(request, _('Applied to group %(group)s.') % {"group": group})
return redirect("groupmanagement:groups") return redirect("groupmanagement:groups")
@@ -387,7 +392,7 @@ def group_request_leave(request, group_id):
logger.info(f"{request.user} attempted to leave {group} but already has an pending leave request.") logger.info(f"{request.user} attempted to leave {group} but already has an pending leave request.")
messages.warning(request, _("You already have a pending leave request for that group.")) messages.warning(request, _("You already have a pending leave request for that group."))
return redirect("groupmanagement:groups") return redirect("groupmanagement:groups")
if getattr(settings, 'AUTO_LEAVE', False): if getattr(settings, 'GROUPMANAGEMENT_AUTO_LEAVE', False):
logger.info(f"{request.user} leaving joinable group {group} due to auto_leave") logger.info(f"{request.user} leaving joinable group {group} due to auto_leave")
request_info = request.user.username + ":" + group.name request_info = request.user.username + ":" + group.name
log = RequestLog(request_type=True, group=group, request_info=request_info, action=1, request_actor=request.user) log = RequestLog(request_type=True, group=group, request_info=request_info, action=1, request_actor=request.user)
@@ -400,5 +405,6 @@ def group_request_leave(request, group_id):
grouprequest.leave_request = True grouprequest.leave_request = True
grouprequest.save() grouprequest.save()
logger.info(f"Created group leave request for user {request.user} to group {Group.objects.get(id=group_id)}") logger.info(f"Created group leave request for user {request.user} to group {Group.objects.get(id=group_id)}")
grouprequest.notify_leaders()
messages.success(request, _('Applied to leave group %(group)s.') % {"group": group}) messages.success(request, _('Applied to leave group %(group)s.') % {"group": group})
return redirect("groupmanagement:groups") return redirect("groupmanagement:groups")

View File

@@ -5,17 +5,17 @@
# #
# Translators: # Translators:
# Erik Kalkoken <erik.kalkoken@gmail.com>, 2020 # Erik Kalkoken <erik.kalkoken@gmail.com>, 2020
# Peter Pfeufer <rounon.dax@terra-nanotech.de>, 2021
# Joel Falknau <ozirascal@gmail.com>, 2021 # Joel Falknau <ozirascal@gmail.com>, 2021
# Peter Pfeufer <rounon.dax@terra-nanotech.de>, 2021
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2021\n" "Last-Translator: Peter Pfeufer <rounon.dax@terra-nanotech.de>, 2021\n"
"Language-Team: German (https://www.transifex.com/alliance-auth/teams/107430/de/)\n" "Language-Team: German (https://www.transifex.com/alliance-auth/teams/107430/de/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -40,12 +40,12 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "E-Mail" msgstr "E-Mail"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "Status geändert zu %s" msgstr "Status geändert zu %s"
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "Dein Nutzerstatus ist nun %(state)s" msgstr "Dein Nutzerstatus ist nun %(state)s"
@@ -66,29 +66,29 @@ msgstr ""
"\n" "\n"
"Hauptcharakter (Status: %(state)s)" "Hauptcharakter (Status: %(state)s)"
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "Kein Hauptcharakter gesetzt." msgstr "Kein Hauptcharakter gesetzt."
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "Charakter hinzufügen" msgstr "Charakter hinzufügen"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "Hauptcharakter ändern" msgstr "Hauptcharakter ändern"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "Gruppen" msgstr "Gruppen"
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "Charaktere" msgstr "Charaktere"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -97,13 +97,13 @@ msgstr "Charaktere"
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "Corp" msgstr "Corp"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -397,7 +397,7 @@ msgstr "Benutzername"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -476,7 +476,6 @@ msgstr "Flotte"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -485,8 +484,8 @@ msgstr "Ersteller"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "Dauer" msgstr "Dauer"
@@ -571,11 +570,128 @@ msgstr "Flottenteilnahme registriert."
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "FAT-Link ist abgelaufen." msgstr "FAT-Link ist abgelaufen."
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
"Dieser Name ist reserviert und kann nicht als Gruppenname genutzt werden."
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr "(automatisch)"
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr "Es existiert bereits eine Gruppe mit diesem Namen."
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "Gruppenverwaltung" msgstr "Gruppenverwaltung"
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
"Interne Gruppe. Nutzer können diese nicht sehen und dieser nicht beitreten. "
"<br>Dies ist für Gruppen genutzt wie Mitglieder, Corp_*, Allianz_*, "
"etc.<br><b>Überschreibt die Versteckt und Offen Option wenn gesetzt</b>"
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
"Diese Gruppe ist nicht sichtbar, aber Nutzer können dennoch beitreten wenn "
"diese den Link hierzu haben."
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
"Gruppe ist offen und Nutzer werden dieser automatisch hinzugefügt bei "
"Anfrage.<br>Wenn die Gruppe nicht offen ist, müssen Anfragen manuell "
"bestätigt werden."
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
"Öffentliche Gruppe. Jeder registrierte Nutzer kann dieser Gruppe beitreten, "
"he nach gesetzter Sichtbarkeit in den andern Optionen dieser Gruppe.<br>Auth"
" wird Nutzer nicht von dieser Gruppe entfernen wenn diese nicht länger "
"authentifiziert sind."
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
"Gruppenleiter können Anfragen für diese Gruppe bearbeiten. Nutze die "
"<code>auth.group_management</code> Berechtigung um Nutzern zu erlauben alle "
"Gruppen zu verwalten<br>"
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
"Mitglieder von Führungsgruppen können Anfragen für diese Gruppe bearbeiten. "
"Nutze die <code>auth.group_management</code> Berechtigung um Nutzern zu "
"erlauben alle Gruppen zu verwalten.<br>"
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
"Hier gelistete Ränge können dieser Gruppe beitreten, vorausgesetzt sie haben"
" die entsprechenden Berechtigungen.<br>"
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
"Kurze Beschreibung <i>(max. 512 Zeichen)</i> der Gruppe die dem Nutzer "
"angezeigt wird."
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr "Kann nicht öffentlichen Gruppen beitreten"
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr "Name"
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr "Name kann nicht für Gruppen genutzt werden"
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr "Grund"
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr "Grund wieso dieser Name reserviert ist."
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr "Erstellt bei"
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr "Erstellt"
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr "Datum der Erstellung dieses Eintrags"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -603,7 +719,7 @@ msgstr "Typ"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -669,6 +785,7 @@ msgstr "Gruppen"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "Beschreibung" msgstr "Beschreibung"
@@ -865,24 +982,24 @@ msgstr "Du bist bereits Mitglied dieser Gruppe."
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "Du hast Dich bereits für diese Gruppe beworben." msgstr "Du hast Dich bereits für diese Gruppe beworben."
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "Beitritt zur Gruppe %(group)s beantragt." msgstr "Beitritt zur Gruppe %(group)s beantragt."
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "Du kannst diese Gruppe nicht verlassen" msgstr "Du kannst diese Gruppe nicht verlassen"
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "Du bist kein Mitglied dieser Gruppe" msgstr "Du bist kein Mitglied dieser Gruppe"
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "Du hast bereits ein ausstehendes Austrittsgesuch für diese Gruppe." msgstr "Du hast bereits ein ausstehendes Austrittsgesuch für diese Gruppe."
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "Austrittsgesuch für Gruppe %(group)s gesendet." msgstr "Austrittsgesuch für Gruppe %(group)s gesendet."
@@ -1143,43 +1260,56 @@ msgstr "Alle gelesenen Benachrichtigungen gelöscht."
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "Flottenoperationen" msgstr "Flottenoperationen"
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "Doktrin" msgstr "Doktrin"
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "Startzeit" msgstr "Startzeit"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "Operationsname" msgstr "Operationsname"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr "Operationsart"
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "Flottenkommandeur" msgstr "Flottenkommandeur"
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Zusätzliche Info"
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr "(Optinal) Beschreibe die Operation mit ein paar Worten"
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "Operation erstellen" msgstr "Operation erstellen"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "Form Up System" msgstr "Form Up System"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "Ortszeit" msgstr "Ortszeit"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "FC" msgstr "FC"
@@ -1197,9 +1327,8 @@ msgid "Current Eve Time:"
msgstr "Momentane Eve Zeit" msgstr "Momentane Eve Zeit"
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers" msgstr "Anstehende Flottenoperationen"
msgstr "Nächste Timer"
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
#: allianceauth/timerboard/templates/timerboard/view.html:363 #: allianceauth/timerboard/templates/timerboard/view.html:363
@@ -1207,9 +1336,8 @@ msgid "No upcoming timers."
msgstr "Keine kommenden Timer." msgstr "Keine kommenden Timer."
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers" msgstr "Vergangene Flottenoperationen"
msgstr "Vergangene Timer"
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:536 #: allianceauth/timerboard/templates/timerboard/view.html:536
@@ -1226,17 +1354,17 @@ msgstr "Aktualisiere Flottenoperationen"
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "Flottenoperation existiert nicht" msgstr "Flottenoperation existiert nicht"
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "Operation timer für %(opname)s erstellt." msgstr "Operation timer für %(opname)s erstellt."
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "Operation timer für %(opname)s entfernt." msgstr "Operation timer für %(opname)s entfernt."
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "Änderungen für Operation timer %(opname)s gespeichert." msgstr "Änderungen für Operation timer %(opname)s gespeichert."
@@ -1400,11 +1528,11 @@ msgstr "Passwort"
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "Passwort muss mindestens 8 Zeichen lang sein" msgstr "Passwort muss mindestens 8 Zeichen lang sein"
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "Discord Konto deaktiviert" msgstr "Discord Konto deaktiviert"
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1740,10 +1868,6 @@ msgstr "Flottenzeit"
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "Flottendoktrin" msgstr "Flottendoktrin"
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Zusätzliche Info"
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "Killboard Link (zkillboard.com oder kb.evetools.org)" msgstr "Killboard Link (zkillboard.com oder kb.evetools.org)"
@@ -1958,12 +2082,12 @@ msgstr ""
"Der Killmail Link Deiner SRP Anfrage ist ungültig. Bitte stelle sicher, dass" "Der Killmail Link Deiner SRP Anfrage ist ungültig. Bitte stelle sicher, dass"
" Du zKillboard benutzt." " Du zKillboard benutzt."
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "SRP Anfrage für Deine %(ship)s gesendet." msgstr "SRP Anfrage für Deine %(ship)s gesendet."
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
@@ -1972,40 +2096,40 @@ msgstr ""
"Charakter %(charid)s gehört nicht zu Deinem Auth Konto. Bitte füge den API " "Charakter %(charid)s gehört nicht zu Deinem Auth Konto. Bitte füge den API "
"Key für diesen Charakter hinzu und versuche es erneut." "Key für diesen Charakter hinzu und versuche es erneut."
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "Keine SRP Anfragen ausgewählt." msgstr "Keine SRP Anfragen ausgewählt."
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "Ausgewählte SRP Anfrage konnte nicht gefunden werden." msgstr "Ausgewählte SRP Anfrage konnte nicht gefunden werden."
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "%(numrequests)s SRP Anfragen gelöscht" msgstr "%(numrequests)s SRP Anfragen gelöscht"
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "%(numrequests)s SRP Anfragen bestätigt." msgstr "%(numrequests)s SRP Anfragen bestätigt."
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "Ausgewählte SRP Anfrage konnte nicht gefunden werden." msgstr "Ausgewählte SRP Anfrage konnte nicht gefunden werden."
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "%(numrequests)s SRP Anfragen abgelehnt." msgstr "%(numrequests)s SRP Anfragen abgelehnt."
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "Unfähig SRP Anfrage mit der ID %(requestid)s zu finden." msgstr "Unfähig SRP Anfrage mit der ID %(requestid)s zu finden."
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "Änderungen der SRP Flotte %(fleetname)s gespeichert" msgstr "Änderungen der SRP Flotte %(fleetname)s gespeichert"
@@ -2132,7 +2256,7 @@ msgstr "Strukturen Typ"
#: allianceauth/timerboard/form.py:62 #: allianceauth/timerboard/form.py:62
msgid "Timer Type" msgid "Timer Type"
msgstr "" msgstr "Timer Typ"
#: allianceauth/timerboard/form.py:63 #: allianceauth/timerboard/form.py:63
#: allianceauth/timerboard/templates/timerboard/view.html:33 #: allianceauth/timerboard/templates/timerboard/view.html:33
@@ -2163,15 +2287,15 @@ msgstr "Auf Corp beschränkt"
#: allianceauth/timerboard/models.py:14 #: allianceauth/timerboard/models.py:14
msgid "Not Specified" msgid "Not Specified"
msgstr "" msgstr "Keine Angabe"
#: allianceauth/timerboard/models.py:15 #: allianceauth/timerboard/models.py:15
msgid "Shield" msgid "Shield"
msgstr "" msgstr "Schild"
#: allianceauth/timerboard/models.py:16 #: allianceauth/timerboard/models.py:16
msgid "Armor" msgid "Armor"
msgstr "" msgstr "Panzerung"
#: allianceauth/timerboard/models.py:17 #: allianceauth/timerboard/models.py:17
msgid "Hull" msgid "Hull"
@@ -2179,15 +2303,15 @@ msgstr "Hülle"
#: allianceauth/timerboard/models.py:18 #: allianceauth/timerboard/models.py:18
msgid "Final" msgid "Final"
msgstr "" msgstr "Final"
#: allianceauth/timerboard/models.py:19 #: allianceauth/timerboard/models.py:19
msgid "Anchoring" msgid "Anchoring"
msgstr "" msgstr "Ankernd"
#: allianceauth/timerboard/models.py:20 #: allianceauth/timerboard/models.py:20
msgid "Unanchoring" msgid "Unanchoring"
msgstr "" msgstr "Entankernd"
#: allianceauth/timerboard/templates/timerboard/timer_confirm_delete.html:11 #: allianceauth/timerboard/templates/timerboard/timer_confirm_delete.html:11
msgid "Delete Timer" msgid "Delete Timer"
@@ -2232,6 +2356,14 @@ msgstr "Corp Timer"
msgid "Structure" msgid "Structure"
msgstr "Struktur" msgstr "Struktur"
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr "Nächste Timer"
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr "Vergangene Timer"
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -34,12 +34,12 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "" msgstr ""
@@ -58,29 +58,29 @@ msgid ""
" " " "
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -89,13 +89,13 @@ msgstr ""
msgid "Name" msgid "Name"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -380,7 +380,7 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -459,7 +459,6 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -468,8 +467,8 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "" msgstr ""
@@ -554,11 +553,104 @@ msgstr ""
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "" msgstr ""
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this group."
"<br>Used for groups such as Members, Corp_*, Alliance_* etc.<br><b>Overrides "
"Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the <code>auth."
"group_management</code> permission to allow a user to manage all groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -586,7 +678,7 @@ msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -652,6 +744,7 @@ msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "" msgstr ""
@@ -844,24 +937,24 @@ msgstr ""
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "" msgstr ""
@@ -1122,43 +1215,56 @@ msgstr ""
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "" msgstr ""
@@ -1176,8 +1282,7 @@ msgid "Current Eve Time:"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
@@ -1186,8 +1291,7 @@ msgid "No upcoming timers."
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
@@ -1205,17 +1309,17 @@ msgstr ""
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "" msgstr ""
@@ -1379,11 +1483,11 @@ msgstr ""
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1706,10 +1810,6 @@ msgstr ""
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "" msgstr ""
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1919,52 +2019,52 @@ msgid ""
"zKillboard." "zKillboard."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
"API key for this character and try again" "API key for this character and try again"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "" msgstr ""
@@ -2191,6 +2291,14 @@ msgstr ""
msgid "Structure" msgid "Structure"
msgstr "" msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr ""
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -12,7 +12,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2021\n" "Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2021\n"
"Language-Team: Spanish (https://www.transifex.com/alliance-auth/teams/107430/es/)\n" "Language-Team: Spanish (https://www.transifex.com/alliance-auth/teams/107430/es/)\n"
@@ -39,12 +39,12 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "E-mail" msgstr "E-mail"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "" msgstr ""
@@ -63,29 +63,29 @@ msgid ""
" " " "
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "No se ha seleccionado un personaje principal." msgstr "No se ha seleccionado un personaje principal."
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "Agregar Personaje" msgstr "Agregar Personaje"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "Cambiar Personaje Principal" msgstr "Cambiar Personaje Principal"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "Personajes" msgstr "Personajes"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -94,13 +94,13 @@ msgstr "Personajes"
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "Corporación" msgstr "Corporación"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -391,7 +391,7 @@ msgstr "Usuario"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -470,7 +470,6 @@ msgstr "Flota"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -479,8 +478,8 @@ msgstr "Creador"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "Duracion" msgstr "Duracion"
@@ -565,11 +564,105 @@ msgstr "Participacion de flota registrada."
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "Enlace de participacion expirado." msgstr "Enlace de participacion expirado."
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "Manejo de Grupo" msgstr "Manejo de Grupo"
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -597,7 +690,7 @@ msgstr "Tipo"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -663,6 +756,7 @@ msgstr "Grupos"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "Descripcion" msgstr "Descripcion"
@@ -858,24 +952,24 @@ msgstr ""
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "Solicitud enviada al grupo %(group)s." msgstr "Solicitud enviada al grupo %(group)s."
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "No puedes dejar el grupos" msgstr "No puedes dejar el grupos"
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "No eres miembro de ese grupo" msgstr "No eres miembro de ese grupo"
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "" msgstr ""
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "Solicitaste dejar el grupo %(group)s." msgstr "Solicitaste dejar el grupo %(group)s."
@@ -1136,43 +1230,56 @@ msgstr "Se borraron todas las notificaciones leidas."
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "Operaciones de Flota" msgstr "Operaciones de Flota"
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "Doctrina" msgstr "Doctrina"
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "Tiempo de inicio" msgstr "Tiempo de inicio"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "Nombre de la operacion" msgstr "Nombre de la operacion"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "Comandante" msgstr "Comandante"
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Informacion Adicional"
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "Create Operacion" msgstr "Create Operacion"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "Sistema de encuentro" msgstr "Sistema de encuentro"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "Tiempo Local" msgstr "Tiempo Local"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "Comandante" msgstr "Comandante"
@@ -1190,9 +1297,8 @@ msgid "Current Eve Time:"
msgstr "Tipo en EVE actual:" msgstr "Tipo en EVE actual:"
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers" msgstr ""
msgstr "Siguientes Timers"
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
#: allianceauth/timerboard/templates/timerboard/view.html:363 #: allianceauth/timerboard/templates/timerboard/view.html:363
@@ -1200,9 +1306,8 @@ msgid "No upcoming timers."
msgstr "No hay proximos timers." msgstr "No hay proximos timers."
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers" msgstr ""
msgstr "Timers Pasados"
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:536 #: allianceauth/timerboard/templates/timerboard/view.html:536
@@ -1219,17 +1324,17 @@ msgstr "Actualizar Operacion"
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "La operacion no existe" msgstr "La operacion no existe"
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "Se creo operacion para el timer %(opname)s." msgstr "Se creo operacion para el timer %(opname)s."
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "Se removio la operacion para %(opname)s." msgstr "Se removio la operacion para %(opname)s."
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "Se guardaron los cambios para la operacion %(opname)s" msgstr "Se guardaron los cambios para la operacion %(opname)s"
@@ -1393,11 +1498,11 @@ msgstr "Contraseña"
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "La contraseña tiene que tener 8 caracteres de largo minimo" msgstr "La contraseña tiene que tener 8 caracteres de largo minimo"
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1722,10 +1827,6 @@ msgstr "Hora de flota"
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "Doctrina" msgstr "Doctrina"
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Informacion Adicional"
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1936,52 +2037,52 @@ msgid ""
msgstr "" msgstr ""
"El enalce suministrado no es valido. Por favor verifica si esats usando ZKB." "El enalce suministrado no es valido. Por favor verifica si esats usando ZKB."
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "Solicitud de SRP para tu %(ship)s completo." msgstr "Solicitud de SRP para tu %(ship)s completo."
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
"API key for this character and try again" "API key for this character and try again"
msgstr "El personaje %(charid)s no pertenece a tu cuenta" msgstr "El personaje %(charid)s no pertenece a tu cuenta"
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "No se selecciono ninguna solicitud de SRP" msgstr "No se selecciono ninguna solicitud de SRP"
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "Imposible localizar la solicitud de SRP." msgstr "Imposible localizar la solicitud de SRP."
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "Se borraron %(numrequests)s pedidos de SRP" msgstr "Se borraron %(numrequests)s pedidos de SRP"
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "Se aprobaron %(numrequests)s pedidos de SRP" msgstr "Se aprobaron %(numrequests)s pedidos de SRP"
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "Imposible localizar el pedido de SRP" msgstr "Imposible localizar el pedido de SRP"
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "Se rechazaron %(numrequests)s pedios de SRP." msgstr "Se rechazaron %(numrequests)s pedios de SRP."
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "Imposible localizar la solicitud de SRP con ID %(requestid)s" msgstr "Imposible localizar la solicitud de SRP con ID %(requestid)s"
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "Se guardaron los cambios en el SRP de la flota %(fleetname)s" msgstr "Se guardaron los cambios en el SRP de la flota %(fleetname)s"
@@ -2208,6 +2309,14 @@ msgstr "Timers de Corporacion"
msgid "Structure" msgid "Structure"
msgstr "Estructura" msgstr "Estructura"
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr "Siguientes Timers"
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr "Timers Pasados"
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,17 @@
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# #
# Translators: # Translators:
# Linus Hope, 2021
# Alessandro Cresti, 2021 # Alessandro Cresti, 2021
# Linus Hope, 2021
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Alessandro Cresti, 2021\n" "Last-Translator: Linus Hope, 2021\n"
"Language-Team: Italian (Italy) (https://www.transifex.com/alliance-auth/teams/107430/it_IT/)\n" "Language-Team: Italian (Italy) (https://www.transifex.com/alliance-auth/teams/107430/it_IT/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -40,12 +40,12 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "" msgstr ""
@@ -67,29 +67,29 @@ msgstr ""
" Personaggio principale (State: %(state)s)\n" " Personaggio principale (State: %(state)s)\n"
" " " "
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "Nessun personaggio principale impostato" msgstr "Nessun personaggio principale impostato"
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "Aggiungi personaggio" msgstr "Aggiungi personaggio"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "Cambia personaggio principale" msgstr "Cambia personaggio principale"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "Gruppi dei quali fai parte" msgstr "Gruppi dei quali fai parte"
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "Personaggi" msgstr "Personaggi"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -98,13 +98,13 @@ msgstr "Personaggi"
msgid "Name" msgid "Name"
msgstr "Nome" msgstr "Nome"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "Corporazione" msgstr "Corporazione"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -117,7 +117,7 @@ msgstr ""
#: allianceauth/authentication/templates/public/register.html:7 #: allianceauth/authentication/templates/public/register.html:7
msgid "Registration" msgid "Registration"
msgstr "" msgstr "Iscriviti"
#: allianceauth/authentication/templates/public/register.html:22 #: allianceauth/authentication/templates/public/register.html:22
#: allianceauth/authentication/templates/registration/registration_form.html:5 #: allianceauth/authentication/templates/registration/registration_form.html:5
@@ -217,7 +217,7 @@ msgstr "Statistiche della corporazione"
#: allianceauth/corputils/templates/corputils/base.html:3 #: allianceauth/corputils/templates/corputils/base.html:3
#: allianceauth/corputils/templates/corputils/base.html:6 #: allianceauth/corputils/templates/corputils/base.html:6
msgid "Corporation Member Data" msgid "Corporation Member Data"
msgstr "" msgstr "Informazioni sui membri della corporazione"
#: allianceauth/corputils/templates/corputils/base.html:12 #: allianceauth/corputils/templates/corputils/base.html:12
msgid "Corporations" msgid "Corporations"
@@ -401,7 +401,7 @@ msgstr "Utente"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -480,7 +480,6 @@ msgstr "Flotta"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -489,8 +488,8 @@ msgstr "Autore"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "Durata" msgstr "Durata"
@@ -575,11 +574,105 @@ msgstr "Partecipazione alla flotta registrata."
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "Il FAT link è scaduto." msgstr "Il FAT link è scaduto."
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "" msgstr ""
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -607,7 +700,7 @@ msgstr "Tipo"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -673,6 +766,7 @@ msgstr "Gruppi"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "Descrizione" msgstr "Descrizione"
@@ -806,20 +900,20 @@ msgstr ""
#: allianceauth/groupmanagement/views.py:159 #: allianceauth/groupmanagement/views.py:159
#, python-format #, python-format
msgid "Removed user %(user)s from group %(group)s." msgid "Removed user %(user)s from group %(group)s."
msgstr "" msgstr "Rimosso il membro %(user)s da %(group)s."
#: allianceauth/groupmanagement/views.py:161 #: allianceauth/groupmanagement/views.py:161
msgid "User does not exist in that group" msgid "User does not exist in that group"
msgstr "" msgstr "Lutente non fa parte del gruppo selezionato"
#: allianceauth/groupmanagement/views.py:164 #: allianceauth/groupmanagement/views.py:164
msgid "Group does not exist" msgid "Group does not exist"
msgstr "" msgstr "Il gruppo non esiste"
#: allianceauth/groupmanagement/views.py:191 #: allianceauth/groupmanagement/views.py:191
#, python-format #, python-format
msgid "Accepted application from %(mainchar)s to %(group)s." msgid "Accepted application from %(mainchar)s to %(group)s."
msgstr "" msgstr "La domanda di %(mainchar)s per %(group)s è stata accettata."
#: allianceauth/groupmanagement/views.py:197 #: allianceauth/groupmanagement/views.py:197
#: allianceauth/groupmanagement/views.py:228 #: allianceauth/groupmanagement/views.py:228
@@ -828,16 +922,20 @@ msgid ""
"An unhandled error occurred while processing the application from " "An unhandled error occurred while processing the application from "
"%(mainchar)s to %(group)s." "%(mainchar)s to %(group)s."
msgstr "" msgstr ""
"Si è verificato unerrore durante lelaborazione della domanda di "
"%(mainchar)s per %(group)s."
#: allianceauth/groupmanagement/views.py:222 #: allianceauth/groupmanagement/views.py:222
#, python-format #, python-format
msgid "Rejected application from %(mainchar)s to %(group)s." msgid "Rejected application from %(mainchar)s to %(group)s."
msgstr "" msgstr "La domanda di %(mainchar)s per %(group)s è stata rifiutata."
#: allianceauth/groupmanagement/views.py:257 #: allianceauth/groupmanagement/views.py:257
#, python-format #, python-format
msgid "Accepted application from %(mainchar)s to leave %(group)s." msgid "Accepted application from %(mainchar)s to leave %(group)s."
msgstr "" msgstr ""
"La domanda di congedo da parte di %(mainchar)s per %(group)s è stata "
"accettata."
#: allianceauth/groupmanagement/views.py:262 #: allianceauth/groupmanagement/views.py:262
#: allianceauth/groupmanagement/views.py:294 #: allianceauth/groupmanagement/views.py:294
@@ -846,91 +944,95 @@ msgid ""
"An unhandled error occurred while processing the application from " "An unhandled error occurred while processing the application from "
"%(mainchar)s to leave %(group)s." "%(mainchar)s to leave %(group)s."
msgstr "" msgstr ""
"Si è verificato unerrore durante lelaborazione della domanda di comgedo da"
" parte di %(mainchar)s per %(group)s."
#: allianceauth/groupmanagement/views.py:288 #: allianceauth/groupmanagement/views.py:288
#, python-format #, python-format
msgid "Rejected application from %(mainchar)s to leave %(group)s." msgid "Rejected application from %(mainchar)s to leave %(group)s."
msgstr "" msgstr ""
"La domanda di congedo da parte di %(mainchar)s per %(group)s è stata "
"rifiutata."
#: allianceauth/groupmanagement/views.py:332 #: allianceauth/groupmanagement/views.py:332
#: allianceauth/groupmanagement/views.py:342 #: allianceauth/groupmanagement/views.py:342
msgid "You cannot join that group" msgid "You cannot join that group"
msgstr "" msgstr "Non puoi aderire a questo gruppo"
#: allianceauth/groupmanagement/views.py:337 #: allianceauth/groupmanagement/views.py:337
msgid "You are already a member of that group." msgid "You are already a member of that group."
msgstr "" msgstr "Sei già parte del gruppo selezionato."
#: allianceauth/groupmanagement/views.py:354 #: allianceauth/groupmanagement/views.py:354
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "" msgstr "La tua domanda per questo gruppo non è ancora stata valutata."
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "" msgstr "Hai fatto domanda per il gruppo %(group)s."
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "" msgstr "Non puoi lasciare questo gruppo."
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "" msgstr "Non sei un membro di questo gruppo."
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "" msgstr "La tua domanda di congedo non è ancora stata valutata."
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "" msgstr "Hai fatto domanda di congedo per %(group)s."
#: allianceauth/hrapplications/auth_hooks.py:14 #: allianceauth/hrapplications/auth_hooks.py:14
msgid "Applications" msgid "Applications"
msgstr "" msgstr "Domande"
#: allianceauth/hrapplications/forms.py:6 #: allianceauth/hrapplications/forms.py:6
#: allianceauth/hrapplications/templates/hrapplications/view.html:92 #: allianceauth/hrapplications/templates/hrapplications/view.html:92
msgid "Comment" msgid "Comment"
msgstr "" msgstr "Commenti"
#: allianceauth/hrapplications/forms.py:10 #: allianceauth/hrapplications/forms.py:10
msgid "Search String" msgid "Search String"
msgstr "" msgstr "Stringa di ricerca"
#: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:5 #: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:5
#: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:8 #: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:8
msgid "Choose a Corp" msgid "Choose a Corp"
msgstr "" msgstr "Seleziona una corporazione"
#: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:11 #: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:11
msgid "Available Corps" msgid "Available Corps"
msgstr "" msgstr "Corporazioni disponibili"
#: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:23 #: allianceauth/hrapplications/templates/hrapplications/corpchoice.html:23
msgid "No corps are accepting applications at this time." msgid "No corps are accepting applications at this time."
msgstr "" msgstr "Nessuna corporazione accetta domanda al momento."
#: allianceauth/hrapplications/templates/hrapplications/create.html:5 #: allianceauth/hrapplications/templates/hrapplications/create.html:5
#: allianceauth/hrapplications/templates/hrapplications/create.html:8 #: allianceauth/hrapplications/templates/hrapplications/create.html:8
msgid "Apply To" msgid "Apply To"
msgstr "" msgstr "Applica a"
#: allianceauth/hrapplications/templates/hrapplications/management.html:6 #: allianceauth/hrapplications/templates/hrapplications/management.html:6
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:6 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:6
msgid "HR Application Management" msgid "HR Application Management"
msgstr "" msgstr "HR Risorse umane"
#: allianceauth/hrapplications/templates/hrapplications/management.html:11 #: allianceauth/hrapplications/templates/hrapplications/management.html:11
msgid "Personal Applications" msgid "Personal Applications"
msgstr "" msgstr "Domande personali"
#: allianceauth/hrapplications/templates/hrapplications/management.html:15 #: allianceauth/hrapplications/templates/hrapplications/management.html:15
#: allianceauth/hrapplications/templates/hrapplications/management.html:18 #: allianceauth/hrapplications/templates/hrapplications/management.html:18
msgid "Create Application" msgid "Create Application"
msgstr "" msgstr "Crea una domanda"
#: allianceauth/hrapplications/templates/hrapplications/management.html:26 #: allianceauth/hrapplications/templates/hrapplications/management.html:26
#: allianceauth/hrapplications/templates/hrapplications/management.html:80 #: allianceauth/hrapplications/templates/hrapplications/management.html:80
@@ -938,7 +1040,7 @@ msgstr ""
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:24 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:24
#: allianceauth/services/templates/services/services.html:16 #: allianceauth/services/templates/services/services.html:16
msgid "Username" msgid "Username"
msgstr "" msgstr "Nome utente"
#: allianceauth/hrapplications/templates/hrapplications/management.html:29 #: allianceauth/hrapplications/templates/hrapplications/management.html:29
#: allianceauth/hrapplications/templates/hrapplications/management.html:84 #: allianceauth/hrapplications/templates/hrapplications/management.html:84
@@ -948,7 +1050,7 @@ msgstr ""
#: allianceauth/srp/templates/srp/data.html:103 #: allianceauth/srp/templates/srp/data.html:103
#: allianceauth/srp/templates/srp/management.html:46 #: allianceauth/srp/templates/srp/management.html:46
msgid "Actions" msgid "Actions"
msgstr "" msgstr "Azioni"
#: allianceauth/hrapplications/templates/hrapplications/management.html:39 #: allianceauth/hrapplications/templates/hrapplications/management.html:39
#: allianceauth/hrapplications/templates/hrapplications/management.html:100 #: allianceauth/hrapplications/templates/hrapplications/management.html:100
@@ -957,7 +1059,7 @@ msgstr ""
#: allianceauth/hrapplications/templates/hrapplications/view.html:16 #: allianceauth/hrapplications/templates/hrapplications/view.html:16
#: allianceauth/srp/templates/srp/data.html:130 #: allianceauth/srp/templates/srp/data.html:130
msgid "Approved" msgid "Approved"
msgstr "" msgstr "Approvato"
#: allianceauth/hrapplications/templates/hrapplications/management.html:41 #: allianceauth/hrapplications/templates/hrapplications/management.html:41
#: allianceauth/hrapplications/templates/hrapplications/management.html:102 #: allianceauth/hrapplications/templates/hrapplications/management.html:102
@@ -965,25 +1067,25 @@ msgstr ""
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:42 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:42
#: allianceauth/srp/templates/srp/data.html:134 #: allianceauth/srp/templates/srp/data.html:134
msgid "Rejected" msgid "Rejected"
msgstr "" msgstr "Rifiutato"
#: allianceauth/hrapplications/templates/hrapplications/management.html:61 #: allianceauth/hrapplications/templates/hrapplications/management.html:61
msgid "Application Management" msgid "Application Management"
msgstr "" msgstr "Gestione delle domande"
#: allianceauth/hrapplications/templates/hrapplications/management.html:65 #: allianceauth/hrapplications/templates/hrapplications/management.html:65
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:16 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:16
msgid "Search Applications" msgid "Search Applications"
msgstr "" msgstr "Cerca domande"
#: allianceauth/hrapplications/templates/hrapplications/management.html:71 #: allianceauth/hrapplications/templates/hrapplications/management.html:71
msgid "Reviewed" msgid "Reviewed"
msgstr "" msgstr "Revisionato"
#: allianceauth/hrapplications/templates/hrapplications/management.html:79 #: allianceauth/hrapplications/templates/hrapplications/management.html:79
#: allianceauth/hrapplications/templates/hrapplications/management.html:123 #: allianceauth/hrapplications/templates/hrapplications/management.html:123
msgid "Date" msgid "Date"
msgstr "" msgstr "Data"
#: allianceauth/hrapplications/templates/hrapplications/management.html:95 #: allianceauth/hrapplications/templates/hrapplications/management.html:95
#: allianceauth/hrapplications/templates/hrapplications/management.html:139 #: allianceauth/hrapplications/templates/hrapplications/management.html:139
@@ -993,126 +1095,126 @@ msgstr ""
#: allianceauth/hrapplications/templates/hrapplications/management.html:114 #: allianceauth/hrapplications/templates/hrapplications/management.html:114
msgid "No pending applications." msgid "No pending applications."
msgstr "" msgstr "Nessuna domanda in sospeso."
#: allianceauth/hrapplications/templates/hrapplications/management.html:163 #: allianceauth/hrapplications/templates/hrapplications/management.html:163
msgid "No reviewed applications." msgid "No reviewed applications."
msgstr "" msgstr "Nessuna domanda revisionata."
#: allianceauth/hrapplications/templates/hrapplications/management.html:177 #: allianceauth/hrapplications/templates/hrapplications/management.html:177
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:63 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:63
#: allianceauth/hrapplications/templates/hrapplications/view.html:135 #: allianceauth/hrapplications/templates/hrapplications/view.html:135
msgid "Close" msgid "Close"
msgstr "" msgstr "Chiudi"
#: allianceauth/hrapplications/templates/hrapplications/management.html:178 #: allianceauth/hrapplications/templates/hrapplications/management.html:178
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:64 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:64
msgid "Application Search" msgid "Application Search"
msgstr "" msgstr "Cerca domande"
#: allianceauth/hrapplications/templates/hrapplications/management.html:185 #: allianceauth/hrapplications/templates/hrapplications/management.html:185
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:71 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:71
msgid "Search" msgid "Search"
msgstr "" msgstr "Cerca"
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:12 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:12
msgid "Application Search Results" msgid "Application Search Results"
msgstr "" msgstr "Risultati della tua ricerca domande"
#: allianceauth/hrapplications/templates/hrapplications/searchview.html:23 #: allianceauth/hrapplications/templates/hrapplications/searchview.html:23
msgid "Application ID" msgid "Application ID"
msgstr "" msgstr "ID Domande"
#: allianceauth/hrapplications/templates/hrapplications/view.html:6 #: allianceauth/hrapplications/templates/hrapplications/view.html:6
#: allianceauth/hrapplications/templates/hrapplications/view.html:11 #: allianceauth/hrapplications/templates/hrapplications/view.html:11
msgid "View Application" msgid "View Application"
msgstr "" msgstr "Visiona domanda"
#: allianceauth/hrapplications/templates/hrapplications/view.html:18 #: allianceauth/hrapplications/templates/hrapplications/view.html:18
msgid "Denied" msgid "Denied"
msgstr "" msgstr "Accesso negato"
#: allianceauth/hrapplications/templates/hrapplications/view.html:28 #: allianceauth/hrapplications/templates/hrapplications/view.html:28
msgid "Applicant" msgid "Applicant"
msgstr "" msgstr "Candidato"
#: allianceauth/hrapplications/templates/hrapplications/view.html:79 #: allianceauth/hrapplications/templates/hrapplications/view.html:79
msgid "Approve" msgid "Approve"
msgstr "" msgstr "Approva"
#: allianceauth/hrapplications/templates/hrapplications/view.html:85 #: allianceauth/hrapplications/templates/hrapplications/view.html:85
msgid "Delete" msgid "Delete"
msgstr "" msgstr "Cancella"
#: allianceauth/hrapplications/templates/hrapplications/view.html:88 #: allianceauth/hrapplications/templates/hrapplications/view.html:88
msgid "Mark in Progress" msgid "Mark in Progress"
msgstr "" msgstr "Segnala in elaborazione"
#: allianceauth/hrapplications/templates/hrapplications/view.html:102 #: allianceauth/hrapplications/templates/hrapplications/view.html:102
#: allianceauth/services/forms.py:17 #: allianceauth/services/forms.py:17
msgid "Comments" msgid "Comments"
msgstr "" msgstr "Commenti"
#: allianceauth/hrapplications/templates/hrapplications/view.html:137 #: allianceauth/hrapplications/templates/hrapplications/view.html:137
#: allianceauth/hrapplications/templates/hrapplications/view.html:144 #: allianceauth/hrapplications/templates/hrapplications/view.html:144
msgid "Add Comment" msgid "Add Comment"
msgstr "" msgstr "Aggiungi commento"
#: allianceauth/notifications/models.py:21 #: allianceauth/notifications/models.py:21
msgid "danger" msgid "danger"
msgstr "" msgstr "pericolo"
#: allianceauth/notifications/models.py:22 #: allianceauth/notifications/models.py:22
msgid "warning" msgid "warning"
msgstr "" msgstr "attenzione"
#: allianceauth/notifications/models.py:23 #: allianceauth/notifications/models.py:23
msgid "info" msgid "info"
msgstr "" msgstr "informazioni"
#: allianceauth/notifications/models.py:24 #: allianceauth/notifications/models.py:24
msgid "success" msgid "success"
msgstr "" msgstr "successo"
#: allianceauth/notifications/templates/notifications/list.html:5 #: allianceauth/notifications/templates/notifications/list.html:5
#: allianceauth/notifications/templates/notifications/list.html:9 #: allianceauth/notifications/templates/notifications/list.html:9
#: allianceauth/templates/allianceauth/notifications_menu_item.html:6 #: allianceauth/templates/allianceauth/notifications_menu_item.html:6
msgid "Notifications" msgid "Notifications"
msgstr "" msgstr "Notifiche"
#: allianceauth/notifications/templates/notifications/list.html:16 #: allianceauth/notifications/templates/notifications/list.html:16
msgid "Unread" msgid "Unread"
msgstr "" msgstr "Non letto"
#: allianceauth/notifications/templates/notifications/list.html:18 #: allianceauth/notifications/templates/notifications/list.html:18
msgid "Read" msgid "Read"
msgstr "" msgstr "Letto"
#: allianceauth/notifications/templates/notifications/list.html:21 #: allianceauth/notifications/templates/notifications/list.html:21
msgid "Mark All Read" msgid "Mark All Read"
msgstr "" msgstr "Seleziona tutto visionato"
#: allianceauth/notifications/templates/notifications/list.html:22 #: allianceauth/notifications/templates/notifications/list.html:22
msgid "Delete All Read" msgid "Delete All Read"
msgstr "" msgstr "Cancella tutti i visionati"
#: allianceauth/notifications/templates/notifications/list.html:33 #: allianceauth/notifications/templates/notifications/list.html:33
#: allianceauth/notifications/templates/notifications/list.html:63 #: allianceauth/notifications/templates/notifications/list.html:63
msgid "Timestamp" msgid "Timestamp"
msgstr "" msgstr "Ora"
#: allianceauth/notifications/templates/notifications/list.html:34 #: allianceauth/notifications/templates/notifications/list.html:34
#: allianceauth/notifications/templates/notifications/list.html:64 #: allianceauth/notifications/templates/notifications/list.html:64
msgid "Title" msgid "Title"
msgstr "" msgstr "Titolo"
#: allianceauth/notifications/templates/notifications/list.html:53 #: allianceauth/notifications/templates/notifications/list.html:53
msgid "No unread notifications." msgid "No unread notifications."
msgstr "" msgstr "Nessuna notifica non visionata."
#: allianceauth/notifications/templates/notifications/list.html:83 #: allianceauth/notifications/templates/notifications/list.html:83
msgid "No read notifications." msgid "No read notifications."
msgstr "" msgstr "Nessuna notifica visionata."
#: allianceauth/notifications/templates/notifications/view.html:5 #: allianceauth/notifications/templates/notifications/view.html:5
#: allianceauth/notifications/templates/notifications/view.html:11 #: allianceauth/notifications/templates/notifications/view.html:11
@@ -1143,43 +1245,56 @@ msgstr ""
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "" msgstr ""
@@ -1197,8 +1312,7 @@ msgid "Current Eve Time:"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
@@ -1207,8 +1321,7 @@ msgid "No upcoming timers."
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
@@ -1226,17 +1339,17 @@ msgstr ""
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "" msgstr ""
@@ -1400,11 +1513,11 @@ msgstr ""
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1729,10 +1842,6 @@ msgstr ""
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "" msgstr ""
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1942,52 +2051,52 @@ msgid ""
"zKillboard." "zKillboard."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
"API key for this character and try again" "API key for this character and try again"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "" msgstr ""
@@ -2214,6 +2323,14 @@ msgstr ""
msgid "Structure" msgid "Structure"
msgstr "" msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr ""
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -12,7 +12,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2020\n" "Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2020\n"
"Language-Team: Japanese (https://www.transifex.com/alliance-auth/teams/107430/ja/)\n" "Language-Team: Japanese (https://www.transifex.com/alliance-auth/teams/107430/ja/)\n"
@@ -38,12 +38,12 @@ msgstr "実行するためにはメインキャラクターの設定が必要で
msgid "Email" msgid "Email"
msgstr "メールアドレス" msgstr "メールアドレス"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "分類が%sに変更されました。" msgstr "分類が%sに変更されました。"
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "あなたの分類は%(state)sになりました。" msgstr "あなたの分類は%(state)sになりました。"
@@ -64,29 +64,29 @@ msgstr ""
"\n" "\n"
" メインキャラクター(分類:%(state)s" " メインキャラクター(分類:%(state)s"
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "メンキャラクターが選択されていません。" msgstr "メンキャラクターが選択されていません。"
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "キャラクターを追加" msgstr "キャラクターを追加"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "メンキャラクターを変更" msgstr "メンキャラクターを変更"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "キャラクター" msgstr "キャラクター"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -95,13 +95,13 @@ msgstr "キャラクター"
msgid "Name" msgid "Name"
msgstr "名前" msgstr "名前"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "Corp" msgstr "Corp"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -386,7 +386,7 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -463,7 +463,6 @@ msgstr ""
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -472,8 +471,8 @@ msgstr "作成者"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "有効時間" msgstr "有効時間"
@@ -558,11 +557,105 @@ msgstr "Fleet参加が登録されました。"
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "Fat-Linkの有効期間が終了してます。" msgstr "Fat-Linkの有効期間が終了してます。"
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "" msgstr ""
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -590,7 +683,7 @@ msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -656,6 +749,7 @@ msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "" msgstr ""
@@ -848,24 +942,24 @@ msgstr "すでにその Group に参加してます。"
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "すでに参加申請を送付済みです。" msgstr "すでに参加申請を送付済みです。"
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "%(group)sへの参加申請を送信しました。" msgstr "%(group)sへの参加申請を送信しました。"
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "この Group から脱退することはできません" msgstr "この Group から脱退することはできません"
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "あなたはその Group のメンバーではありません" msgstr "あなたはその Group のメンバーではありません"
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "すでに脱退申請を送信済みです。" msgstr "すでに脱退申請を送信済みです。"
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "%(group)sからの脱退申請を送信しました。" msgstr "%(group)sからの脱退申請を送信しました。"
@@ -1126,43 +1220,56 @@ msgstr "確認済みのすべての通知を削除"
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "開始時間" msgstr "開始時間"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "作戦名" msgstr "作戦名"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "" msgstr ""
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "" msgstr ""
@@ -1180,8 +1287,7 @@ msgid "Current Eve Time:"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
@@ -1190,8 +1296,7 @@ msgid "No upcoming timers."
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers"
msgstr "" msgstr ""
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
@@ -1209,17 +1314,17 @@ msgstr ""
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "" msgstr ""
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "%(opname)sのTimerが作成されました。" msgstr "%(opname)sのTimerが作成されました。"
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "%(opname)sのTimerが削除されました。" msgstr "%(opname)sのTimerが削除されました。"
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "%(opname)sのTimerの変更が保存されました。" msgstr "%(opname)sのTimerの変更が保存されました。"
@@ -1383,11 +1488,11 @@ msgstr ""
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "Passwordは8 文字以上必要です。" msgstr "Passwordは8 文字以上必要です。"
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "Discordのアカウントを無効化" msgstr "Discordのアカウントを無効化"
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1712,10 +1817,6 @@ msgstr ""
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "" msgstr ""
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr ""
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1928,52 +2029,52 @@ msgid ""
"zKillboard." "zKillboard."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
"API key for this character and try again" "API key for this character and try again"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "" msgstr ""
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "" msgstr ""
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "" msgstr ""
@@ -2199,6 +2300,14 @@ msgstr ""
msgid "Structure" msgid "Structure"
msgstr "" msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr ""
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr ""
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -15,7 +15,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2020\n" "Last-Translator: Joel Falknau <ozirascal@gmail.com>, 2020\n"
"Language-Team: Korean (Korea) (https://www.transifex.com/alliance-auth/teams/107430/ko_KR/)\n" "Language-Team: Korean (Korea) (https://www.transifex.com/alliance-auth/teams/107430/ko_KR/)\n"
@@ -41,12 +41,12 @@ msgstr "해당 기능을 수행하려면 주 캐릭터가 요구됨. 아래에
msgid "Email" msgid "Email"
msgstr "이메일" msgstr "이메일"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "상태가 %s로 변경됐습니다." msgstr "상태가 %s로 변경됐습니다."
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "사용자의 상태는 %(state)s입니다." msgstr "사용자의 상태는 %(state)s입니다."
@@ -68,29 +68,29 @@ msgstr ""
" 메인 캐릭터 (상태: %(state)s)\n" " 메인 캐릭터 (상태: %(state)s)\n"
" " " "
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "주 캐릭터가 지정되지 않음" msgstr "주 캐릭터가 지정되지 않음"
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "캐릭터 추가" msgstr "캐릭터 추가"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "주 캐릭터 변경" msgstr "주 캐릭터 변경"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "그룹 멤버쉽" msgstr "그룹 멤버쉽"
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "캐릭터" msgstr "캐릭터"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -99,13 +99,13 @@ msgstr "캐릭터"
msgid "Name" msgid "Name"
msgstr "이름" msgstr "이름"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "콥" msgstr "콥"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -390,7 +390,7 @@ msgstr "유저"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -467,7 +467,6 @@ msgstr "함대"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -476,8 +475,8 @@ msgstr "생성자"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "소요 시간" msgstr "소요 시간"
@@ -562,11 +561,105 @@ msgstr "플릿 참여 등록됨"
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "플릿활동추적 링크 기한만료" msgstr "플릿활동추적 링크 기한만료"
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "그룹 관리" msgstr "그룹 관리"
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -594,7 +687,7 @@ msgstr "타입"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -660,6 +753,7 @@ msgstr "그룹"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "설명" msgstr "설명"
@@ -852,24 +946,24 @@ msgstr "이미 해당 그룹에 가입되어 있습니다."
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "해당 그룹에 대한 참여신청이 보류되었습니다." msgstr "해당 그룹에 대한 참여신청이 보류되었습니다."
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "%(group)s그룹에 지원하였음." msgstr "%(group)s그룹에 지원하였음."
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "해당 그룹을 떠날 수 없습니다." msgstr "해당 그룹을 떠날 수 없습니다."
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "해당그룹의 멤버가 아닙니다." msgstr "해당그룹의 멤버가 아닙니다."
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "해당 그룹의 탈퇴 신청이 접수된 상태입니다." msgstr "해당 그룹의 탈퇴 신청이 접수된 상태입니다."
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "%(group)s그룹의 탈퇴가 신청됨." msgstr "%(group)s그룹의 탈퇴가 신청됨."
@@ -1130,43 +1224,56 @@ msgstr "모든 읽은 알림을 삭제했습니다."
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "플릿 옵" msgstr "플릿 옵"
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "독트린" msgstr "독트린"
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "시작 시간" msgstr "시작 시간"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "옵 이름" msgstr "옵 이름"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "플릿 커맨더" msgstr "플릿 커맨더"
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "추가 기재 사항"
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "옵 만들기" msgstr "옵 만들기"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "폼업 성계" msgstr "폼업 성계"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "현지 시간" msgstr "현지 시간"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "FC" msgstr "FC"
@@ -1184,9 +1291,8 @@ msgid "Current Eve Time:"
msgstr "현재 이브 시간:" msgstr "현재 이브 시간:"
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers" msgstr ""
msgstr "다음 옵 타이머"
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
#: allianceauth/timerboard/templates/timerboard/view.html:363 #: allianceauth/timerboard/templates/timerboard/view.html:363
@@ -1194,9 +1300,8 @@ msgid "No upcoming timers."
msgstr "예정된 옵 타이머가 없습니다." msgstr "예정된 옵 타이머가 없습니다."
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers" msgstr ""
msgstr "이전 옵 타이머"
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:536 #: allianceauth/timerboard/templates/timerboard/view.html:536
@@ -1213,17 +1318,17 @@ msgstr "플릿 옵 수정"
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "존재하지 않는 플릿 옵" msgstr "존재하지 않는 플릿 옵"
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "%(opname)s 의 옵 타이머를 생성했습니다." msgstr "%(opname)s 의 옵 타이머를 생성했습니다."
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "%(opname)s 의 옵 타이머를 제거했습니다." msgstr "%(opname)s 의 옵 타이머를 제거했습니다."
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "%(opname)s 의 옵 타이머 변경사항을 저장했습니다." msgstr "%(opname)s 의 옵 타이머 변경사항을 저장했습니다."
@@ -1387,11 +1492,11 @@ msgstr "비밀번호"
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "비밀번호는 8글자 이상이어야 합니다." msgstr "비밀번호는 8글자 이상이어야 합니다."
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "디스코드 계정 비활성화" msgstr "디스코드 계정 비활성화"
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1716,10 +1821,6 @@ msgstr "플릿 시간"
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "플릿 독트린" msgstr "플릿 독트린"
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "추가 기재 사항"
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1929,12 +2030,12 @@ msgid ""
"zKillboard." "zKillboard."
msgstr "SRP 보상 요구를 위한 킬메일 링크가 유효하지 않습니다. zkillboard를 사용해 주십시요." msgstr "SRP 보상 요구를 위한 킬메일 링크가 유효하지 않습니다. zkillboard를 사용해 주십시요."
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "%(ship)s에 대한 SRP 보상 요청이 제출되었습니다." msgstr "%(ship)s에 대한 SRP 보상 요청이 제출되었습니다."
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
@@ -1942,40 +2043,40 @@ msgid ""
msgstr "" msgstr ""
"%(charid)s 캐릭터가 Auth 계정에 포함되어 있지 않습니다. 해당 캐릭터의 API를 추가하신 후, 다시 시도하시기 바랍니다." "%(charid)s 캐릭터가 Auth 계정에 포함되어 있지 않습니다. 해당 캐릭터의 API를 추가하신 후, 다시 시도하시기 바랍니다."
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "SRP 보상 요청이 선택되지 않았습니다." msgstr "SRP 보상 요청이 선택되지 않았습니다."
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "선택하신 SRP 보상 요청을 찾을 수 없습니다." msgstr "선택하신 SRP 보상 요청을 찾을 수 없습니다."
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "SRP 보상 요청 %(numrequests)s 삭제 완료" msgstr "SRP 보상 요청 %(numrequests)s 삭제 완료"
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "SRP 보상 요청 %(numrequests)s 승인 완료" msgstr "SRP 보상 요청 %(numrequests)s 승인 완료"
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "선택하신 SRP 보상 요청을 찾을 수 없습니다." msgstr "선택하신 SRP 보상 요청을 찾을 수 없습니다."
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "SRP 보상 요청 %(numrequests)s 거절됨." msgstr "SRP 보상 요청 %(numrequests)s 거절됨."
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "SRP 보상 요청 %(requestid)s을 찾을 수 없습니다. " msgstr "SRP 보상 요청 %(requestid)s을 찾을 수 없습니다. "
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "SRP 보상 요청 플릿 %(fleetname)s의 변경 사항이 저장되었습니다." msgstr "SRP 보상 요청 플릿 %(fleetname)s의 변경 사항이 저장되었습니다."
@@ -2201,6 +2302,14 @@ msgstr "콥 타이머"
msgid "Structure" msgid "Structure"
msgstr "스트럭처" msgstr "스트럭처"
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr "다음 옵 타이머"
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr "이전 옵 타이머"
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -13,7 +13,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Андрей Зубков <and.vareba81@gmail.com>, 2020\n" "Last-Translator: Андрей Зубков <and.vareba81@gmail.com>, 2020\n"
"Language-Team: Russian (https://www.transifex.com/alliance-auth/teams/107430/ru/)\n" "Language-Team: Russian (https://www.transifex.com/alliance-auth/teams/107430/ru/)\n"
@@ -39,12 +39,12 @@ msgstr "Необходимо указать основного персонаж
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "Статус изменен: %s" msgstr "Статус изменен: %s"
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "Статус пилота: %(state)s" msgstr "Статус пилота: %(state)s"
@@ -66,29 +66,29 @@ msgstr ""
" Основной персонаж (статус: %(state)s)\n" " Основной персонаж (статус: %(state)s)\n"
" " " "
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "Основной персонаж не установлен." msgstr "Основной персонаж не установлен."
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "Добавить Персонажа" msgstr "Добавить Персонажа"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "Сменить основного персонажа" msgstr "Сменить основного персонажа"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "Роли" msgstr "Роли"
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "Персонажи" msgstr "Персонажи"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -97,13 +97,13 @@ msgstr "Персонажи"
msgid "Name" msgid "Name"
msgstr "Имя" msgstr "Имя"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "Корпорация" msgstr "Корпорация"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -391,7 +391,7 @@ msgstr "Пользователь"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -474,7 +474,6 @@ msgstr "Флот"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -483,8 +482,8 @@ msgstr "Создатель"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "Продолжительность" msgstr "Продолжительность"
@@ -569,11 +568,105 @@ msgstr "Флотовое участие зарегистрированно."
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "ФлАк ссылка устарела" msgstr "ФлАк ссылка устарела"
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "Управление Группой" msgstr "Управление Группой"
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -601,7 +694,7 @@ msgstr "Тип"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -667,6 +760,7 @@ msgstr "Группы"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "Описание" msgstr "Описание"
@@ -863,24 +957,24 @@ msgstr "Вы уже участник этой группы."
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "Вы уже подали заявку на вступление этой группы." msgstr "Вы уже подали заявку на вступление этой группы."
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "Вступить в группу %(group)s." msgstr "Вступить в группу %(group)s."
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "Вы не можете покинуть эту группу" msgstr "Вы не можете покинуть эту группу"
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "Вы не участник группыы" msgstr "Вы не участник группыы"
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "Ваш запрос находится на рассмотрении" msgstr "Ваш запрос находится на рассмотрении"
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "Запрос на выход из группы %(group)s." msgstr "Запрос на выход из группы %(group)s."
@@ -1141,43 +1235,56 @@ msgstr "Удалить все прочитанные уведомления"
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "Флотовые операции" msgstr "Флотовые операции"
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "Доктрина" msgstr "Доктрина"
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "Начало" msgstr "Начало"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "Название операции" msgstr "Название операции"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "ФлитКом" msgstr "ФлитКом"
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Дополнительная информация"
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "Создать операцию" msgstr "Создать операцию"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "Система сбора" msgstr "Система сбора"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "Локальное время" msgstr "Локальное время"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "ФК" msgstr "ФК"
@@ -1195,9 +1302,8 @@ msgid "Current Eve Time:"
msgstr "Текущий EVE Time:" msgstr "Текущий EVE Time:"
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers" msgstr ""
msgstr "Следующие таймера"
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
#: allianceauth/timerboard/templates/timerboard/view.html:363 #: allianceauth/timerboard/templates/timerboard/view.html:363
@@ -1205,9 +1311,8 @@ msgid "No upcoming timers."
msgstr "Нет предстоящих таймеров" msgstr "Нет предстоящих таймеров"
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers" msgstr ""
msgstr "Прошлые таймера"
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:536 #: allianceauth/timerboard/templates/timerboard/view.html:536
@@ -1224,17 +1329,17 @@ msgstr "Обновить Флотовые операции"
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "Флотовая операция не существует" msgstr "Флотовая операция не существует"
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "Таймер для %(opname)s назначен." msgstr "Таймер для %(opname)s назначен."
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "Таймер для %(opname)s удалено. " msgstr "Таймер для %(opname)s удалено. "
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "Таймер для %(opname)sобновлен." msgstr "Таймер для %(opname)sобновлен."
@@ -1398,11 +1503,11 @@ msgstr "Пароль"
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "Пароль должен быть не менее 8 символов." msgstr "Пароль должен быть не менее 8 символов."
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "Discord персонаж отключен" msgstr "Discord персонаж отключен"
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1735,10 +1840,6 @@ msgstr "Флотовое время"
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "Флотовая Доктрина" msgstr "Флотовая Доктрина"
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "Дополнительная информация"
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1950,12 +2051,12 @@ msgstr ""
"Ваш SRP запрос Killmail неправильный. Пожалуйста убедитесь в правильности " "Ваш SRP запрос Killmail неправильный. Пожалуйста убедитесь в правильности "
"ссылки. " "ссылки. "
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "Запрос SRP на Ваш %(ship)s утвержден." msgstr "Запрос SRP на Ваш %(ship)s утвержден."
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
@@ -1964,40 +2065,40 @@ msgstr ""
"Персонаж %(charid)s больше не имеет авторизации с Вашим аккаунтом. " "Персонаж %(charid)s больше не имеет авторизации с Вашим аккаунтом. "
"Пожалуйста перепроверьте ключ доступа." "Пожалуйста перепроверьте ключ доступа."
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "Нет SRP выбранных запросов" msgstr "Нет SRP выбранных запросов"
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "Не могу найти выбранный SRP запрос." msgstr "Не могу найти выбранный SRP запрос."
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "Удален %(numrequests)sиз SRP запросов." msgstr "Удален %(numrequests)sиз SRP запросов."
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "Утвержден %(numrequests)s SRP запрос." msgstr "Утвержден %(numrequests)s SRP запрос."
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "Невозможно найти выбранный SRP запрос" msgstr "Невозможно найти выбранный SRP запрос"
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "SRP запрос %(numrequests)s отказано." msgstr "SRP запрос %(numrequests)s отказано."
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "Невозможно найти SRP запрос с ID %(requestid)s." msgstr "Невозможно найти SRP запрос с ID %(requestid)s."
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "Сохранены изменения в SRP флот %(fleetname)s" msgstr "Сохранены изменения в SRP флот %(fleetname)s"
@@ -2226,6 +2327,14 @@ msgstr "Корпоративные таймера"
msgid "Structure" msgid "Structure"
msgstr "Структура" msgstr "Структура"
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr "Следующие таймера"
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr "Прошлые таймера"
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -13,7 +13,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-26 18:36+1000\n" "POT-Creation-Date: 2021-11-29 01:03+1000\n"
"PO-Revision-Date: 2020-02-18 03:14+0000\n" "PO-Revision-Date: 2020-02-18 03:14+0000\n"
"Last-Translator: Aaron BuBu <351793078@qq.com>, 2020\n" "Last-Translator: Aaron BuBu <351793078@qq.com>, 2020\n"
"Language-Team: Chinese Simplified (https://www.transifex.com/alliance-auth/teams/107430/zh-Hans/)\n" "Language-Team: Chinese Simplified (https://www.transifex.com/alliance-auth/teams/107430/zh-Hans/)\n"
@@ -39,12 +39,12 @@ msgstr "只有主要角色才能执行这个操作。在下面添加一个"
msgid "Email" msgid "Email"
msgstr "电子邮箱" msgstr "电子邮箱"
#: allianceauth/authentication/models.py:74 #: allianceauth/authentication/models.py:79
#, python-format #, python-format
msgid "State changed to: %s" msgid "State changed to: %s"
msgstr "" msgstr ""
#: allianceauth/authentication/models.py:75 #: allianceauth/authentication/models.py:80
#, python-format #, python-format
msgid "Your user's state is now: %(state)s" msgid "Your user's state is now: %(state)s"
msgstr "" msgstr ""
@@ -63,29 +63,29 @@ msgid ""
" " " "
msgstr "" msgstr ""
#: allianceauth/authentication/templates/authentication/dashboard.html:81 #: allianceauth/authentication/templates/authentication/dashboard.html:102
msgid "No main character set." msgid "No main character set."
msgstr "没有主要角色组" msgstr "没有主要角色组"
#: allianceauth/authentication/templates/authentication/dashboard.html:88 #: allianceauth/authentication/templates/authentication/dashboard.html:109
msgid "Add Character" msgid "Add Character"
msgstr "添加角色" msgstr "添加角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:92 #: allianceauth/authentication/templates/authentication/dashboard.html:113
msgid "Change Main" msgid "Change Main"
msgstr "修改主要角色" msgstr "修改主要角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:101 #: allianceauth/authentication/templates/authentication/dashboard.html:122
msgid "Group Memberships" msgid "Group Memberships"
msgstr "用户组成员" msgstr "用户组成员"
#: allianceauth/authentication/templates/authentication/dashboard.html:121 #: allianceauth/authentication/templates/authentication/dashboard.html:142
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticscorpview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:41 #: allianceauth/hrapplications/templates/hrapplications/view.html:41
msgid "Characters" msgid "Characters"
msgstr "角色" msgstr "角色"
#: allianceauth/authentication/templates/authentication/dashboard.html:129 #: allianceauth/authentication/templates/authentication/dashboard.html:150
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:73
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:24
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:15
@@ -94,13 +94,13 @@ msgstr "角色"
msgid "Name" msgid "Name"
msgstr "角色名" msgstr "角色名"
#: allianceauth/authentication/templates/authentication/dashboard.html:130 #: allianceauth/authentication/templates/authentication/dashboard.html:151
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkstatisticsview.html:23
#: allianceauth/hrapplications/templates/hrapplications/view.html:46 #: allianceauth/hrapplications/templates/hrapplications/view.html:46
msgid "Corp" msgid "Corp"
msgstr "所在公司" msgstr "所在公司"
#: allianceauth/authentication/templates/authentication/dashboard.html:131 #: allianceauth/authentication/templates/authentication/dashboard.html:152
#: allianceauth/corputils/templates/corputils/corpstats.html:76 #: allianceauth/corputils/templates/corputils/corpstats.html:76
#: allianceauth/hrapplications/templates/hrapplications/view.html:47 #: allianceauth/hrapplications/templates/hrapplications/view.html:47
msgid "Alliance" msgid "Alliance"
@@ -385,7 +385,7 @@ msgstr "用户"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkmodify.html:25
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:29
#: allianceauth/optimer/form.py:7 allianceauth/timerboard/form.py:59 #: allianceauth/optimer/form.py:13 allianceauth/timerboard/form.py:59
#: allianceauth/timerboard/templates/timerboard/view.html:34 #: allianceauth/timerboard/templates/timerboard/view.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:201 #: allianceauth/timerboard/templates/timerboard/view.html:201
#: allianceauth/timerboard/templates/timerboard/view.html:374 #: allianceauth/timerboard/templates/timerboard/view.html:374
@@ -462,7 +462,6 @@ msgstr "舰队"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:49
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:74
#: allianceauth/optimer/templates/optimer/fleetoptable.html:17
#: allianceauth/timerboard/templates/timerboard/view.html:38 #: allianceauth/timerboard/templates/timerboard/view.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:205 #: allianceauth/timerboard/templates/timerboard/view.html:205
#: allianceauth/timerboard/templates/timerboard/view.html:378 #: allianceauth/timerboard/templates/timerboard/view.html:378
@@ -471,8 +470,8 @@ msgstr "创建者"
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkpersonalmonthlystatisticsview.html:51
#: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77 #: allianceauth/fleetactivitytracking/templates/fleetactivitytracking/fatlinkview.html:77
#: allianceauth/optimer/form.py:9 #: allianceauth/optimer/form.py:18
#: allianceauth/optimer/templates/optimer/fleetoptable.html:14 #: allianceauth/optimer/templates/optimer/fleetoptable.html:15
msgid "Duration" msgid "Duration"
msgstr "持续时间" msgstr "持续时间"
@@ -557,11 +556,105 @@ msgstr "成功注册舰队PAP"
msgid "FAT link has expired." msgid "FAT link has expired."
msgstr "PAP链接已过期" msgstr "PAP链接已过期"
#: allianceauth/groupmanagement/admin.py:104
msgid "This name has been reserved and can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/admin.py:230
msgid "(auto)"
msgstr ""
#: allianceauth/groupmanagement/admin.py:239
msgid "There already exists a group with that name."
msgstr ""
#: allianceauth/groupmanagement/auth_hooks.py:17 #: allianceauth/groupmanagement/auth_hooks.py:17
#: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/menu.html:14
msgid "Group Management" msgid "Group Management"
msgstr "用户组管理" msgstr "用户组管理"
#: allianceauth/groupmanagement/models.py:102
msgid ""
"Internal group, users cannot see, join or request to join this "
"group.<br>Used for groups such as Members, Corp_*, Alliance_* "
"etc.<br><b>Overrides Hidden and Open options when selected.</b>"
msgstr ""
#: allianceauth/groupmanagement/models.py:110
msgid "Group is hidden from users but can still join with the correct link."
msgstr ""
#: allianceauth/groupmanagement/models.py:116
msgid ""
"Group is open and users will be automatically added upon request.<br>If the "
"group is not open users will need their request manually approved."
msgstr ""
#: allianceauth/groupmanagement/models.py:123
msgid ""
"Group is public. Any registered user is able to join this group, with "
"visibility based on the other options set for this group.<br>Auth will not "
"remove users from this group automatically when they are no longer "
"authenticated."
msgstr ""
#: allianceauth/groupmanagement/models.py:134
msgid ""
"Group leaders can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:144
msgid ""
"Members of leader groups can process requests for this group. Use the "
"<code>auth.group_management</code> permission to allow a user to manage all "
"groups.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:153
msgid ""
"States listed here will have the ability to join this group provided they "
"have the proper permissions.<br>"
msgstr ""
#: allianceauth/groupmanagement/models.py:161
msgid ""
"Short description <i>(max. 512 characters)</i> of the group shown to users."
msgstr ""
#: allianceauth/groupmanagement/models.py:168
msgid "Can request non-public groups"
msgstr ""
#: allianceauth/groupmanagement/models.py:189
msgid "name"
msgstr ""
#: allianceauth/groupmanagement/models.py:192
msgid "Name that can not be used for groups."
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "reason"
msgstr ""
#: allianceauth/groupmanagement/models.py:195
msgid "Reason why this name is reserved."
msgstr ""
#: allianceauth/groupmanagement/models.py:198
msgid "created by"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "created at"
msgstr ""
#: allianceauth/groupmanagement/models.py:203
msgid "Date when this entry was created"
msgstr ""
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:5
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:14
msgid "Audit Log" msgid "Audit Log"
@@ -589,7 +682,7 @@ msgstr "类型"
#: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33 #: allianceauth/groupmanagement/templates/groupmanagement/audit.html:33
#: allianceauth/notifications/templates/notifications/list.html:35 #: allianceauth/notifications/templates/notifications/list.html:35
#: allianceauth/notifications/templates/notifications/list.html:65 #: allianceauth/notifications/templates/notifications/list.html:65
#: allianceauth/optimer/templates/optimer/fleetoptable.html:18 #: allianceauth/optimer/templates/optimer/fleetoptable.html:19
#: allianceauth/services/templates/services/services.html:18 #: allianceauth/services/templates/services/services.html:18
#: allianceauth/timerboard/templates/timerboard/view.html:40 #: allianceauth/timerboard/templates/timerboard/view.html:40
#: allianceauth/timerboard/templates/timerboard/view.html:207 #: allianceauth/timerboard/templates/timerboard/view.html:207
@@ -655,6 +748,7 @@ msgstr "群组"
#: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25 #: allianceauth/groupmanagement/templates/groupmanagement/groupmembership.html:25
#: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16 #: allianceauth/groupmanagement/templates/groupmanagement/groups.html:16
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10
msgid "Description" msgid "Description"
msgstr "描述" msgstr "描述"
@@ -847,24 +941,24 @@ msgstr "你已经是那个群组的一员了。"
msgid "You already have a pending application for that group." msgid "You already have a pending application for that group."
msgstr "你已经有了该组的未决申请" msgstr "你已经有了该组的未决申请"
#: allianceauth/groupmanagement/views.py:362 #: allianceauth/groupmanagement/views.py:363
#, python-format #, python-format
msgid "Applied to group %(group)s." msgid "Applied to group %(group)s."
msgstr "修改已经应用到%(group)s啦" msgstr "修改已经应用到%(group)s啦"
#: allianceauth/groupmanagement/views.py:372 #: allianceauth/groupmanagement/views.py:373
msgid "You cannot leave that group" msgid "You cannot leave that group"
msgstr "你无法离开那个用户组" msgstr "你无法离开那个用户组"
#: allianceauth/groupmanagement/views.py:376 #: allianceauth/groupmanagement/views.py:377
msgid "You are not a member of that group" msgid "You are not a member of that group"
msgstr "你不是那个用户组的成员" msgstr "你不是那个用户组的成员"
#: allianceauth/groupmanagement/views.py:388 #: allianceauth/groupmanagement/views.py:389
msgid "You already have a pending leave request for that group." msgid "You already have a pending leave request for that group."
msgstr "你已经有了该组的未决离开请求" msgstr "你已经有了该组的未决离开请求"
#: allianceauth/groupmanagement/views.py:403 #: allianceauth/groupmanagement/views.py:405
#, python-format #, python-format
msgid "Applied to leave group %(group)s." msgid "Applied to leave group %(group)s."
msgstr "已经离开群组%(group)s" msgstr "已经离开群组%(group)s"
@@ -1125,43 +1219,56 @@ msgstr "删除所有已读通知"
msgid "Fleet Operations" msgid "Fleet Operations"
msgstr "起队搞事" msgstr "起队搞事"
#: allianceauth/optimer/form.py:6 #: allianceauth/optimer/form.py:12
#: allianceauth/optimer/templates/optimer/fleetoptable.html:10 #: allianceauth/optimer/templates/optimer/fleetoptable.html:11
msgid "Doctrine" msgid "Doctrine"
msgstr "船型" msgstr "船型"
#: allianceauth/optimer/form.py:8 #: allianceauth/optimer/form.py:14
#: allianceauth/optimer/templates/optimer/fleetoptable.html:12 #: allianceauth/optimer/templates/optimer/fleetoptable.html:13
msgid "Start Time" msgid "Start Time"
msgstr "集合时间" msgstr "集合时间"
#: allianceauth/optimer/form.py:10 #: allianceauth/optimer/form.py:15
#: allianceauth/optimer/templates/optimer/fleetoptable.html:9 #: allianceauth/optimer/templates/optimer/fleetoptable.html:9
msgid "Operation Name" msgid "Operation Name"
msgstr "搞事名目" msgstr "搞事名目"
#: allianceauth/optimer/form.py:11 #: allianceauth/optimer/form.py:16
msgid "Operation Type"
msgstr ""
#: allianceauth/optimer/form.py:17
#: allianceauth/srp/templates/srp/management.html:40 #: allianceauth/srp/templates/srp/management.html:40
msgid "Fleet Commander" msgid "Fleet Commander"
msgstr "FC" msgstr "FC"
#: allianceauth/optimer/form.py:22 allianceauth/srp/form.py:14
#: allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "其他信息"
#: allianceauth/optimer/form.py:23
msgid "(Optional) Describe the operation with a couple of short words."
msgstr ""
#: allianceauth/optimer/templates/optimer/add.html:7 #: allianceauth/optimer/templates/optimer/add.html:7
#: allianceauth/optimer/templates/optimer/management.html:14 #: allianceauth/optimer/templates/optimer/management.html:14
msgid "Create Operation" msgid "Create Operation"
msgstr "起一个队" msgstr "起一个队"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:11 #: allianceauth/optimer/templates/optimer/fleetoptable.html:12
msgid "Form Up System" msgid "Form Up System"
msgstr "集结点" msgstr "集结点"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:13 #: allianceauth/optimer/templates/optimer/fleetoptable.html:14
#: allianceauth/timerboard/templates/timerboard/view.html:37 #: allianceauth/timerboard/templates/timerboard/view.html:37
#: allianceauth/timerboard/templates/timerboard/view.html:204 #: allianceauth/timerboard/templates/timerboard/view.html:204
#: allianceauth/timerboard/templates/timerboard/view.html:377 #: allianceauth/timerboard/templates/timerboard/view.html:377
msgid "Local Time" msgid "Local Time"
msgstr "本地时间" msgstr "本地时间"
#: allianceauth/optimer/templates/optimer/fleetoptable.html:15 #: allianceauth/optimer/templates/optimer/fleetoptable.html:16
msgid "FC" msgid "FC"
msgstr "FC" msgstr "FC"
@@ -1179,9 +1286,8 @@ msgid "Current Eve Time:"
msgstr "当前EVE游戏内时间" msgstr "当前EVE游戏内时间"
#: allianceauth/optimer/templates/optimer/management.html:27 #: allianceauth/optimer/templates/optimer/management.html:27
#: allianceauth/timerboard/templates/timerboard/view.html:194 msgid "Next Fleet Operations"
msgid "Next Timers" msgstr ""
msgstr "接下来的时间节点"
#: allianceauth/optimer/templates/optimer/management.html:31 #: allianceauth/optimer/templates/optimer/management.html:31
#: allianceauth/timerboard/templates/timerboard/view.html:363 #: allianceauth/timerboard/templates/timerboard/view.html:363
@@ -1189,9 +1295,8 @@ msgid "No upcoming timers."
msgstr "没有快到的时间节点,歇一会吧" msgstr "没有快到的时间节点,歇一会吧"
#: allianceauth/optimer/templates/optimer/management.html:34 #: allianceauth/optimer/templates/optimer/management.html:34
#: allianceauth/timerboard/templates/timerboard/view.html:367 msgid "Past Fleet Operations"
msgid "Past Timers" msgstr ""
msgstr "已经过去的时间节点"
#: allianceauth/optimer/templates/optimer/management.html:38 #: allianceauth/optimer/templates/optimer/management.html:38
#: allianceauth/timerboard/templates/timerboard/view.html:536 #: allianceauth/timerboard/templates/timerboard/view.html:536
@@ -1208,17 +1313,17 @@ msgstr "更新搞事队"
msgid "Fleet Operation Does Not Exist" msgid "Fleet Operation Does Not Exist"
msgstr "这搞事队不存在啊,你会不会搞事啊" msgstr "这搞事队不存在啊,你会不会搞事啊"
#: allianceauth/optimer/views.py:55 #: allianceauth/optimer/views.py:69
#, python-format #, python-format
msgid "Created operation timer for %(opname)s." msgid "Created operation timer for %(opname)s."
msgstr "为%(opname)s创建了搞事时间节点冲鸭" msgstr "为%(opname)s创建了搞事时间节点冲鸭"
#: allianceauth/optimer/views.py:73 #: allianceauth/optimer/views.py:87
#, python-format #, python-format
msgid "Removed operation timer for %(opname)s." msgid "Removed operation timer for %(opname)s."
msgstr "移除了%(opname)s的搞事时间节点咕咕咕" msgstr "移除了%(opname)s的搞事时间节点咕咕咕"
#: allianceauth/optimer/views.py:96 #: allianceauth/optimer/views.py:125
#, python-format #, python-format
msgid "Saved changes to operation timer for %(opname)s." msgid "Saved changes to operation timer for %(opname)s."
msgstr "对搞事时间节点%(opname)s的修改保存了朝令夕改你是不是合格FC啊" msgstr "对搞事时间节点%(opname)s的修改保存了朝令夕改你是不是合格FC啊"
@@ -1382,11 +1487,11 @@ msgstr "密码"
msgid "Password must be at least 8 characters long." msgid "Password must be at least 8 characters long."
msgstr "密码至少要有8个字符啊你也太不注重安全啦" msgstr "密码至少要有8个字符啊你也太不注重安全啦"
#: allianceauth/services/modules/discord/models.py:225 #: allianceauth/services/modules/discord/models.py:234
msgid "Discord Account Disabled" msgid "Discord Account Disabled"
msgstr "" msgstr ""
#: allianceauth/services/modules/discord/models.py:227 #: allianceauth/services/modules/discord/models.py:236
msgid "" msgid ""
"Your Discord account was disabled automatically by Auth. If you think this " "Your Discord account was disabled automatically by Auth. If you think this "
"was a mistake, please contact an admin." "was a mistake, please contact an admin."
@@ -1711,10 +1816,6 @@ msgstr "集结时间"
msgid "Fleet Doctrine" msgid "Fleet Doctrine"
msgstr "舰队船型" msgstr "舰队船型"
#: allianceauth/srp/form.py:14 allianceauth/srp/templates/srp/data.html:93
msgid "Additional Info"
msgstr "其他信息"
#: allianceauth/srp/form.py:16 #: allianceauth/srp/form.py:16
msgid "Killboard Link (zkillboard.com or kb.evetools.org)" msgid "Killboard Link (zkillboard.com or kb.evetools.org)"
msgstr "" msgstr ""
@@ -1924,52 +2025,52 @@ msgid ""
"zKillboard." "zKillboard."
msgstr "小老弟你这个补损用的KB链接不对劲儿啊你是不是没用zKillboard啊" msgstr "小老弟你这个补损用的KB链接不对劲儿啊你是不是没用zKillboard啊"
#: allianceauth/srp/views.py:211 #: allianceauth/srp/views.py:212
#, python-format #, python-format
msgid "Submitted SRP request for your %(ship)s." msgid "Submitted SRP request for your %(ship)s."
msgstr "你的%(ship)s的补损申请好啦" msgstr "你的%(ship)s的补损申请好啦"
#: allianceauth/srp/views.py:215 #: allianceauth/srp/views.py:216
#, python-format #, python-format
msgid "" msgid ""
"Character %(charid)s does not belong to your Auth account. Please add the " "Character %(charid)s does not belong to your Auth account. Please add the "
"API key for this character and try again" "API key for this character and try again"
msgstr "%(charid)s这个角色好像不在你的账号里啊你交没交ESI啊交过再试一次吧" msgstr "%(charid)s这个角色好像不在你的账号里啊你交没交ESI啊交过再试一次吧"
#: allianceauth/srp/views.py:235 allianceauth/srp/views.py:261 #: allianceauth/srp/views.py:236 allianceauth/srp/views.py:262
#: allianceauth/srp/views.py:299 #: allianceauth/srp/views.py:300
msgid "No SRP requests selected" msgid "No SRP requests selected"
msgstr "你没选中任何补损请求哦" msgstr "你没选中任何补损请求哦"
#: allianceauth/srp/views.py:246 allianceauth/srp/views.py:284 #: allianceauth/srp/views.py:247 allianceauth/srp/views.py:285
msgid "Unable to locate selected SRP request." msgid "Unable to locate selected SRP request."
msgstr "你选的这条补损请求找不到呀" msgstr "你选的这条补损请求找不到呀"
#: allianceauth/srp/views.py:249 #: allianceauth/srp/views.py:250
#, python-format #, python-format
msgid "Deleted %(numrequests)s SRP requests" msgid "Deleted %(numrequests)s SRP requests"
msgstr "删了%(numrequests)s条补损请求你是不是准备赖账啊" msgstr "删了%(numrequests)s条补损请求你是不是准备赖账啊"
#: allianceauth/srp/views.py:287 #: allianceauth/srp/views.py:288
#, python-format #, python-format
msgid "Approved %(numrequests)s SRP requests" msgid "Approved %(numrequests)s SRP requests"
msgstr "通过了%(numrequests)s条补损请求钱包大出血" msgstr "通过了%(numrequests)s条补损请求钱包大出血"
#: allianceauth/srp/views.py:319 #: allianceauth/srp/views.py:320
msgid "Unable to locate selected SRP request" msgid "Unable to locate selected SRP request"
msgstr "你选的这条补损请求找不到呀" msgstr "你选的这条补损请求找不到呀"
#: allianceauth/srp/views.py:322 #: allianceauth/srp/views.py:323
#, python-format #, python-format
msgid "Rejected %(numrequests)s SRP requests." msgid "Rejected %(numrequests)s SRP requests."
msgstr "已拒绝%(numrequests)s个补损申请小老弟你这是想赖账" msgstr "已拒绝%(numrequests)s个补损申请小老弟你这是想赖账"
#: allianceauth/srp/views.py:335 #: allianceauth/srp/views.py:336
#, python-format #, python-format
msgid "Unable to locate SRP request with ID %(requestid)s" msgid "Unable to locate SRP request with ID %(requestid)s"
msgstr "找不到ID是%(requestid)s的补损申请呀老哥眼花了" msgstr "找不到ID是%(requestid)s的补损申请呀老哥眼花了"
#: allianceauth/srp/views.py:359 #: allianceauth/srp/views.py:360
#, python-format #, python-format
msgid "Saved changes to SRP fleet %(fleetname)s" msgid "Saved changes to SRP fleet %(fleetname)s"
msgstr "你做的修改已经保存到%(fleetname)s这个补损舰队啦尽情白给吧" msgstr "你做的修改已经保存到%(fleetname)s这个补损舰队啦尽情白给吧"
@@ -2195,6 +2296,14 @@ msgstr "公司时间表"
msgid "Structure" msgid "Structure"
msgstr "建筑" msgstr "建筑"
#: allianceauth/timerboard/templates/timerboard/view.html:194
msgid "Next Timers"
msgstr "接下来的时间节点"
#: allianceauth/timerboard/templates/timerboard/view.html:367
msgid "Past Timers"
msgstr "已经过去的时间节点"
#: allianceauth/timerboard/views.py:74 #: allianceauth/timerboard/views.py:74
#, python-format #, python-format
msgid "Added new timer in %(system)s at %(time)s." msgid "Added new timer in %(system)s at %(time)s."

View File

@@ -1,9 +1,3 @@
from .core import notify # noqa: F401
default_app_config = 'allianceauth.notifications.apps.NotificationsConfig' default_app_config = 'allianceauth.notifications.apps.NotificationsConfig'
def notify(
user: object, title: str, message: str = None, level: str = 'info'
) -> None:
"""Sends a new notification to user. Convenience function to manager pendant."""
from .models import Notification
Notification.objects.notify_user(user, title, message, level)

View File

@@ -0,0 +1,33 @@
class NotifyApiWrapper:
"""Wrapper to create notify API."""
def __call__(self, *args, **kwargs): # provide old API for backwards compatibility
return self._add_notification(*args, **kwargs)
def danger(self, user: object, title: str, message: str = None) -> None:
"""Add danger notification for user."""
self._add_notification(user, title, message, level="danger")
def info(self, user: object, title: str, message: str = None) -> None:
"""Add info notification for user."""
self._add_notification(user=user, title=title, message=message, level="info")
def success(self, user: object, title: str, message: str = None) -> None:
"""Add success notification for user."""
self._add_notification(user, title, message, level="success")
def warning(self, user: object, title: str, message: str = None) -> None:
"""Add warning notification for user."""
self._add_notification(user, title, message, level="warning")
def _add_notification(
self, user: object, title: str, message: str = None, level: str = "info"
) -> None:
from .models import Notification
Notification.objects.notify_user(
user=user, title=title, message=message, level=level
)
notify = NotifyApiWrapper()

View File

@@ -49,19 +49,22 @@ class NotificationManager(models.Manager):
logger.info("Created notification %s", obj) logger.info("Created notification %s", obj)
return obj return obj
def _max_notifications_per_user(self): def _max_notifications_per_user(self) -> int:
"""return the maximum number of notifications allowed per user""" """Maximum number of notifications allowed per user."""
max_notifications = getattr(settings, 'NOTIFICATIONS_MAX_PER_USER', None) max_notifications = getattr(
if ( settings,
max_notifications is None "NOTIFICATIONS_MAX_PER_USER",
or not isinstance(max_notifications, int) self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT
or max_notifications < 0 )
): try:
max_notifications = int(max_notifications)
except ValueError:
max_notifications = None
if max_notifications is None or max_notifications < 0:
logger.warning( logger.warning(
'NOTIFICATIONS_MAX_PER_USER setting is invalid. Using default.' "NOTIFICATIONS_MAX_PER_USER setting is invalid. Using default."
) )
max_notifications = self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT max_notifications = self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT
return max_notifications return max_notifications
def user_unread_count(self, user_pk: int) -> int: def user_unread_count(self, user_pk: int) -> int:

View File

@@ -5,91 +5,34 @@
{% block page_title %}{% translate "Notifications" %}{% endblock %} {% block page_title %}{% translate "Notifications" %}{% endblock %}
{% block content %} {% block content %}
<div class="col-lg-12"> <h1 class="page-header text-center">{% translate "Notifications" %}</h1>
<h1 class="page-header text-center">{% translate "Notifications" %}</h1>
<div class="col-lg-12 container" id="example"> <div class="panel panel-default">
<div class="row">
<div class="col-lg-12"> <div class="panel-heading">
<div class="panel panel-default"> <ul class="nav nav-pills">
<div class="panel-heading"> <li class="active"><a data-toggle="tab" href="#unread">{% translate "Unread" %}<b>({{ unread|length }})</b></a></li>
<ul class="nav nav-pills"> <li><a data-toggle="tab" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a></li>
<li class="active"><a data-toggle="pill" href="#unread">{% translate "Unread" %} <div class="pull-right">
<b>({{ unread|length }})</b></a></li> <a href="{% url 'notifications:mark_all_read' %}" class="btn btn-warning">{% translate "Mark All Read" %}</a>
<li><a data-toggle="pill" href="#read">{% translate "Read" %} <b>({{ read|length }})</b></a> <a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</li>
<div class="pull-right">
<a href="{% url 'notifications:mark_all_read' %}" class="btn btn-primary">{% translate "Mark All Read" %}</a>
<a href="{% url 'notifications:delete_all_read' %}" class="btn btn-danger">{% translate "Delete All Read" %}</a>
</div>
</ul>
</div>
<div class="panel-body">
<div class="tab-content">
<div id="unread" class="tab-pane fade in active">
<div class="table-responsive">
{% if unread %}
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in unread %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="alert alert-warning text-center">{% translate "No unread notifications." %}</div>
{% endif %}
</div>
</div>
<div id="read" class="tab-pane fade">
<div class="panel-body">
<div class="table-responsive">
{% if read %}
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in read %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-success" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="alert alert-warning text-center">{% translate "No read notifications." %}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</ul>
</div>
<div class="panel-body">
<div class="tab-content">
<div id="unread" class="tab-pane fade in active">
{% include "notifications/list_partial.html" with notifications=unread %}
</div>
<div id="read" class="tab-pane fade">
{% include "notifications/list_partial.html" with notifications=read %}
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,29 @@
{% load i18n %}
{% if notifications %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-striped">
<tr>
<th class="text-center">{% translate "Timestamp" %}</th>
<th class="text-center">{% translate "Title" %}</th>
<th class="text-center">{% translate "Action" %}</th>
</tr>
{% for notif in notifications %}
<tr class="{{ notif.level }}">
<td class="text-center">{{ notif.timestamp }}</td>
<td class="text-center">{{ notif.title }}</td>
<td class="text-center">
<a href="{% url 'notifications:view' notif.id %}" class="btn btn-primary" title="View">
<span class="glyphicon glyphicon-eye-open"></span>
</a>
<a href="{% url 'notifications:remove' notif.id %}" class="btn btn-danger" title="Remove">
<span class="glyphicon glyphicon-remove"></span>
</a>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div class="alert alert-default text-center">{% translate "No notifications." %}</div>
{% endif %}

View File

@@ -5,25 +5,22 @@
{% block page_title %}{% translate "View Notification" %}{% endblock page_title %} {% block page_title %}{% translate "View Notification" %}{% endblock page_title %}
{% block content %} {% block content %}
<h1 class="page-header text-center">
{% translate "View Notification" %}
<div class="text-right">
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg">
<span class="glyphicon glyphicon-arrow-left"></span>
</a>
</div>
</h1>
<div class="col-lg-12"> <div class="row">
<h1 class="page-header text-center"> <div class="col-lg-12">
{% translate "View Notification" %} <div class="panel panel-{{ notif.level }}">
<div class="text-right"> <div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
<a href="{% url 'notifications:list' %}" class="btn btn-primary btn-lg"> <div class="panel-body"><pre>{{ notif.message }}</pre></div>
<span class="glyphicon glyphicon-arrow-left"></span>
</a>
</div>
</h1>
<div class="col-lg-12 container">
<div class="row">
<div class="col-lg-12">
<div class="panel panel-{{ notif.level }}">
<div class="panel-heading">{{ notif.timestamp }} {{ notif.title }}</div>
<div class="panel-body"><pre>{{ notif.message }}</pre></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,85 @@
from django.test import TestCase
from allianceauth.tests.auth_utils import AuthUtils
from ..core import NotifyApiWrapper
from ..models import Notification
class TestUserNotificationCount(TestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.user = AuthUtils.create_user("bruce_wayne")
def test_should_add_danger_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.danger(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.DANGER)
def test_should_add_info_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.info(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.INFO)
def test_should_add_success_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.success(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.SUCCESS)
def test_should_add_warning_notification(self):
# given
notify = NotifyApiWrapper()
# when
notify.warning(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.WARNING)
def test_should_add_info_notification_via_callable(self):
# given
notify = NotifyApiWrapper()
# when
notify(user=self.user, title="title", message="message")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.INFO)
def test_should_add_danger_notification_via_callable(self):
# given
notify = NotifyApiWrapper()
# when
notify(user=self.user, title="title", message="message", level="danger")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, Notification.Level.DANGER)

View File

@@ -4,11 +4,8 @@ from allianceauth.tests.auth_utils import AuthUtils
from .. import notify from .. import notify
from ..models import Notification from ..models import Notification
MODULE_PATH = 'allianceauth.notifications'
class TestUserNotificationCount(TestCase): class TestUserNotificationCount(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.user = AuthUtils.create_user('magic_mike') cls.user = AuthUtils.create_user('magic_mike')
@@ -23,6 +20,18 @@ class TestUserNotificationCount(TestCase):
alliance_name='RIDERS' alliance_name='RIDERS'
) )
def test_can_notify(self): def test_can_notify_short(self):
notify(self.user, 'dummy') # when
notify(self.user, "dummy")
# then
self.assertEqual(Notification.objects.filter(user=self.user).count(), 1) self.assertEqual(Notification.objects.filter(user=self.user).count(), 1)
def test_can_notify_full(self):
# when
notify(user=self.user, title="title", message="message", level="danger")
# then
obj = Notification.objects.first()
self.assertEqual(obj.user, self.user)
self.assertEqual(obj.title, "title")
self.assertEqual(obj.message, "message")
self.assertEqual(obj.level, "danger")

View File

@@ -1,5 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@@ -113,29 +114,53 @@ class TestUserNotify(TestCase):
self.assertSetEqual(result, expected) self.assertSetEqual(result, expected)
@patch("allianceauth.notifications.managers.logger")
@patch( @patch(
MODULE_PATH + '.Notification.NOTIFICATIONS_MAX_PER_USER_DEFAULT', MODULE_PATH + ".Notification.NOTIFICATIONS_MAX_PER_USER_DEFAULT",
NOTIFICATIONS_MAX_PER_USER_DEFAULT NOTIFICATIONS_MAX_PER_USER_DEFAULT
) )
class TestMaxNotificationsPerUser(TestCase): class TestMaxNotificationsPerUser(TestCase):
@override_settings(NOTIFICATIONS_MAX_PER_USER=42)
@override_settings(NOTIFICATIONS_MAX_PER_USER=None) def test_should_use_custom_integer_setting(self, mock_logger):
def test_reset_to_default_if_not_defined(self): # when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, 42)
self.assertFalse(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER='11') @override_settings(NOTIFICATIONS_MAX_PER_USER="42")
def test_reset_to_default_if_not_int(self): def test_should_use_custom_string_setting(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, 42)
self.assertFalse(mock_logger.warning.called)
@override_settings()
def test_should_use_default_if_not_defined(self, mock_logger):
# given
del settings.NOTIFICATIONS_MAX_PER_USER
# when
result = Notification.objects._max_notifications_per_user()
# then
self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertFalse(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER="abc")
def test_should_reset_to_default_if_not_int(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user()
# then
self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertTrue(mock_logger.warning.called)
@override_settings(NOTIFICATIONS_MAX_PER_USER=-1) @override_settings(NOTIFICATIONS_MAX_PER_USER=-1)
def test_reset_to_default_if_lt_zero(self): def test_should_reset_to_default_if_lt_zero(self, mock_logger):
# when
result = Notification.objects._max_notifications_per_user() result = Notification.objects._max_notifications_per_user()
expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT # then
self.assertEqual(result, expected) self.assertEqual(result, NOTIFICATIONS_MAX_PER_USER_DEFAULT)
self.assertTrue(mock_logger.warning.called)
@patch('allianceauth.notifications.managers.cache') @patch('allianceauth.notifications.managers.cache')

View File

@@ -1,5 +1,6 @@
from django.contrib import admin from django.contrib import admin
from allianceauth.optimer.models import OpTimer from allianceauth.optimer.models import OpTimer, OpTimerType
admin.site.register(OpTimerType)
admin.site.register(OpTimer) admin.site.register(OpTimer)

View File

@@ -1,11 +1,34 @@
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from allianceauth.optimer.form_widgets import DataListWidget
class OpForm(forms.Form): class OpForm(forms.Form):
"""
Create/Edit Fleet Operation Form
"""
doctrine = forms.CharField(max_length=254, required=True, label=_('Doctrine')) doctrine = forms.CharField(max_length=254, required=True, label=_('Doctrine'))
system = forms.CharField(max_length=254, required=True, label=_("System")) system = forms.CharField(max_length=254, required=True, label=_("System"))
start = forms.DateTimeField(required=True, label=_("Start Time")) start = forms.DateTimeField(required=True, label=_("Start Time"))
duration = forms.CharField(max_length=254, required=True, label=_("Duration"))
operation_name = forms.CharField(max_length=254, required=True, label=_("Operation Name")) operation_name = forms.CharField(max_length=254, required=True, label=_("Operation Name"))
type = forms.CharField(required=False, label=_("Operation Type"))
fc = forms.CharField(max_length=254, required=True, label=_("Fleet Commander")) fc = forms.CharField(max_length=254, required=True, label=_("Fleet Commander"))
duration = forms.CharField(max_length=254, required=True, label=_("Duration"))
description = forms.CharField(
widget=forms.Textarea(attrs={"rows": 10, "cols": 20, "input_type": "textarea"}),
required=False,
label=_("Additional Info"),
help_text=_("(Optional) Describe the operation with a couple of short words."),
)
def __init__(self, *args, **kwargs):
_data_list = kwargs.pop('data_list', None)
super().__init__(*args, **kwargs)
# Add the DataListWidget to our type field
self.fields['type'].widget = DataListWidget(
data_list=_data_list, name='data-list'
)

View File

@@ -0,0 +1,45 @@
"""
Form Widgets
"""
from django import forms
class DataListWidget(forms.TextInput):
"""
DataListWidget
Draws an HTML5 datalist form field
"""
def __init__(self, data_list, name, *args, **kwargs):
super().__init__(*args, **kwargs)
self._name = name
self._list = data_list
self.attrs.update({"list": "list__%s" % self._name})
def render(self, name, value, attrs=None, renderer=None):
"""
Render the DataList
:param name:
:type name:
:param value:
:type value:
:param attrs:
:type attrs:
:param renderer:
:type renderer:
:return:
:rtype:
"""
text_html = super().render(name, value, attrs=attrs)
data_list = '<datalist id="list__%s">' % self._name
for item in self._list:
data_list += '<option value="%s">' % item
data_list += "</datalist>"
return text_html + data_list

View File

@@ -0,0 +1,50 @@
# Generated by Django 3.2.8 on 2021-10-26 16:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("optimer", "0004_on_delete"),
]
operations = [
migrations.CreateModel(
name="OpTimerType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("type", models.CharField(default="", max_length=254)),
],
options={
"ordering": ["type"],
"default_permissions": (),
},
),
migrations.AlterModelOptions(
name="optimer",
options={"default_permissions": (), "ordering": ["start"]},
),
migrations.AddField(
model_name="optimer",
name="description",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="optimer",
name="type",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="optimer.optimertype",
),
),
]

View File

@@ -6,9 +6,25 @@ from django.utils import timezone
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter
class OpTimerType(models.Model):
"""
Optimer Type
"""
type = models.CharField(max_length=254, default="")
def __str__(self):
return self.type
class Meta:
ordering = ['type']
default_permissions = ()
class OpTimer(models.Model): class OpTimer(models.Model):
class Meta: class Meta:
ordering = ['start'] ordering = ['start']
default_permissions = ()
doctrine = models.CharField(max_length=254, default="") doctrine = models.CharField(max_length=254, default="")
system = models.CharField(max_length=254, default="") system = models.CharField(max_length=254, default="")
@@ -17,7 +33,10 @@ class OpTimer(models.Model):
operation_name = models.CharField(max_length=254, default="") operation_name = models.CharField(max_length=254, default="")
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, on_delete=models.SET_NULL) eve_character = models.ForeignKey(EveCharacter, null=True,
on_delete=models.SET_NULL)
description = models.TextField(blank=True, default="")
type = models.ForeignKey(OpTimerType, null=True, on_delete=models.SET_NULL)
def __str__(self): def __str__(self):
return self.operation_name return self.operation_name

View File

@@ -6,37 +6,48 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th class="text-center col-lg-3">{% translate "Operation Name" %}</th> <th>{% translate "Operation Name" %}</th>
<th class="text-center col lg-2">{% translate "Doctrine" %}</th> <th>{% translate "Description" %}</th>
<th class="text-center col-lg-1">{% translate "Form Up System" %}</th> <th>{% translate "Doctrine" %}</th>
<th class="text-center col-lg-1">{% translate "Start Time" %}</th> <th>{% translate "Form Up System" %}</th>
<th class="text-center col-lg-1">{% translate "Local Time" %}</th> <th>{% translate "Start Time" %}</th>
<th class="text-center col-lg-1">{% translate "Duration" %}</th> <th>{% translate "Local Time" %}</th>
<th class="text-center col-lg-1">{% translate "FC" %}</th> <th>{% translate "Duration" %}</th>
<th>{% translate "FC" %}</th>
{% if perms.auth.optimer_management %} {% if perms.auth.optimer_management %}
<th class="text-center col-lg-1">{% translate "Creator" %}</th> {# <th>{% translate "Creator" %}</th>#}
<th class="text-center col-lg-2">{% translate "Action" %}</th> <th class="text-right" style="width: 150px;">{% translate "Action" %}</th>
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
{% for ops in timers %} {% for ops in timers %}
<tbody> <tbody>
<tr> <tr>
<td class="text-center">{{ ops.operation_name }}</td> <td>
<td class="text-center">{{ ops.doctrine }}</td> {{ ops.operation_name }}
<td class="text-center"> {% if ops.type %}
<br>({{ ops.type }})
{% endif %}
</td>
<td>{{ ops.description }}</td>
<td>{{ ops.doctrine }}</td>
<td>
<a href="{{ ops.system|dotlan_solar_system_url }}">{{ ops.system }}</a> <a href="{{ ops.system|dotlan_solar_system_url }}">{{ ops.system }}</a>
</td> </td>
<td class="text-center" nowrap>{{ ops.start | date:"Y-m-d H:i" }}</td> <td nowrap>{{ ops.start | date:"Y-m-d H:i" }}</td>
<td class="text-center" nowrap><div id="localtime{{ ops.id }}"></div><div id="countdown{{ ops.id }}"></div></td> <td nowrap><div id="localtime{{ ops.id }}"></div><div id="countdown{{ ops.id }}"></div></td>
<td class="text-center">{{ ops.duration }}</td> <td>{{ ops.duration }}</td>
<td class="text-center">{{ ops.fc }}</td> <td>{{ ops.fc }}</td>
{% if perms.auth.optimer_management %} {% if perms.auth.optimer_management %}
<td class="text-center">{{ ops.eve_character }}</td> {# <td>{{ ops.eve_character }}</td>#}
<td class="text-center"> <td class="text-right">
<a href="{% url 'optimer:remove' ops.id %}" class="btn btn-danger"> <a href="{% url 'optimer:remove' ops.id %}" class="btn btn-danger">
<span class="glyphicon glyphicon-remove"></span> <span class="glyphicon glyphicon-remove"></span>
</a><a href="{% url 'optimer:edit' ops.id %}" class="btn btn-info"><span class="glyphicon glyphicon-pencil"></span></a> </a>
<a href="{% url 'optimer:edit' ops.id %}" class="btn btn-info">
<span class="glyphicon glyphicon-pencil"></span>
</a>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>

View File

@@ -24,14 +24,14 @@
<br /> <br />
</div> </div>
<h4><b>{% translate "Next Timers" %}</b></h4> <h4><b>{% translate "Next Fleet Operations" %}</b></h4>
{% if future_timers %} {% if future_timers %}
{% include "optimer/fleetoptable.html" with timers=future_timers %} {% include "optimer/fleetoptable.html" with timers=future_timers %}
{% else %} {% else %}
<div class="alert alert-warning text-center">{% translate "No upcoming timers." %}</div> <div class="alert alert-warning text-center">{% translate "No upcoming timers." %}</div>
{% endif %} {% endif %}
<h4><b>{% translate "Past Timers" %}</b></h4> <h4><b>{% translate "Past Fleet Operations" %}</b></h4>
{% if past_timers %} {% if past_timers %}
{% include "optimer/fleetoptable.html" with timers=past_timers %} {% include "optimer/fleetoptable.html" with timers=past_timers %}
{% else %} {% else %}

View File

@@ -9,7 +9,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from .form import OpForm from .form import OpForm
from .models import OpTimer from .models import OpTimer, OpTimerType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
@permission_required('auth.optimer_view') @permission_required('auth.optimer_view')
def optimer_view(request): def optimer_view(request):
logger.debug("optimer_view called by user %s" % request.user) logger.debug("optimer_view called by user %s" % request.user)
base_query = OpTimer.objects.select_related('eve_character') base_query = OpTimer.objects.select_related('eve_character', 'type')
render_items = {'optimer': base_query.all(), render_items = {'optimer': base_query.all(),
'future_timers': base_query.filter( 'future_timers': base_query.filter(
start__gte=timezone.now()), start__gte=timezone.now()),
@@ -33,9 +33,21 @@ def optimer_view(request):
def add_optimer_view(request): def add_optimer_view(request):
logger.debug("add_optimer_view called by user %s" % request.user) logger.debug("add_optimer_view called by user %s" % request.user)
if request.method == 'POST': if request.method == 'POST':
form = OpForm(request.POST) form = OpForm(request.POST, data_list=OpTimerType.objects.all())
logger.debug("Request type POST contains form valid: %s" % form.is_valid()) logger.debug("Request type POST contains form valid: %s" % form.is_valid())
if form.is_valid(): if form.is_valid():
optimer_type = None
if form.cleaned_data['type'] != '':
try:
optimer_type = OpTimerType.objects.get(
type__iexact=form.cleaned_data['type']
)
except OpTimerType.DoesNotExist:
optimer_type = OpTimerType.objects.create(
type=form.cleaned_data['type']
)
# Get Current Time # Get Current Time
post_time = timezone.now() post_time = timezone.now()
# Get character # Get character
@@ -50,13 +62,15 @@ def add_optimer_view(request):
op.fc = form.cleaned_data['fc'] op.fc = form.cleaned_data['fc']
op.create_time = post_time op.create_time = post_time
op.eve_character = character op.eve_character = character
op.type = optimer_type
op.description = form.cleaned_data['description']
op.save() op.save()
logger.info(f"User {request.user} created op timer with name {op.operation_name}") logger.info(f"User {request.user} created op timer with name {op.operation_name}")
messages.success(request, _('Created operation timer for %(opname)s.') % {"opname": op.operation_name}) messages.success(request, _('Created operation timer for %(opname)s.') % {"opname": op.operation_name})
return redirect("optimer:view") return redirect("optimer:view")
else: else:
logger.debug("Returning new opForm") logger.debug("Returning new opForm")
form = OpForm() form = OpForm(data_list=OpTimerType.objects.all())
render_items = {'form': form} render_items = {'form': form}
@@ -80,10 +94,23 @@ def edit_optimer(request, optimer_id):
logger.debug(f"edit_optimer called by user {request.user} for optimer id {optimer_id}") logger.debug(f"edit_optimer called by user {request.user} for optimer id {optimer_id}")
op = get_object_or_404(OpTimer, id=optimer_id) op = get_object_or_404(OpTimer, id=optimer_id)
if request.method == 'POST': if request.method == 'POST':
form = OpForm(request.POST) form = OpForm(request.POST, data_list=OpTimerType.objects.all())
logger.debug("Received POST request containing update optimer form, is valid: %s" % form.is_valid()) logger.debug("Received POST request containing update optimer form, is valid: %s" % form.is_valid())
if form.is_valid(): if form.is_valid():
character = request.user.profile.main_character character = request.user.profile.main_character
optimer_type = None
if form.cleaned_data['type'] != '':
try:
optimer_type = OpTimerType.objects.get(
type__iexact=form.cleaned_data['type']
)
except OpTimerType.DoesNotExist:
optimer_type = OpTimerType.objects.create(
type=form.cleaned_data['type']
)
op.doctrine = form.cleaned_data['doctrine'] op.doctrine = form.cleaned_data['doctrine']
op.system = form.cleaned_data['system'] op.system = form.cleaned_data['system']
op.start = form.cleaned_data['start'] op.start = form.cleaned_data['start']
@@ -91,6 +118,8 @@ def edit_optimer(request, optimer_id):
op.operation_name = form.cleaned_data['operation_name'] op.operation_name = form.cleaned_data['operation_name']
op.fc = form.cleaned_data['fc'] op.fc = form.cleaned_data['fc']
op.eve_character = character op.eve_character = character
op.type = optimer_type
op.description = form.cleaned_data['description']
logger.info(f"User {request.user} updating optimer id {optimer_id} ") logger.info(f"User {request.user} updating optimer id {optimer_id} ")
op.save() op.save()
messages.success(request, _('Saved changes to operation timer for %(opname)s.') % {"opname": op.operation_name}) messages.success(request, _('Saved changes to operation timer for %(opname)s.') % {"opname": op.operation_name})
@@ -103,6 +132,8 @@ def edit_optimer(request, optimer_id):
'duration': op.duration, 'duration': op.duration,
'operation_name': op.operation_name, 'operation_name': op.operation_name,
'fc': op.fc, 'fc': op.fc,
'description': op.description,
'type': op.type
} }
form = OpForm(initial=data) form = OpForm(initial=data, data_list=OpTimerType.objects.all())
return render(request, 'optimer/update.html', context={'form': form}) return render(request, 'optimer/update.html', context={'form': form})

View File

@@ -73,6 +73,8 @@
], ],
bootstrap: true bootstrap: true
}, },
"stateSave": true,
"stateDuration": 0,
drawCallback: function ( settings ) { drawCallback: function ( settings ) {
let api = this.api(); let api = this.api();
let rows = api.rows( {page:'current'} ).nodes(); let rows = api.rows( {page:'current'} ).nodes();

View File

@@ -106,8 +106,10 @@
idx: 1 idx: 1
} }
], ],
bootstrap: true bootstrap: true,
}, },
"stateSave": true,
"stateDuration": 0,
drawCallback: function ( settings ) { drawCallback: function ( settings ) {
let api = this.api(); let api = this.api();
let rows = api.rows( {page:'current'} ).nodes(); let rows = api.rows( {page:'current'} ).nodes();

View File

@@ -167,7 +167,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# Bootstrap messaging css workaround # Bootstrap messaging css workaround
MESSAGE_TAGS = { MESSAGE_TAGS = {
messages.ERROR: 'danger' messages.ERROR: 'danger error'
} }
CACHES = { CACHES = {

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
@@ -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.core.cache import caches 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,19 +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:
default_cache = caches['default'] self._redis = get_redis_client()
self._redis = default_cache.get_master_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 '
@@ -132,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.
""" """
@@ -153,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.
@@ -164,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)
@@ -277,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:
@@ -346,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(
@@ -358,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 = {
@@ -372,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')
@@ -420,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(
@@ -429,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(
@@ -452,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:
@@ -477,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
@@ -530,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):
@@ -578,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)
@@ -589,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:
@@ -611,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
@@ -655,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:
@@ -680,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
@@ -704,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,26 +1,37 @@
from copy import copy from copy import copy
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)):
@@ -39,21 +50,30 @@ class DiscordRoles:
def __len__(self): def __len__(self):
return len(self._roles.keys()) return len(self._roles.keys())
def has_roles(self, role_ids: set) -> 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: 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(self, role_ids: set = None, managed_only: bool = False) -> object: def subset(
"""returns a new object containing the subset of roles as defined self,
by given role IDs and/or including managed roles only role_ids: Iterable[int] = None,
managed_only: bool = False,
role_names: Iterable[str] = None
) -> "RolesSet":
"""Create instance containing the subset of roles
Args:
role_ids: role ids must be in the provided list
managed_only: roles must be managed
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}
@@ -65,67 +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
]) ])
else: elif role_ids is None and managed_only is False and role_names is not None:
return copy(self) role_names = {Role.sanitize_name(name).lower() for name in role_names}
return type(self)([
role for role in self._roles.values()
if role.name.lower() in role_names
])
def union(self, other: object) -> object: return copy(self)
"""returns a new roles object that is the union of this roles object
with other""" def union(self, other: object) -> "RolesSet":
"""Create instance that is the union of this roles object with other."""
return type(self)(list(self) + list(other)) return type(self)(list(self) + list(other))
def difference(self, other: object) -> object: 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]
else: return None
return dict()
@classmethod @classmethod
def create_from_matched_roles(cls, matched_roles: list) -> None: 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):
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
)
)

View File

@@ -0,0 +1,125 @@
"""Implementation of Discord objects used by this client.
Note that only those objects and properties are implemented, which are needed by AA.
Names and types are mirrored from the API whenever possible.
Discord's snowflake type (used by Discord IDs) is implemented as int.
"""
from dataclasses import asdict, dataclass
from typing import FrozenSet
@dataclass(frozen=True)
class User:
"""A user on Discord."""
id: int
username: str
discriminator: str
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "username", str(self.username))
object.__setattr__(self, "discriminator", str(self.discriminator))
@classmethod
def from_dict(cls, data: dict) -> "User":
"""Create object from dictionary as received from the API."""
return cls(
id=int(data["id"]),
username=data["username"],
discriminator=data["discriminator"],
)
@dataclass(frozen=True)
class Role:
"""A role on Discord."""
_ROLE_NAME_MAX_CHARS = 100
id: int
name: str
managed: bool = False
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "name", self.sanitize_name(self.name))
object.__setattr__(self, "managed", bool(self.managed))
def asdict(self) -> dict:
"""Convert object into a dictionary representation."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "Role":
"""Create object from dictionary as received from the API."""
return cls(id=int(data["id"]), name=data["name"], managed=data["managed"])
@classmethod
def sanitize_name(cls, role_name: str) -> str:
"""Shorten too long names if necessary."""
return str(role_name)[: cls._ROLE_NAME_MAX_CHARS]
@dataclass(frozen=True)
class Guild:
"""A guild on Discord."""
id: int
name: str
roles: FrozenSet[Role]
def __post_init__(self):
object.__setattr__(self, "id", int(self.id))
object.__setattr__(self, "name", str(self.name))
for role in self.roles:
if not isinstance(role, Role):
raise TypeError("roles can only contain Role objects.")
object.__setattr__(self, "roles", frozenset(self.roles))
@classmethod
def from_dict(cls, data: dict) -> "Guild":
"""Create object from dictionary as received from the API."""
return cls(
id=int(data["id"]),
name=data["name"],
roles=frozenset(Role.from_dict(obj) for obj in data["roles"]),
)
@dataclass(frozen=True)
class GuildMember:
"""A member of a guild on Discord."""
_NICK_MAX_CHARS = 32
roles: FrozenSet[int]
nick: str = None
user: User = None
def __post_init__(self):
if self.nick:
object.__setattr__(self, "nick", self.sanitize_nick(self.nick))
if self.user and not isinstance(self.user, User):
raise TypeError("user must be of type User")
for role in self.roles:
if not isinstance(role, int):
raise TypeError("roles can only contain ints")
object.__setattr__(self, "roles", frozenset(self.roles))
@classmethod
def from_dict(cls, data: dict) -> "GuildMember":
"""Create object from dictionary as received from the API."""
params = {"roles": {int(obj) for obj in data["roles"]}}
if data.get("user"):
params["user"] = User.from_dict(data["user"])
if data.get("nick"):
params["nick"] = data["nick"]
return cls(**params)
@classmethod
def sanitize_nick(cls, nick: str) -> str:
"""Sanitize a nick, i.e. shorten too long strings if necessary."""
return str(nick)[: cls._NICK_MAX_CHARS]

View File

@@ -1,38 +0,0 @@
TEST_GUILD_ID = 123456789012345678
TEST_USER_ID = 198765432012345678
TEST_USER_NAME = 'Peter Parker'
TEST_USER_DISCRIMINATOR = '1234'
TEST_BOT_TOKEN = 'abcdefhijlkmnopqastzvwxyz1234567890ABCDEFGHOJKLMNOPQRSTUVWXY'
TEST_ROLE_ID = 654321012345678912
def create_role(id: int, name: str, managed=False):
return {
'id': int(id),
'name': str(name),
'managed': bool(managed)
}
def create_matched_role(role, created=False) -> tuple:
return role, created
ROLE_ALPHA = create_role(1, 'alpha')
ROLE_BRAVO = create_role(2, 'bravo')
ROLE_CHARLIE = create_role(3, 'charlie')
ROLE_MIKE = create_role(13, 'mike', True)
ALL_ROLES = [ROLE_ALPHA, ROLE_BRAVO, ROLE_CHARLIE, ROLE_MIKE]
def create_user_info(
id: int = TEST_USER_ID,
username: str = TEST_USER_NAME,
discriminator: str = TEST_USER_DISCRIMINATOR
):
return {
'id': str(id),
'username': str(username[:32]),
'discriminator': str(discriminator[:4])
}

View File

@@ -0,0 +1,155 @@
{
"guilds": {
"2909267986263572999": {
"id": "2909267986263572999",
"name": "Mason's Test Server",
"icon": "389030ec9db118cb5b85a732333b7c98",
"description": null,
"splash": "75610b05a0dd09ec2c3c7df9f6975ea0",
"discovery_splash": null,
"approximate_member_count": 2,
"approximate_presence_count": 2,
"features": [
"INVITE_SPLASH",
"VANITY_URL",
"COMMERCE",
"BANNER",
"NEWS",
"VERIFIED",
"VIP_REGIONS"
],
"emojis": [
{
"name": "ultrafastparrot",
"roles": [],
"id": "393564762228785161",
"require_colons": true,
"managed": false,
"animated": true,
"available": true
}
],
"banner": "5c3cb8d1bc159937fffe7e641ec96ca7",
"owner_id": "53908232506183680",
"application_id": null,
"region": null,
"afk_channel_id": null,
"afk_timeout": 300,
"system_channel_id": null,
"widget_enabled": true,
"widget_channel_id": "639513352485470208",
"verification_level": 0,
"roles": [
{
"id": "2909267986263572999",
"name": "@everyone",
"permissions": "49794752",
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
}
],
"default_message_notifications": 1,
"mfa_level": 0,
"explicit_content_filter": 0,
"max_presences": null,
"max_members": 250000,
"max_video_channel_users": 25,
"vanity_url_code": "no",
"premium_tier": 0,
"premium_subscription_count": 0,
"system_channel_flags": 0,
"preferred_locale": "en-US",
"rules_channel_id": null,
"public_updates_channel_id": null
}
},
"guildMembers": {
"1": {
"user": {},
"nick": null,
"avatar": null,
"roles": [],
"joined_at": "2015-04-26T06:26:56.936000+00:00",
"deaf": false,
"mute": false
},
"2": {
"user": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"avatar": "8342729096ea3675442027381ff50dfe",
"verified": true,
"email": "nelly@discord.com",
"flags": 64,
"banner": "06c16474723fe537c283b8efa61a30c8",
"accent_color": 16711680,
"premium_type": 1,
"public_flags": 64
},
"nick": "Nelly the great",
"avatar": null,
"roles": [
"197150972374548480",
"41771983423143936"
],
"joined_at": "2015-04-26T06:26:56.936000+00:00",
"deaf": false,
"mute": false
}
},
"roles": {
"197150972374548480": {
"id": "197150972374548480",
"name": "My Managed Role",
"color": 3447003,
"hoist": false,
"icon": "cf3ced8600b777c9486c6d8d84fb4327",
"unicode_emoji": null,
"position": 2,
"permissions": "66321471",
"managed": true,
"mentionable": false
},
"2909267986263572999": {
"id": "2909267986263572999",
"name": "@everyone",
"permissions": "49794752",
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
},
"41771983423143936": {
"id": "41771983423143936",
"name": "WE DEM BOYZZ!!!!!!",
"color": 3447003,
"hoist": true,
"icon": "cf3ced8600b777c9486c6d8d84fb4327",
"unicode_emoji": null,
"position": 1,
"permissions": "66321471",
"managed": false,
"mentionable": false
}
},
"users": {
"80351110224678912": {
"id": "80351110224678912",
"username": "Nelly",
"discriminator": "1337",
"avatar": "8342729096ea3675442027381ff50dfe",
"verified": true,
"email": "nelly@discord.com",
"flags": 64,
"banner": "06c16474723fe537c283b8efa61a30c8",
"accent_color": 16711680,
"premium_type": 1,
"public_flags": 64
}
}
}

View File

@@ -0,0 +1,110 @@
from itertools import count
from django.utils.timezone import now
from ..client import DiscordApiStatusCode
from ..models import Guild, GuildMember, Role, User
TEST_GUILD_ID = 123456789012345678
TEST_GUILD_NAME = "Test Guild"
TEST_USER_ID = 198765432012345678
TEST_USER_NAME = "Peter Parker"
TEST_USER_DISCRIMINATOR = "1234"
TEST_BOT_TOKEN = "abcdefhijlkmnopqastzvwxyz1234567890ABCDEFGHOJKLMNOPQRSTUVWXY"
TEST_ROLE_ID = 654321012345678912
def create_discord_role_object(id: int, name: str, managed: bool = False) -> dict:
return {"id": str(int(id)), "name": str(name), "managed": bool(managed)}
def create_matched_role(role, created=False) -> tuple:
return role, created
def create_discord_user_object(**kwargs):
params = {
"id": TEST_USER_ID,
"username": TEST_USER_NAME,
"discriminator": TEST_USER_DISCRIMINATOR,
}
params.update(kwargs)
params["id"] = str(int(params["id"]))
return params
def create_discord_guild_member_object(user=None, **kwargs):
user_params = {}
if user:
user_params["user"] = user
params = {
"user": create_discord_user_object(**user_params),
"roles": [],
"joined_at": now().isoformat(),
"deaf": False,
"mute": False,
}
params.update(kwargs)
params["roles"] = [str(int(obj)) for obj in params["roles"]]
return params
def create_discord_error_response(code: int) -> dict:
return {"code": int(code)}
def create_discord_error_response_unknown_member() -> dict:
return create_discord_error_response(DiscordApiStatusCode.UNKNOWN_MEMBER.value)
def create_discord_guild_object(**kwargs):
params = {"id": TEST_GUILD_ID, "name": TEST_GUILD_NAME, "roles": []}
params.update(kwargs)
params["id"] = str(int(params["id"]))
return params
def create_user(**kwargs):
params = {
"id": TEST_USER_ID,
"username": TEST_USER_NAME,
"discriminator": TEST_USER_DISCRIMINATOR,
}
params.update(kwargs)
return User(**params)
def create_guild(**kwargs):
params = {"id": TEST_GUILD_ID, "name": TEST_GUILD_NAME, "roles": []}
params.update(kwargs)
return Guild(**params)
def create_guild_member(**kwargs):
params = {"user": create_user(), "roles": []}
params.update(kwargs)
return GuildMember(**params)
def create_role(**kwargs) -> dict:
params = {"managed": False}
params.update(kwargs)
if "id" not in params:
params["id"] = next_number("role")
if "name" not in params:
params["name"] = f"Test Role #{params['id']}"
return Role(**params)
def next_number(key: str = None) -> int:
"""Calculate the next number in a persistent sequence."""
if key is None:
key = "_general"
try:
return next_number._counter[key].__next__()
except AttributeError:
next_number._counter = dict()
except KeyError:
pass
next_number._counter[key] = count(start=1)
return next_number._counter[key].__next__()

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