aa-structures 2.10.0__py3-none-any.whl → 2.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aa-structures
3
- Version: 2.10.0
3
+ Version: 2.12.0
4
4
  Summary: App for managing Eve Online structures with Alliance Auth.
5
5
  Author-email: Erik Kalkoken <kalkoken87@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -1,12 +1,12 @@
1
- structures/__init__.py,sha256=uMpmaP4VBhwkpAgLo3BXo4Hx7Rw_4RLMyB7jScq_qO8,204
2
- structures/admin.py,sha256=cn6AtcSDI6bJROhBnvE2iQwOkm1t3NVabrpTVphxbTk,38662
3
- structures/app_settings.py,sha256=7BFWl1Q4tqTuj3CV6BmEctIFKJx9QIUfm2tjKzme2jM,6526
1
+ structures/__init__.py,sha256=CxjO-itYCGXfwByFQ-cKfhmKWBvz2Lygf1fchGS8_nU,204
2
+ structures/admin.py,sha256=TYWGPou4s-mgspoGaZSiZu2nxBeNzidL_DcJTv7JGJI,40068
3
+ structures/app_settings.py,sha256=7I_6VC7YecCxnSgtyNQa9h1Zdwb11qJhrNaBLgyA5m0,6527
4
4
  structures/apps.py,sha256=MNZH9l3qWCwuS7OGiKGkBVrDzKoOFlqwDdEgyEFzxVA,195
5
5
  structures/auth_hooks.py,sha256=nRbrixFkAE5gphDokB1E8xhH8FY2VtXVwu0XMmSGBAw,1013
6
6
  structures/constants.py,sha256=R7sC5esaWJayJpTDbug7dTKxkkXDKXQ-U6M9Qb0GH5s,967
7
7
  structures/forms.py,sha256=kXs-SGIIj-D9xtFCILKe_PMRU4eHl3-HLNWRhaZKmnQ,391
8
8
  structures/helpers.py,sha256=_dw7j7yobpcV70VwWxoQiptk69b3ksvaDlRQKHg8Kmg,2344
9
- structures/managers.py,sha256=Th_nPY8GNcdBPqqfqjtTriYdsZWFAWknbNqI8QG17vo,19225
9
+ structures/managers.py,sha256=5hejDjWN3ZPZqvSGNf9HXtbtpWX9d75Ygc9FXrO0Np4,19638
10
10
  structures/providers.py,sha256=9QYHd8X5HwBhrbgbX3LUrXXV1KGM3hFhcxK6qIJjJtg,317
11
11
  structures/tasks.py,sha256=INZ3soMYHYfxrQsyTO-ozoxbVX6lpMnF3AUo8ybWIoA,9083
12
12
  structures/urls.py,sha256=An5v27AD_NXMNL66cMVtQNci1C_Aqry32rrvDkMpgYo,1467
@@ -20,7 +20,7 @@ structures/core/notification_embeds/__init__.py,sha256=30cpjKLZ5_OFtXDACDcjvWQAY
20
20
  structures/core/notification_embeds/billing_embeds.py,sha256=d9XkTyYH7U3UDZaqXu0WoN0wvlrT2S68Rfn2jEpVtM4,5164
21
21
  structures/core/notification_embeds/corporate_embeds.py,sha256=mfm6dDrgzupyRZ9TZGXML-UGoY_PymNj9LuR8EthdDA,6098
22
22
  structures/core/notification_embeds/helpers.py,sha256=0SlrLLFyV0IrdOYfIOLQs0xZgzK8bO7Iroqh7dZnm98,2974
23
- structures/core/notification_embeds/main.py,sha256=N-l9PJYxgsBvcPOG1WQ2OgxC6hCAQnxgx2V1gWUsSMc,15233
23
+ structures/core/notification_embeds/main.py,sha256=P8Ajvd9j4SUBpZde2IOejFUV5bEXMUWU4lolNjkrxWQ,16048
24
24
  structures/core/notification_embeds/moonmining_embeds.py,sha256=5OJtfwoLE18mB62iwyHTcMo9eOPBC2vQFSjQlDnTcTY,7130
25
25
  structures/core/notification_embeds/orbital_embeds.py,sha256=ovAnTqrc2X0s-HhQS-UleI8J5-gJEO361HYJzcoAdeQ,2864
26
26
  structures/core/notification_embeds/sov_embeds.py,sha256=_r4U5zN3MfEag9FCeN0my2d4qfMBUwYUGWW-oGftjn8,8901
@@ -57,11 +57,12 @@ structures/migrations/0002_remove_eveuniverse_relation_names.py,sha256=yXOFSolB0
57
57
  structures/migrations/0003_add_localization_and_unique_key.py,sha256=ZxJMfUtgUH2W8Z0zbN-iqMzjC3-1pvsbywJFyFMxWGA,44042
58
58
  structures/migrations/0004_improve_localization.py,sha256=R29j2Ki9I_yOvEQTxEu_zMnZrR7JkSGpmvMTpp9XjqM,14014
59
59
  structures/migrations/0005_add_notification_types.py,sha256=aIgNZN4Gsvh9GiJ0i4XmXopT0qqjZTQrdVjZaVIJKP8,7087
60
+ structures/migrations/0006_add_ownercharacter_disabled.py,sha256=yfcuwWHdSoF7HTEzdtQ1bGhI-K78SxCeE3g3z6UjbqI,726
60
61
  structures/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
62
  structures/models/__init__.py,sha256=Da0-Z4BtsZ_mlnI6XtyvoE0UtTuOA0psGCuVhVPVKrM,916
62
63
  structures/models/eveuniverse.py,sha256=vTIfzZL9guEDuZHQvRdG6pI4zSEdMqfVE-2SkHdqbRo,2213
63
- structures/models/notifications.py,sha256=tznJo2prNjhk9C7S4cDrQxftIvuLcidB6vSkvFfb4mw,35146
64
- structures/models/owners.py,sha256=8tw8mR2ti9MpPs5ajeV5AONY53cvO5XnucrfIOQXEA4,53953
64
+ structures/models/notifications.py,sha256=HwQc-ChdP0ULuJgxUym9_CBsCWCmcHT7whlk9tB2vzw,35364
65
+ structures/models/owners.py,sha256=8VDRL1fPep0TN6Nv5MJg1NC6_2V6bMJd6f-eda4-PJg,55697
65
66
  structures/models/structures_1.py,sha256=e1sI-2zMSKcPi2PyYA--fcc8rn-yQwODzlUHKjeWgLI,29778
66
67
  structures/models/structures_2.py,sha256=g5Pct5jNmZC-n7fnpLs5UyiP9JLrGdI1vjYucHTV_wQ,10371
67
68
  structures/static/structures/css/global.css,sha256=R4LEH9PwLoN77qkqBK7u2y_vzRC3fq8X_zKnAW7yhiA,1165
@@ -156,10 +157,10 @@ structures/templates/structures/templatetags/list_title.html,sha256=C2NWwo27TGfG
156
157
  structures/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
157
158
  structures/templatetags/structures.py,sha256=vSt5AnfYuo11q1SxiskT4oshSMsZ9ZgM0nYpYiMl8bA,1637
158
159
  structures/tests/__init__.py,sha256=9MrJzKr8DdsQY3-79v188pTpwqT4TDQ46vKnBJAGqic,75
159
- structures/tests/test_admin.py,sha256=_kV4giMVn-Pp77er0V5Pu10eBBohzT_oEdnl6D6q5bM,24333
160
+ structures/tests/test_admin.py,sha256=cb_AbOanp31k-oZhaUXCqnlkUu87EgNddq_VQKbB_Dk,26839
160
161
  structures/tests/test_helpers.py,sha256=BQC-4H-9-v5qW4nugqy0bkxuGlA3UO2clYY2lI2LjoY,4893
161
162
  structures/tests/test_managers_1.py,sha256=ruKq5VRYHdStDQe3uzVhcxF0seQQfmdudJEUYXzi8bY,34539
162
- structures/tests/test_managers_2.py,sha256=COF4DO67ejHueNwmVj5mtj9ps-FCq9uJQYTmWwpQW7Y,2942
163
+ structures/tests/test_managers_2.py,sha256=FISr_NT1Qnu9j1JYYTceNsUk8k9rfCTKwjKi5WUVbPs,3025
163
164
  structures/tests/test_tasks.py,sha256=reL1rPv_kvTgsC4lq5SXulkbEHaJrw5B1-hFI4tiDWY,17269
164
165
  structures/tests/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
165
166
  structures/tests/core/test_notification_structuretimers.py,sha256=OvldjgIHmRVsqoC9C6VJRqwS9WeRFMgpyutRc69PjAU,10857
@@ -170,7 +171,7 @@ structures/tests/core/test_sovereignty.py,sha256=SGgNw6NxmaU3jV-EDhKJRLfHnRnqDCw
170
171
  structures/tests/core/test_starbases.py,sha256=Q3EZVIKZGzxUUvvn0TvZYOL9YQ-baiTRTT83xWqTAhQ,3186
171
172
  structures/tests/core/notification_embeds/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
172
173
  structures/tests/core/notification_embeds/test_helpers.py,sha256=HG20TC8Bz7NqpaQWo6uvrsxfNIwTL2Zi_NRkh3UyN2Y,1776
173
- structures/tests/core/notification_embeds/test_main.py,sha256=vTDByjxxVCA6KMcr5XH4f4QsMgcDI_YDd-fjZfHztng,13808
174
+ structures/tests/core/notification_embeds/test_main.py,sha256=rSUm8dGsddjnX_OUwtCh-wd5UhJ6ttTsfxc1soAoDv8,14667
174
175
  structures/tests/integration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
175
176
  structures/tests/integration/test_tasks.py,sha256=s6hL5UBotDmhLq7NUiOad72W-S_18u_yAHiLWVryVi8,23617
176
177
  structures/tests/integration/test_views.py,sha256=SkbIposTUjo0nujHL4WEaZO7nY1Bfuvm805H39XUv-g,4361
@@ -179,19 +180,20 @@ structures/tests/models/test_eveuniverse.py,sha256=bQ1upOLfoivfaa9KCrzjWvSH_EvL2
179
180
  structures/tests/models/test_notifications_1.py,sha256=xVX4an54sZM8WLUdsAGUG2Nb4Rl7W5FBzhI0hBhJo5g,30300
180
181
  structures/tests/models/test_notifications_2.py,sha256=wBoB6lHMK4yrd63DSkZ570yXW6coevDtI0pl1ttgB1o,31989
181
182
  structures/tests/models/test_notifications_3.py,sha256=u11s0k9EmtFgL3kyb4zDyLVliQLOps701ZgEA2T2i0Y,7257
182
- structures/tests/models/test_notifications_discord.py,sha256=9_42lcrb2lUUMRqgdRJAub9cJ4QZI71OyoORwK0LEvg,5551
183
- structures/tests/models/test_owners_1.py,sha256=Xpac4P0xpJUmU8Jz5Ebxu0oSvhGcaW9a6UI7Jxhgmz4,22600
183
+ structures/tests/models/test_notifications_discord.py,sha256=6maDXjl2C5yH2XRgsvMtb3P-tfOdZLQDaksffiqS8OM,6312
184
+ structures/tests/models/test_owners_1.py,sha256=o8AJasQ0-Pdb0FRFZcmNcMGjaIk8fH3OZIZw4paJiDw,24433
184
185
  structures/tests/models/test_owners_2.py,sha256=gkTXpSibPWs1XfZtPvyeEvVebyYZcKERD9GQSYSClYk,21911
185
186
  structures/tests/models/test_owners_3.py,sha256=cpDkfFkVvC1v4uuXha1vGUmWZ163huYTUDuZyed9Rdc,16947
186
- structures/tests/models/test_owners_4.py,sha256=1tLHxeQTE4RacEdckwwQCocWyy0tVYuDDrHnHtQTNlA,18831
187
+ structures/tests/models/test_owners_4.py,sha256=_VP4QsPYGwBnn_bVN4aNX2ZVeJzMAzWObZrgCJkOPgY,19496
187
188
  structures/tests/models/test_owners_5.py,sha256=lTSfVY3rPOLyF-IhlKPT4mbRqvuN_NA-kyU0Vzzp9qY,31607
189
+ structures/tests/models/test_owners_6.py,sha256=R9UE2QGwiGTWLHzzOZwGQPWJrAPwIIl7UwrHH-ki4Ak,940
188
190
  structures/tests/models/test_structures.py,sha256=6HktwBOsmRtRq5MnRlZnvNixUsWUpRni_XnPjNpMN0Y,41469
189
191
  structures/tests/testdata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
190
192
  structures/tests/testdata/create_eveuniverse.py,sha256=E-Fh-dmuHmLZcqgK2U6mwt9W6dI7H99bG7DaETfcVQY,2129
191
193
  structures/tests/testdata/entities.json,sha256=kmcmmbHG_igHwvkpAJGHQEztEXjlo8McDuojpEBmgqU,32147
192
194
  structures/tests/testdata/esi_data.json,sha256=DX47oSlRZIPxlPueMfiNElbZbAip2cp3LAUmv8GAPJ0,13864
193
195
  structures/tests/testdata/eveuniverse.json,sha256=Z8_EXOF8XNvls9RfBYIHhoNX0rj0NqcjInTU6tZuwhI,993284
194
- structures/tests/testdata/factories.py,sha256=I-NQI9c6yakWzvYWg48QyX3-6h2bxoymcvPM0RyiEJw,24718
196
+ structures/tests/testdata/factories.py,sha256=KNXxOad44DKD0luQ7QS0DepnCr1S_Z6qwfiXnNDy10I,24774
195
197
  structures/tests/testdata/generate_notifications.py,sha256=nE077mVHVmOVudKIGMtHUfBIcV5Ch5Z0IngD1gp5yQk,5746
196
198
  structures/tests/testdata/generate_notifications_2.py,sha256=mdoGXd9vgy_opmrwTr9MfGe-QieHV4BHtRA7VFzxFKM,1413
197
199
  structures/tests/testdata/generate_structures.py,sha256=4p2ypDj-goniBnraTP2KfWzEX3YLj0qhKPNTfCXy05g,9134
@@ -203,14 +205,14 @@ structures/tests/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
203
205
  structures/tests/views/test_public.py,sha256=FTaT3VUOnoT4t9rCCTCGyDrberpa8CRJHsXuqAw2PQE,1372
204
206
  structures/tests/views/test_service_status.py,sha256=mWkP13HnKRa3ltsZ2FKJuZRJjvYic9wjgQ1J3qA8n-k,4197
205
207
  structures/tests/views/test_statistics.py,sha256=MHt31AIX9_jt8-yP8XsbsiRRUKEtY8mQJbk11saFYZc,2626
206
- structures/tests/views/test_structures.py,sha256=dspLWOORWZyhgZRD8OtqZtG2kL4kN7JEFTI1v4l7I9U,25971
208
+ structures/tests/views/test_structures.py,sha256=4DrURs32GvbMnXXJm1dC1ZhsDVhJ50iKyiqTRyyRe70,28284
207
209
  structures/tests/views/utils.py,sha256=0ALPjL8d0vcIWa_Pl4_gDl1qtBp71oDqyZLJyvDj-Uc,247
208
210
  structures/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
209
211
  structures/views/common.py,sha256=Z_hcpEpkGrhwFdMAAMON_DqsfQ_lMyRwOMl5ROo_aSk,818
210
212
  structures/views/public.py,sha256=-Tel-poYKZTy8eQllhsWUxVf7HYBuCfeL9JWlbKhs7s,3035
211
213
  structures/views/statistics.py,sha256=7jj8b9ATsYwE7Cg6gMp-bYx29nV43GdWYun9WBggGkM,4709
212
214
  structures/views/status.py,sha256=gcahbk6dPIZDqkaNHDAsEHyDWLzicTK18Fom0A6xx3c,718
213
- structures/views/structures.py,sha256=hw4Rjr6O_fK7dvdgtZGP9r3DExkfEwCPixeoEBK5KNU,22548
215
+ structures/views/structures.py,sha256=E1ssnqr2zp0rLQ3txIa6XkcX3NStNjMkuQM4iNjt6yw,22560
214
216
  structures/webhooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
215
217
  structures/webhooks/core.py,sha256=mP25MbQG8Yv2YayDFcR6x2V30eqXhR2q5u4SRefblf4,6573
216
218
  structures/webhooks/managers.py,sha256=L3G3AmsyDeif_lfpWshmAxQ61UGJ9w8i9lZaF2jbOtQ,1117
@@ -218,7 +220,7 @@ structures/webhooks/models.py,sha256=kUkt9rnRQIJIrU9Bjcs34rvkb-TMbUubHdn-kny08kI
218
220
  structures/webhooks/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
219
221
  structures/webhooks/tests/test_core.py,sha256=4NcEAQgK2KhQkFOxYh2ad0S-qUWh1DNGDmLo5Mo5opI,6762
220
222
  structures/webhooks/tests/test_utils.py,sha256=ekADFv0JOEtXeqdiejbeqrABO__Q1flJHzVieQ7L9e0,459
221
- aa_structures-2.10.0.dist-info/LICENSE,sha256=XZiwB_S_40_HhnvLg5xvtBb3g1oGjPrk0rpFwk8iInE,1070
222
- aa_structures-2.10.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
223
- aa_structures-2.10.0.dist-info/METADATA,sha256=fc6JHf4tNiaFaCZmiZZRD71A0Q2BMEjAjuIQNHmwicI,5972
224
- aa_structures-2.10.0.dist-info/RECORD,,
223
+ aa_structures-2.12.0.dist-info/LICENSE,sha256=XZiwB_S_40_HhnvLg5xvtBb3g1oGjPrk0rpFwk8iInE,1070
224
+ aa_structures-2.12.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
225
+ aa_structures-2.12.0.dist-info/METADATA,sha256=E2IAO0tGX_bRqM9r1ryldZsIGyv22TLHn8tMs8zQ2zs,5972
226
+ aa_structures-2.12.0.dist-info/RECORD,,
structures/__init__.py CHANGED
@@ -3,5 +3,5 @@
3
3
  # pylint: disable = invalid-name
4
4
  default_app_config = "structures.apps.StructuresConfig"
5
5
 
6
- __version__ = "2.10.0"
6
+ __version__ = "2.12.0"
7
7
  __title__ = "Structures"
structures/admin.py CHANGED
@@ -348,6 +348,29 @@ class OwnerCharacterAdminInline(admin.TabularInline):
348
348
  return False
349
349
 
350
350
 
351
+ class DisabledCharactersFilter(admin.SimpleListFilter):
352
+ title = _("has disabled characters")
353
+ parameter_name = "has_disabled_characters"
354
+
355
+ def lookups(self, request, model_admin):
356
+ return (
357
+ ("yes", _("yes")),
358
+ ("no", _("no")),
359
+ )
360
+
361
+ def queryset(self, request, queryset):
362
+ """Return the filtered queryset"""
363
+ if self.value() == "yes":
364
+ return queryset.annotate_characters_count().filter(
365
+ characters_disabled_count__gt=0
366
+ )
367
+ if self.value() == "no":
368
+ return queryset.annotate_characters_count().filter(
369
+ characters_disabled_count=0
370
+ )
371
+ return queryset
372
+
373
+
351
374
  @admin.register(Owner)
352
375
  class OwnerAdmin(admin.ModelAdmin):
353
376
  list_display = (
@@ -364,6 +387,7 @@ class OwnerAdmin(admin.ModelAdmin):
364
387
  list_filter = (
365
388
  "is_active",
366
389
  "is_up",
390
+ DisabledCharactersFilter,
367
391
  ("corporation__alliance", admin.RelatedOnlyFieldListFilter),
368
392
  "has_default_pings_enabled",
369
393
  "is_alliance_main",
@@ -376,6 +400,7 @@ class OwnerAdmin(admin.ModelAdmin):
376
400
  "fetch_notifications",
377
401
  "deactivate_owners",
378
402
  "activate_owners",
403
+ "reset_characters",
379
404
  )
380
405
  inlines = (OwnerCharacterAdminInline,)
381
406
  filter_horizontal = ("ping_groups", "webhooks")
@@ -485,9 +510,13 @@ class OwnerAdmin(admin.ModelAdmin):
485
510
  def has_add_permission(self, request):
486
511
  return False
487
512
 
488
- @admin.display(ordering="characters_count_2", description=_("characters"))
489
- def _characters(self, obj: Owner) -> int:
490
- return obj.characters_count_2
513
+ @admin.display(ordering="characters_enabled_count", description=_("characters"))
514
+ def _characters(self, obj: Owner) -> str:
515
+ enabled = obj.characters_enabled_count
516
+ disabled = obj.characters_disabled_count
517
+ if not disabled:
518
+ return enabled
519
+ return f"{enabled} ({disabled})"
491
520
 
492
521
  @admin.display(description=_("default pings"), boolean=True)
493
522
  def _has_default_pings_enabled(self, obj: Owner):
@@ -549,6 +578,16 @@ class OwnerAdmin(admin.ModelAdmin):
549
578
  queryset.update(is_active=False)
550
579
  self.message_user(request, _("Deactivated %d owners") % queryset.count())
551
580
 
581
+ @admin.action(description=_("Reset disabled characters for selected owners"))
582
+ def reset_characters(self, request, queryset):
583
+ owner_pks = queryset.values_list("pk", flat=True)
584
+ OwnerCharacter.objects.filter(
585
+ owner__pk__in=list(owner_pks), is_enabled=False
586
+ ).update(is_enabled=True, disabled_reason="", error_count=0)
587
+ self.message_user(
588
+ request, _("Characters have been reset for %d owners") % len(owner_pks)
589
+ )
590
+
552
591
  @admin.action(description=_("Update all from EVE server for selected owners"))
553
592
  def update_all(self, request, queryset):
554
593
  for obj in queryset:
@@ -631,7 +670,7 @@ class OwnerAdmin(admin.ModelAdmin):
631
670
  def _assets_last_update_fresh(self, obj: Owner) -> bool:
632
671
  return obj.is_assets_sync_fresh
633
672
 
634
- @admin.action(description=_("structures Count"))
673
+ @admin.display(description=_("structures Count"))
635
674
  def _structures_count(self, obj: Owner) -> int:
636
675
  return obj.structures.count()
637
676
 
@@ -55,7 +55,7 @@ STRUCTURES_FEATURE_STARBASES = clean_setting("STRUCTURES_FEATURE_STARBASES", Tru
55
55
  STRUCTURES_ESI_DIRECTOR_ERROR_MAX_RETRIES = clean_setting(
56
56
  "STRUCTURES_ESI_DIRECTOR_ERROR_MAX_RETRIES", 3
57
57
  )
58
- """Max retries before a character is deleted when ESI claims the character
58
+ """Max retries before a character is disabled when ESI claims the character
59
59
  is not a director (Since this sometimes is reported wrongly by ESI).
60
60
  """
61
61
 
@@ -10,6 +10,8 @@ import dhooks_lite
10
10
  from django.conf import settings
11
11
  from django.utils.translation import gettext as _
12
12
 
13
+ from allianceauth.services.hooks import get_extension_logger
14
+ from app_utils.logging import LoggerAddTag
13
15
  from app_utils.urls import reverse_absolute, static_file_absolute_url
14
16
 
15
17
  from structures import __title__
@@ -19,6 +21,8 @@ from structures.models.notifications import Notification, NotificationBase, Webh
19
21
 
20
22
  from .helpers import target_datetime_formatted
21
23
 
24
+ logger = LoggerAddTag(get_extension_logger(__name__), __title__)
25
+
22
26
 
23
27
  class NotificationBaseEmbed:
24
28
  """Base class for all notification embeds.
@@ -141,6 +145,20 @@ class NotificationBaseEmbed:
141
145
  footer_text += f" #{my_text}"
142
146
  footer_icon_url = footer_icon_url if is_absolute_url(footer_icon_url) else None
143
147
  footer = dhooks_lite.Footer(text=footer_text, icon_url=footer_icon_url)
148
+ max_description = dhooks_lite.Embed.MAX_DESCRIPTION
149
+ if self._description and len(self._description) > max_description:
150
+ logger.warning(
151
+ "%s: Description of notification is too long: %s",
152
+ self,
153
+ self._description,
154
+ )
155
+ self._description = self._description[:max_description]
156
+ max_title = dhooks_lite.Embed.MAX_TITLE
157
+ if self._title and len(self._title) > max_title:
158
+ logger.warning(
159
+ "%s: Title of notification is too long: %s", self, self._title
160
+ )
161
+ self._title = self._title[:max_title]
144
162
  return dhooks_lite.Embed(
145
163
  author=author,
146
164
  color=self._color,
structures/managers.py CHANGED
@@ -181,14 +181,27 @@ GeneratedNotificationManager = GeneratedNotificationManagerBase.from_queryset(
181
181
 
182
182
  class OwnerQuerySet(models.QuerySet):
183
183
  def annotate_characters_count(self) -> models.QuerySet:
184
- """Add character count annotation."""
185
- return self.annotate(
186
- characters_count_2=Count(
184
+ """Add character count annotations."""
185
+ qs = self.annotate(
186
+ characters_enabled_count=Count(
187
+ "characters",
188
+ filter=Q(
189
+ characters__character_ownership__isnull=False,
190
+ characters__is_enabled=True,
191
+ ),
192
+ distinct=True,
193
+ )
194
+ ).annotate(
195
+ characters_disabled_count=Count(
187
196
  "characters",
188
- filter=Q(characters__character_ownership__isnull=False),
197
+ filter=Q(
198
+ characters__character_ownership__isnull=False,
199
+ characters__is_enabled=False,
200
+ ),
189
201
  distinct=True,
190
202
  )
191
203
  )
204
+ return qs
192
205
 
193
206
  def structures_last_updated(self) -> Optional[dt.datetime]:
194
207
  """Date/time when structures were last updated for any of the active owners."""
@@ -0,0 +1,27 @@
1
+ # Generated by Django 4.0.10 on 2024-06-23 14:26
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("structures", "0005_add_notification_types"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="ownercharacter",
15
+ name="disabled_reason",
16
+ field=models.TextField(default=""),
17
+ ),
18
+ migrations.AddField(
19
+ model_name="ownercharacter",
20
+ name="is_enabled",
21
+ field=models.BooleanField(
22
+ default=True,
23
+ help_text="Disabled characters are not used for syncing owners",
24
+ verbose_name="is enabled",
25
+ ),
26
+ ),
27
+ ]
@@ -410,11 +410,15 @@ class NotificationBase(models.Model):
410
410
  try:
411
411
  embed, ping_type = self._generate_embed(webhook.language_code)
412
412
  except OSError as ex:
413
- logger.warning("%s: Failed to generate embed: %s", self, ex, exc_info=True)
413
+ logger.error("%s: Failed to generate embed: %s", self, ex, exc_info=True)
414
414
  return False
415
415
 
416
416
  content = self._create_content_with_pings(webhook, ping_type)
417
417
  content += self._add_discord_group_pings(webhook)
418
+ max_content = dhooks_lite.Webhook.MAX_CHARACTERS
419
+ if content and len(content) > max_content:
420
+ logger.error("%s: Content of notification is too long: %s", self, content)
421
+ return False
418
422
 
419
423
  username, avatar_url = self._gen_avatar()
420
424
  new_queue_size = webhook.send_message(
@@ -338,12 +338,15 @@ class Owner(models.Model):
338
338
  f"Character {character_ownership.character} does not belong "
339
339
  "to owner corporation."
340
340
  )
341
- obj, _ = self.characters.get_or_create(character_ownership=character_ownership)
341
+ obj: OwnerCharacter = self.characters.get_or_create(
342
+ character_ownership=character_ownership
343
+ )[0]
344
+ obj.reset()
342
345
  return obj
343
346
 
344
- def characters_count(self) -> int:
347
+ def valid_characters_count(self) -> int:
345
348
  """Count of valid owner characters."""
346
- return self.characters.count()
349
+ return self.characters.filter(is_enabled=True).count()
347
350
 
348
351
  def has_sov(self, eve_solar_system: EveSolarSystem) -> bool:
349
352
  """Determine whether this owner has sov in the given solar system."""
@@ -351,42 +354,31 @@ class Owner(models.Model):
351
354
  eve_solar_system=eve_solar_system, corporation=self.corporation
352
355
  )
353
356
 
354
- def delete_character(
357
+ def disable_character(
355
358
  self,
356
359
  character: "OwnerCharacter",
357
- error: str,
358
- level: str = "warning",
360
+ reason: str,
359
361
  max_allowed_errors: int = 0,
360
362
  ) -> None:
361
- """Delete character and notify it's owner and admin about the reason
363
+ """Disable character and notify it's owner and admins about it.
362
364
 
363
365
  Args:
364
- - character: Character this error refers to
365
- - error: Error text
366
- - level: context level for the notification
367
- - max_error: how many errors are permitted before character is deleted
366
+ - character: Character to disable
367
+ - reason: User friendly reason for the deletion
368
+ - max_allowed_errors: Maximum number of allowed errors for this type of error
368
369
  """
369
370
  if character.error_count < max_allowed_errors:
370
- logger.warning(
371
- (
372
- "%s: Character encountered an error and will be deleted "
373
- "if this occurs more often (%d/%d): %s"
374
- ),
375
- character,
376
- character.error_count + 1,
377
- max_allowed_errors,
378
- error,
379
- )
380
- with transaction.atomic():
381
- character.error_count = F("error_count") + 1
382
- character.save(update_fields=["error_count"])
371
+ character.increase_error_count()
383
372
  return
384
373
 
385
- title = f"{__title__}: {self}: Invalid character has been removed"
374
+ character.disable(reason)
375
+
376
+ title = f"{__title__}: {self}: Character has been disabled"
377
+ level = "warning"
386
378
  message = (
387
- f"{character.character_ownership}: {error}\n"
388
- "The character has been removed. "
389
- "Please add a new character to restore the previous service level."
379
+ f"{character.character_ownership}: {reason}\n"
380
+ "This character caused too many errors and has been disabled. "
381
+ "Administrator action is required to resolve this issue."
390
382
  )
391
383
  notify(
392
384
  user=character.character_ownership.user,
@@ -394,15 +386,44 @@ class Owner(models.Model):
394
386
  message=message,
395
387
  level=level,
396
388
  )
397
- if self.characters.count() == 1:
389
+ if not self.valid_characters_count():
398
390
  message += (
399
391
  " This owner has no configured characters anymore "
400
392
  "and it's services are now down."
401
393
  )
402
394
  level = "danger"
395
+
403
396
  notify_admins(title=f"FYI: {title}", message=message, level=level)
397
+
398
+ def delete_character(self, character: "OwnerCharacter", reason: str) -> None:
399
+ """Delete character and notify it's owner and admin about the reason
400
+
401
+ Args:
402
+ - character: Character this error refers to
403
+ - reason: User friendly reason for the deletion
404
+ """
404
405
  character.delete()
405
406
 
407
+ title = f"{__title__}: {self}: Invalid character has been removed"
408
+ level = "warning"
409
+ message = (
410
+ f"{character.character_ownership}: {reason}\n"
411
+ "Your character is no longer valid for syncing this owner and has been removed. "
412
+ )
413
+ notify(
414
+ user=character.character_ownership.user,
415
+ title=title,
416
+ message=message,
417
+ level=level,
418
+ )
419
+ if not self.valid_characters_count():
420
+ message += (
421
+ " This owner has no valid characters anymore "
422
+ "and it's services are now down."
423
+ )
424
+ level = "danger"
425
+ notify_admins(title=f"FYI: {title}", message=message, level=level)
426
+
406
427
  def _rotate_character(
407
428
  self,
408
429
  character: "OwnerCharacter",
@@ -421,7 +442,7 @@ class Owner(models.Model):
421
442
  )
422
443
  try:
423
444
  minimum_time_between_rotations = max(
424
- rotate_characters.esi_cache_duration / self.characters.count(),
445
+ rotate_characters.esi_cache_duration / self.valid_characters_count(),
425
446
  60,
426
447
  )
427
448
  except ZeroDivisionError:
@@ -453,14 +474,18 @@ class Owner(models.Model):
453
474
  if rotate_characters
454
475
  else "notifications_last_used_at"
455
476
  )
456
- for character in self.characters.order_by(order_by_last_used):
477
+ enabled_characters: models.QuerySet[OwnerCharacter] = self.characters.filter(
478
+ is_enabled=True
479
+ ).order_by(order_by_last_used)
480
+ for character in enabled_characters:
457
481
  if (
458
482
  character.character_ownership.character.corporation_id
459
483
  != self.corporation.corporation_id
460
484
  ):
485
+ corporation_name = self.corporation.corporation_name
461
486
  self.delete_character(
462
487
  character=character,
463
- error="Character does no longer belong to the owner's corporation.",
488
+ reason=f"Character is no longer a member of {corporation_name}",
464
489
  )
465
490
  continue
466
491
 
@@ -469,17 +494,17 @@ class Owner(models.Model):
469
494
  ):
470
495
  self.delete_character(
471
496
  character=character,
472
- error="Character does not have sufficient permission to sync.",
497
+ reason="Character no longer has permission to sync",
473
498
  )
474
499
  continue
475
500
 
476
501
  token = character.valid_token()
477
502
  if not token:
478
- self.delete_character(
479
- character=character,
480
- error="Character has no valid token for sync.",
503
+ self.disable_character(
504
+ character=character, reason="No valid token found for character"
481
505
  )
482
506
  continue
507
+
483
508
  found_character = character
484
509
  break # leave the for loop if we have found a valid token
485
510
 
@@ -797,6 +822,13 @@ class Owner(models.Model):
797
822
 
798
823
  Return True when successful, else False.
799
824
  """
825
+ try:
826
+ character: OwnerCharacter = self.characters.get(
827
+ character_ownership__character__character_id=token.character_id
828
+ )
829
+ except ObjectDoesNotExist:
830
+ return False
831
+
800
832
  structures = []
801
833
  try:
802
834
  starbases_data = (
@@ -824,21 +856,11 @@ class Owner(models.Model):
824
856
  self._store_raw_data("starbases", structures)
825
857
 
826
858
  except HTTPForbidden:
827
- try:
828
- character = self.characters.get(
829
- character_ownership__character__character_id=token.character_id
830
- )
831
- except ObjectDoesNotExist:
832
- pass
833
- else:
834
- self.delete_character(
835
- character=character,
836
- error=(
837
- "Character is not a director or CEO and therefore "
838
- "can not fetch starbases."
839
- ),
840
- max_allowed_errors=STRUCTURES_ESI_DIRECTOR_ERROR_MAX_RETRIES,
841
- )
859
+ self.disable_character(
860
+ character=character,
861
+ reason=("This character is not a director or CEO"),
862
+ max_allowed_errors=STRUCTURES_ESI_DIRECTOR_ERROR_MAX_RETRIES,
863
+ )
842
864
  return False
843
865
 
844
866
  except OSError as ex:
@@ -849,6 +871,7 @@ class Owner(models.Model):
849
871
  structures_qs=self.structures.filter_starbases(),
850
872
  new_structures=structures,
851
873
  )
874
+ character.reset_error_counter()
852
875
  return True
853
876
 
854
877
  def _store_updates_for_starbases(self, token, structures):
@@ -1337,6 +1360,12 @@ class OwnerCharacter(models.Model):
1337
1360
  verbose_name=_("error count"),
1338
1361
  help_text="Count of ESI errors which happened with this character.",
1339
1362
  )
1363
+ is_enabled = models.BooleanField(
1364
+ default=True,
1365
+ verbose_name=_("is enabled"),
1366
+ help_text=_("Disabled characters are not used for syncing owners"),
1367
+ )
1368
+ disabled_reason = models.TextField(default="")
1340
1369
  created_at = models.DateTimeField(auto_now_add=True)
1341
1370
 
1342
1371
  class Meta:
@@ -1369,3 +1398,27 @@ class OwnerCharacter(models.Model):
1369
1398
  .require_valid()
1370
1399
  .first()
1371
1400
  )
1401
+
1402
+ def reset(self) -> None:
1403
+ """Resets a disabled owner character."""
1404
+ self.is_enabled = True
1405
+ self.disabled_reason = ""
1406
+ self.error_count = 0
1407
+ self.save()
1408
+
1409
+ def reset_error_counter(self) -> None:
1410
+ """Reset the error counter"""
1411
+ self.error_count = 0
1412
+ self.save(update_fields=["error_count"])
1413
+
1414
+ def disable(self, reason: str = "") -> None:
1415
+ """Disables a character."""
1416
+ self.is_enabled = False
1417
+ self.disabled_reason = reason
1418
+ self.save()
1419
+
1420
+ def increase_error_count(self):
1421
+ """Increase error count of this character by one."""
1422
+ with transaction.atomic():
1423
+ self.error_count = F("error_count") + 1
1424
+ self.save(update_fields=["error_count"])
@@ -240,6 +240,26 @@ class TestNotificationEmbedsGenerate(TestCase):
240
240
  self.assertEqual(discord_embed.footer.text, "Structures")
241
241
  self.assertIn("structures_logo.png", discord_embed.footer.icon_url)
242
242
 
243
+ def test_should_not_break_with_too_large_description(self):
244
+ # given
245
+ notification = Notification.objects.get(notification_id=1000000403)
246
+ notification_embed = NotificationBaseEmbed.create(notification)
247
+ notification_embed._description = "x" * 2049
248
+ # when
249
+ discord_embed = notification_embed.generate_embed()
250
+ # then
251
+ self.assertIsInstance(discord_embed, dhooks_lite.Embed)
252
+
253
+ def test_should_not_break_with_too_large_title(self):
254
+ # given
255
+ notification = Notification.objects.get(notification_id=1000000403)
256
+ notification_embed = NotificationBaseEmbed.create(notification)
257
+ notification_embed._title = "x" * 257
258
+ # when
259
+ discord_embed = notification_embed.generate_embed()
260
+ # then
261
+ self.assertIsInstance(discord_embed, dhooks_lite.Embed)
262
+
243
263
 
244
264
  class TestNotificationEmbedsClasses(NoSocketsTestCase):
245
265
  @classmethod
@@ -33,6 +33,7 @@ if "discord" in app_labels():
33
33
  super().setUpClass()
34
34
  load_eveuniverse()
35
35
  load_eve_entities()
36
+
36
37
  cls.group_1 = Group.objects.create(name="Dummy Group 1")
37
38
  cls.group_2 = Group.objects.create(name="Dummy Group 2")
38
39
  cls.owner = OwnerFactory()
@@ -140,3 +141,23 @@ if "discord" in app_labels():
140
141
  self.assertTrue(mock_import_discord.called)
141
142
  _, kwargs = mock_send_message.call_args
142
143
  self.assertFalse(re.search(r"(<@&\d+>)", kwargs["content"]))
144
+
145
+ def test_should_abort_when_content_is_too_large(
146
+ self, mock_send_message, mock_import_discord
147
+ ):
148
+ # given
149
+ mock_send_message.return_value = 1
150
+ mock_import_discord.return_value.objects.group_to_role.side_effect = (
151
+ self._my_group_to_role
152
+ )
153
+ webhook = WebhookFactory()
154
+ for i in range(286):
155
+ group = Group.objects.create(name=f"Group {i+1}")
156
+ webhook.ping_groups.add(group)
157
+ obj = clone_notification(
158
+ Notification.objects.get(notification_id=1000000509)
159
+ )
160
+ # when
161
+ result = obj.send_to_webhook(webhook)
162
+ # then
163
+ self.assertFalse(result)
@@ -318,20 +318,21 @@ class TestOwnerFetchToken(NoSocketsTestCase):
318
318
  self.assertTrue(mock_notify.called)
319
319
  self.assertEqual(owner.characters.count(), 0)
320
320
 
321
- def test_raise_error_when_token_not_found_and_delete_character(
321
+ def test_raise_error_when_no_valid_token_found_and_disable_character(
322
322
  self, mock_notify_admins, mock_notify
323
323
  ):
324
324
  # given
325
- character = EveCharacterFactory()
326
- user = UserMainDefaultOwnerFactory(main_character__character=character)
327
- owner = OwnerFactory(user=user, characters=[character])
328
- user.token_set.first().scopes.clear()
325
+ eve_character = EveCharacterFactory()
326
+ user = UserMainDefaultOwnerFactory(main_character__character=eve_character)
327
+ owner = OwnerFactory(user=user, characters=[eve_character])
328
+ user.token_set.first().scopes.clear() # token no longer valid
329
329
  # when/then
330
330
  with self.assertRaises(TokenError):
331
331
  owner.fetch_token()
332
+ character = owner.characters.first()
333
+ self.assertFalse(character.is_enabled)
332
334
  self.assertTrue(mock_notify_admins.called)
333
335
  self.assertTrue(mock_notify.called)
334
- self.assertEqual(owner.characters.count(), 0)
335
336
 
336
337
  def test_raise_error_when_character_no_longer_a_corporation_member_and_delete_it(
337
338
  self, mock_notify_admins, mock_notify
@@ -351,37 +352,7 @@ class TestOwnerFetchToken(NoSocketsTestCase):
351
352
  self.assertTrue(mock_notify.called)
352
353
  self.assertEqual(owner.characters.count(), 0)
353
354
 
354
- def test_should_delete_invalid_characters_and_return_token_from_valid_char(
355
- self, mock_notify_admins, mock_notify
356
- ):
357
- # given
358
- character_1 = EveCharacterFactory()
359
- user = UserMainDefaultOwnerFactory(main_character__character=character_1)
360
- owner = OwnerFactory(
361
- user=user,
362
- characters=[character_1],
363
- characters__notifications_last_used_at=dt.datetime(
364
- 2021, 1, 1, 1, 2, tzinfo=utc
365
- ),
366
- )
367
- character_2 = EveCharacterFactory()
368
- OwnerCharacterFactory(
369
- owner=owner,
370
- eve_character=character_2,
371
- notifications_last_used_at=dt.datetime(2021, 1, 1, 1, 1, tzinfo=utc),
372
- )
373
-
374
- # when
375
- token = owner.fetch_token()
376
-
377
- # then
378
- self.assertIsInstance(token, Token)
379
- self.assertEqual(token.user, user)
380
- self.assertTrue(mock_notify_admins.called)
381
- self.assertTrue(mock_notify.called)
382
- self.assertEqual(owner.characters.count(), 1)
383
-
384
- def test_should_rotate_through_characters_for_notification(
355
+ def test_should_rotate_through_enabled_characters_for_notification(
385
356
  self, mock_notify_admins, mock_notify
386
357
  ):
387
358
  # given
@@ -398,6 +369,9 @@ class TestOwnerFetchToken(NoSocketsTestCase):
398
369
  owner=owner,
399
370
  notifications_last_used_at=dt.datetime(2021, 1, 1, 3, 0, tzinfo=utc),
400
371
  )
372
+ OwnerCharacterFactory(
373
+ owner=owner, is_enabled=False
374
+ ) # this one should be ignore
401
375
  tokens_received = []
402
376
 
403
377
  # when
@@ -493,6 +467,36 @@ class TestOwnerFetchToken(NoSocketsTestCase):
493
467
  ],
494
468
  )
495
469
 
470
+ def test_should_delete_invalid_characters_and_return_token_from_valid_char(
471
+ self, mock_notify_admins, mock_notify
472
+ ):
473
+ # given
474
+ character_1 = EveCharacterFactory()
475
+ user = UserMainDefaultOwnerFactory(main_character__character=character_1)
476
+ owner = OwnerFactory(
477
+ user=user,
478
+ characters=[character_1],
479
+ characters__notifications_last_used_at=dt.datetime(
480
+ 2021, 1, 1, 1, 2, tzinfo=utc
481
+ ),
482
+ )
483
+ character_2 = EveCharacterFactory() # invalid, because of different corporation
484
+ OwnerCharacterFactory(
485
+ owner=owner,
486
+ eve_character=character_2,
487
+ notifications_last_used_at=dt.datetime(2021, 1, 1, 1, 1, tzinfo=utc),
488
+ )
489
+
490
+ # when
491
+ token = owner.fetch_token()
492
+
493
+ # then
494
+ self.assertIsInstance(token, Token)
495
+ self.assertEqual(token.user, user)
496
+ self.assertTrue(mock_notify_admins.called)
497
+ self.assertTrue(mock_notify.called)
498
+ self.assertEqual(owner.characters.count(), 1)
499
+
496
500
 
497
501
  class TestOwnerCharacters(NoSocketsTestCase):
498
502
  @classmethod
@@ -500,12 +504,6 @@ class TestOwnerCharacters(NoSocketsTestCase):
500
504
  super().setUpClass()
501
505
  cls.owner = OwnerFactory()
502
506
 
503
- def test_should_return_str(self):
504
- # given
505
- character = OwnerCharacterFactory(owner=self.owner)
506
- # when/then
507
- self.assertTrue(str(character))
508
-
509
507
  def test_should_add_new_character(self):
510
508
  # given
511
509
  character = EveCharacterFactory(corporation=self.owner.corporation)
@@ -546,22 +544,35 @@ class TestOwnerCharacters(NoSocketsTestCase):
546
544
  with self.assertRaises(ValueError):
547
545
  self.owner.add_character(character_ownership)
548
546
 
549
- def test_should_count_characters(self):
547
+ def test_should_count_enabled_characters_only(self):
550
548
  # given
551
- OwnerCharacterFactory(owner=self.owner)
549
+ OwnerCharacterFactory(owner=self.owner, is_enabled=False)
552
550
  # when
553
- result = self.owner.characters_count()
551
+ result = self.owner.valid_characters_count()
554
552
  # then
555
- self.assertEqual(result, 2)
553
+ self.assertEqual(result, 1)
556
554
 
557
555
  def test_should_count_characters_when_empty(self):
558
556
  # given
559
557
  owner = OwnerFactory(characters=False)
560
558
  # when
561
- result = owner.characters_count()
559
+ result = owner.valid_characters_count()
562
560
  # then
563
561
  self.assertEqual(result, 0)
564
562
 
563
+ def test_should_reset_character_when_re_adding(self):
564
+ # given
565
+ character: OwnerCharacter = self.owner.characters.first()
566
+ character.is_enabled = False
567
+ character.disabled_reason = "some reason"
568
+ character.save()
569
+ # when
570
+ self.owner.add_character(character.character_ownership)
571
+ # then
572
+ character.refresh_from_db()
573
+ self.assertTrue(character.is_enabled)
574
+ self.assertFalse(character.disabled_reason)
575
+
565
576
 
566
577
  @patch(MODULE_PATH + ".notify", spec=True)
567
578
  @patch(MODULE_PATH + ".notify_admins", spec=True)
@@ -577,7 +588,7 @@ class TestOwnerDeleteCharacter(NoSocketsTestCase):
577
588
  user = character.character_ownership.user
578
589
 
579
590
  # when
580
- self.owner.delete_character(character=character, error="dummy error")
591
+ self.owner.delete_character(character=character, reason="dummy error")
581
592
 
582
593
  # then
583
594
  self.assertEqual(self.owner.characters.count(), 0)
@@ -591,18 +602,50 @@ class TestOwnerDeleteCharacter(NoSocketsTestCase):
591
602
  self.assertEqual(kwargs["user"], user)
592
603
  self.assertEqual(kwargs["level"], "warning")
593
604
 
594
- def test_should_not_delete_when_errors_are_allowed(
605
+
606
+ @patch(MODULE_PATH + ".notify", spec=True)
607
+ @patch(MODULE_PATH + ".notify_admins", spec=True)
608
+ class TestOwnerDisableCharacters(NoSocketsTestCase):
609
+ @classmethod
610
+ def setUpClass(cls):
611
+ super().setUpClass()
612
+ cls.owner = OwnerFactory(characters=False)
613
+
614
+ def test_should_disable_character_and_notify(self, mock_notify_admins, mock_notify):
615
+ # given
616
+ character = OwnerCharacterFactory(owner=self.owner)
617
+ user = character.character_ownership.user
618
+
619
+ # when
620
+ self.owner.disable_character(character=character, reason="dummy error")
621
+
622
+ # then
623
+ character.refresh_from_db()
624
+ self.assertFalse(character.is_enabled)
625
+ self.assertTrue(character.disabled_reason)
626
+ self.assertTrue(mock_notify_admins.called)
627
+ _, kwargs = mock_notify_admins.call_args
628
+ self.assertIn("dummy error", kwargs["message"])
629
+ self.assertEqual(kwargs["level"], "danger")
630
+ self.assertTrue(mock_notify.called)
631
+ _, kwargs = mock_notify.call_args
632
+ self.assertIn("dummy error", kwargs["message"])
633
+ self.assertEqual(kwargs["user"], user)
634
+ self.assertEqual(kwargs["level"], "warning")
635
+
636
+ def test_should_not_disable_when_error_counter_above_zero(
595
637
  self, mock_notify_admins, mock_notify
596
638
  ):
597
639
  # given
598
640
  character = OwnerCharacterFactory(owner=self.owner)
599
641
 
600
642
  # when
601
- self.owner.delete_character(
602
- character=character, error="dummy error", max_allowed_errors=1
643
+ self.owner.disable_character(
644
+ character=character, reason="dummy error", max_allowed_errors=1
603
645
  )
604
646
  # then
605
647
  character.refresh_from_db()
648
+ self.assertTrue(character.is_enabled)
606
649
  self.assertEqual(character.error_count, 1)
607
650
  self.assertFalse(mock_notify_admins.called)
608
651
  self.assertFalse(mock_notify.called)
@@ -7,7 +7,7 @@ from app_utils.esi_testing import EsiClientStub, EsiEndpoint
7
7
  from app_utils.testing import NoSocketsTestCase
8
8
 
9
9
  from structures.constants import EveCorporationId
10
- from structures.models import StarbaseDetail, Structure
10
+ from structures.models import OwnerCharacter, StarbaseDetail, Structure
11
11
  from structures.tests.testdata.factories import (
12
12
  EveEntityCorporationFactory,
13
13
  OwnerFactory,
@@ -353,12 +353,13 @@ class TestUpdateStarbasesEsi(NoSocketsTestCase):
353
353
  owner.refresh_from_db()
354
354
  self.assertFalse(owner.is_structure_sync_fresh)
355
355
  self.assertTrue(mock_notify)
356
- character = owner.characters.first()
356
+ character: OwnerCharacter = owner.characters.first()
357
357
  self.assertEqual(character.error_count, 1)
358
+ self.assertTrue(character.is_enabled)
358
359
 
359
360
  @patch(MODULE_PATH + ".STRUCTURES_ESI_DIRECTOR_ERROR_MAX_RETRIES", 3)
360
361
  @patch(MODULE_PATH + ".notify", spec=True)
361
- def test_should_remove_character_when_not_director_while_updating_starbases(
362
+ def test_should_disable_character_when_not_director_while_updating_starbases(
362
363
  self, mock_notify, mock_esi
363
364
  ):
364
365
  # given
@@ -369,7 +370,7 @@ class TestUpdateStarbasesEsi(NoSocketsTestCase):
369
370
  )
370
371
  mock_esi.client = self.esi_client_stub.replace_endpoints([new_endpoint])
371
372
  owner = OwnerFactory(user=self.user, structures_last_update_at=None)
372
- character = owner.characters.first()
373
+ character: OwnerCharacter = owner.characters.first()
373
374
  character.error_count = 3
374
375
  character.save()
375
376
  # when
@@ -378,7 +379,22 @@ class TestUpdateStarbasesEsi(NoSocketsTestCase):
378
379
  owner.refresh_from_db()
379
380
  self.assertFalse(owner.is_structure_sync_fresh)
380
381
  self.assertTrue(mock_notify)
381
- self.assertNotIn(character, owner.characters.all())
382
+ character.refresh_from_db()
383
+ self.assertFalse(character.is_enabled)
384
+
385
+ def test_should_reset_error_count_for_character_when_successful(self, mock_esi):
386
+ # given
387
+ mock_esi.client = self.esi_client_stub
388
+ owner = OwnerFactory(user=self.user, structures_last_update_at=None)
389
+ character: OwnerCharacter = owner.characters.first()
390
+ character.error_count = 3
391
+ character.save()
392
+ # when
393
+ owner.update_structures_esi()
394
+ # then
395
+ character.refresh_from_db()
396
+ self.assertTrue(character.is_enabled)
397
+ self.assertEqual(character.error_count, 0)
382
398
 
383
399
  def test_should_remove_old_starbases(self, mock_esi):
384
400
  # given
@@ -0,0 +1,31 @@
1
+ from app_utils.testing import NoSocketsTestCase
2
+
3
+ from structures.tests.testdata.factories import OwnerCharacterFactory, OwnerFactory
4
+
5
+ MODULE_PATH = "structures.models.owners"
6
+
7
+
8
+ class TestOwnerCharacter(NoSocketsTestCase):
9
+ @classmethod
10
+ def setUpClass(cls):
11
+ super().setUpClass()
12
+ cls.owner = OwnerFactory()
13
+
14
+ def test_should_return_str(self):
15
+ # given
16
+ character = OwnerCharacterFactory(owner=self.owner)
17
+ # when/then
18
+ self.assertTrue(str(character))
19
+
20
+ def test_can_reset_character(self):
21
+ # given
22
+ character = OwnerCharacterFactory(
23
+ owner=self.owner, is_enabled=False, disabled_reason="reason", error_count=42
24
+ )
25
+ # when
26
+ character.reset()
27
+ # then
28
+ character.refresh_from_db()
29
+ self.assertTrue(character.is_enabled)
30
+ self.assertFalse(character.disabled_reason)
31
+ self.assertFalse(character.error_count)
@@ -32,6 +32,7 @@ from .testdata.factories import (
32
32
  EveCorporationInfoFactory,
33
33
  FuelAlertConfigFactory,
34
34
  NotificationFactory,
35
+ OwnerCharacterFactory,
35
36
  OwnerFactory,
36
37
  PocoFactory,
37
38
  StarbaseFactory,
@@ -261,6 +262,7 @@ class TestNotificationAdmin(TestCase):
261
262
  )
262
263
  # when
263
264
  result = self.modeladmin._structures(obj)
265
+ # then
264
266
  self.assertIsNone(result)
265
267
 
266
268
  # FIXME: Does not seam to work with special prefetch list
@@ -356,6 +358,52 @@ class TestNotificationAdmin(TestCase):
356
358
  self.assertSetEqual(set(queryset), set(expected))
357
359
 
358
360
 
361
+ class TestNotificationAdminWebhooks(TestCase):
362
+ @classmethod
363
+ def setUpClass(cls):
364
+ super().setUpClass()
365
+ cls.factory = RequestFactory()
366
+ load_eveuniverse()
367
+ cls.modeladmin = NotificationAdmin(model=Notification, admin_site=AdminSite())
368
+ cls.user = SuperuserFactory()
369
+
370
+ def test_should_return_name_of_owner_webhook(self):
371
+ # given
372
+ owner = OwnerFactory(webhooks__name="Alpha")
373
+ obj = NotificationFactory(
374
+ owner=owner, notif_type=NotificationType.STRUCTURE_LOST_SHIELD
375
+ )
376
+ obj2 = self.modeladmin.get_queryset(MockRequest(user=self.user)).get(pk=obj.pk)
377
+ # when
378
+ result = self.modeladmin._webhooks(obj2)
379
+ # then
380
+ self.assertEqual("Alpha", result)
381
+
382
+ def test_should_report_missing_webhook(self):
383
+ # given
384
+ owner = OwnerFactory(webhooks=False)
385
+ obj = NotificationFactory(
386
+ owner=owner, notif_type=NotificationType.STRUCTURE_LOST_SHIELD
387
+ )
388
+ obj2 = self.modeladmin.get_queryset(MockRequest(user=self.user)).get(pk=obj.pk)
389
+ # when
390
+ result = self.modeladmin._webhooks(obj2)
391
+ # then
392
+ self.assertIn("Not configured", result)
393
+
394
+ def test_should_report_when_webhooks_not_configured_for_this_notif_type(self):
395
+ # given
396
+ owner = OwnerFactory()
397
+ obj = NotificationFactory(
398
+ owner=owner, notif_type=NotificationType.SOV_ENTOSIS_CAPTURE_STARTED
399
+ )
400
+ obj2 = self.modeladmin.get_queryset(MockRequest(user=self.user)).get(pk=obj.pk)
401
+ # when
402
+ result = self.modeladmin._webhooks(obj2)
403
+ # then
404
+ self.assertIn("Not configured", result)
405
+
406
+
359
407
  class TestOwnerAdmin(TestCase):
360
408
  @classmethod
361
409
  def setUpClass(cls):
@@ -420,6 +468,23 @@ class TestOwnerAdmin(TestCase):
420
468
  self.assertEqual(mock_task.delay.call_count, 1)
421
469
  self.assertTrue(mock_message_user.called)
422
470
 
471
+ @patch(MODULE_PATH + ".OwnerAdmin.message_user", spec=True)
472
+ def test_action_reset_characters(self, mock_message_user):
473
+ # given
474
+ owner_1 = OwnerFactory(characters=False)
475
+ character_1 = OwnerCharacterFactory(owner=owner_1, is_enabled=False)
476
+ owner_2 = OwnerFactory(characters=False)
477
+ character_2 = OwnerCharacterFactory(owner=owner_2, is_enabled=False)
478
+ # when
479
+ queryset = Owner.objects.filter(pk=owner_1.pk)
480
+ self.modeladmin.reset_characters(MockRequest(self.user), queryset)
481
+ # then
482
+ self.assertTrue(mock_message_user.called)
483
+ character_1.refresh_from_db()
484
+ self.assertTrue(character_1.is_enabled)
485
+ character_2.refresh_from_db()
486
+ self.assertFalse(character_2.is_enabled)
487
+
423
488
  def test_should_return_empty_turnaround_times(self):
424
489
  # given
425
490
  obj = OwnerFactory()
@@ -60,12 +60,13 @@ class TestOwnerManager(NoSocketsTestCase):
60
60
  def test_should_annotate_characters_count(self):
61
61
  # given
62
62
  owner = OwnerFactory() # 1st character automatically created
63
- OwnerCharacterFactory(owner=owner) # 2nd character added
63
+ OwnerCharacterFactory(owner=owner, is_enabled=False) # 2nd character added
64
64
  # when
65
65
  result = Owner.objects.annotate_characters_count()
66
66
  # then
67
67
  obj = result.get(pk=owner.pk)
68
- self.assertEqual(obj.characters_count_2, 2)
68
+ self.assertEqual(obj.characters_enabled_count, 1)
69
+ self.assertEqual(obj.characters_disabled_count, 1)
69
70
 
70
71
  def test_should_return_when_structures_where_last_updated_for_several_owners(self):
71
72
  # given
@@ -203,9 +203,7 @@ class OwnerFactory(factory.django.DjangoModelFactory, metaclass=BaseMetaFactory[
203
203
  def characters(
204
204
  obj, create: bool, extracted: Optional[List[EveCharacter]], **kwargs
205
205
  ):
206
- """
207
- Set extracted to False to skip creating characters.
208
- """
206
+ # Set characters=False to skip creating characters.
209
207
  if not create or extracted is False:
210
208
  return
211
209
 
@@ -222,7 +220,8 @@ class OwnerFactory(factory.django.DjangoModelFactory, metaclass=BaseMetaFactory[
222
220
 
223
221
  @factory.post_generation
224
222
  def webhooks(obj, create, extracted, **kwargs):
225
- if not create:
223
+ # Set webhooks=False to skip creating characters.
224
+ if not create or extracted is False:
226
225
  return
227
226
 
228
227
  if extracted:
@@ -8,14 +8,13 @@ from django.urls import reverse
8
8
  from django.utils.dateparse import parse_datetime
9
9
  from django.utils.timezone import now
10
10
 
11
+ from allianceauth.eveonline.models import EveCharacter
11
12
  from app_utils.testdata_factories import UserMainFactory
12
13
  from app_utils.testing import json_response_to_python
13
14
 
14
15
  import structures.views.status
15
16
  from structures.models import Owner, Structure
16
- from structures.views import structures
17
-
18
- from ..testdata.factories import (
17
+ from structures.tests.testdata.factories import (
19
18
  EveCharacterFactory,
20
19
  JumpGateFactory,
21
20
  OwnerFactory,
@@ -27,7 +26,9 @@ from ..testdata.factories import (
27
26
  UserMainDefaultOwnerFactory,
28
27
  WebhookFactory,
29
28
  )
30
- from ..testdata.load_eveuniverse import load_eveuniverse
29
+ from structures.tests.testdata.load_eveuniverse import load_eveuniverse
30
+ from structures.views import structures
31
+
31
32
  from .utils import json_response_to_dict
32
33
 
33
34
  VIEWS_PATH = "structures.views.structures"
@@ -527,7 +528,7 @@ class TestAddStructureOwner(TestCase):
527
528
  cls.factory = RequestFactory()
528
529
  load_eveuniverse()
529
530
  cls.user = UserMainDefaultOwnerFactory()
530
- cls.character = cls.user.profile.main_character
531
+ cls.character: EveCharacter = cls.user.profile.main_character
531
532
  cls.character_ownership = cls.character.character_ownership
532
533
 
533
534
  def _add_structure_owner_view(self, token=None, user=None):
@@ -656,6 +657,58 @@ class TestAddStructureOwner(TestCase):
656
657
  )
657
658
  self.assertTrue(owner.is_active)
658
659
 
660
+ @patch(VIEWS_PATH + ".STRUCTURES_ADMIN_NOTIFICATIONS_ENABLED", False)
661
+ @patch(VIEWS_PATH + ".tasks.update_all_for_owner")
662
+ @patch(VIEWS_PATH + ".notify_admins")
663
+ @patch(VIEWS_PATH + ".messages")
664
+ def test_can_readd_same_character(
665
+ self, mock_messages, mock_notify_admins, mock_update_all_for_owner
666
+ ):
667
+ # given
668
+ owner = OwnerFactory(characters=[self.character])
669
+ owner_character = owner.characters.first()
670
+ # when
671
+ response = self._add_structure_owner_view(user=self.user)
672
+ # then
673
+ self.assertEqual(response.status_code, 302)
674
+ self.assertEqual(response.url, reverse("structures:index"))
675
+ owner.refresh_from_db()
676
+ character_ownerships = set(
677
+ owner.characters.values_list("character_ownership", flat=True)
678
+ )
679
+ self.assertSetEqual(
680
+ {self.character_ownership.pk, owner_character.character_ownership.pk},
681
+ character_ownerships,
682
+ )
683
+
684
+ # @patch(VIEWS_PATH + ".STRUCTURES_ADMIN_NOTIFICATIONS_ENABLED", False)
685
+ # @patch(VIEWS_PATH + ".tasks.update_all_for_owner")
686
+ # @patch(VIEWS_PATH + ".notify_admins")
687
+ # @patch(VIEWS_PATH + ".messages")
688
+ # def test_should_reenable_character_when_re_adding(
689
+ # self, mock_messages, mock_notify_admins, mock_update_all_for_owner
690
+ # ):
691
+ # # given
692
+ # owner = OwnerFactory(characters=[self.character])
693
+ # owner_character: OwnerCharacter = owner.characters.first()
694
+ # owner_character.is_enabled = False
695
+ # owner_character.save()
696
+ # # when
697
+ # response = self._add_structure_owner_view(user=self.user)
698
+ # # then
699
+ # self.assertEqual(response.status_code, 302)
700
+ # self.assertEqual(response.url, reverse("structures:index"))
701
+ # owner.refresh_from_db()
702
+ # character_ownerships = set(
703
+ # owner.characters.values_list("character_ownership", flat=True)
704
+ # )
705
+ # self.assertSetEqual(
706
+ # {self.character_ownership.pk, owner_character.character_ownership.pk},
707
+ # character_ownerships,
708
+ # )
709
+ # owner_character.refresh_from_db()
710
+ # self.assertTrue(owner_character.is_enabled)
711
+
659
712
 
660
713
  class TestStructureFittingModal(TestCase):
661
714
  @classmethod
@@ -630,7 +630,7 @@ def add_structure_owner(request: HttpRequest, token: Token):
630
630
  % {
631
631
  "corporation": owner,
632
632
  "character": token_char,
633
- "characters_count": owner.characters_count(),
633
+ "characters_count": owner.valid_characters_count(),
634
634
  }
635
635
  ),
636
636
  )
@@ -646,7 +646,7 @@ def add_structure_owner(request: HttpRequest, token: Token):
646
646
  "character": token_char,
647
647
  "corporation": owner,
648
648
  "user": request.user.username,
649
- "characters_count": owner.characters_count(),
649
+ "characters_count": owner.valid_characters_count(),
650
650
  },
651
651
  title=_("%s: Character added to: %s") % (__title__, owner),
652
652
  )