aa-ledger 0.9.9__py3-none-any.whl → 0.9.9.1__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.
Files changed (77) hide show
  1. {aa_ledger-0.9.9.dist-info → aa_ledger-0.9.9.1.dist-info}/METADATA +1 -1
  2. {aa_ledger-0.9.9.dist-info → aa_ledger-0.9.9.1.dist-info}/RECORD +77 -77
  3. ledger/__init__.py +9 -9
  4. ledger/api/api_helper/billboard_helper.py +277 -277
  5. ledger/api/ledger/admin.py +289 -289
  6. ledger/app_settings.py +50 -50
  7. ledger/constants.py +5 -0
  8. ledger/decorators.py +92 -11
  9. ledger/helpers/alliance.py +353 -334
  10. ledger/helpers/character.py +260 -260
  11. ledger/helpers/core.py +565 -565
  12. ledger/helpers/corporation.py +455 -421
  13. ledger/helpers/etag.py +237 -237
  14. ledger/helpers/ref_type.py +475 -475
  15. ledger/locale/cs_CZ/LC_MESSAGES/django.po +942 -942
  16. ledger/locale/de/LC_MESSAGES/django.po +961 -961
  17. ledger/locale/django.pot +942 -942
  18. ledger/locale/es/LC_MESSAGES/django.po +943 -943
  19. ledger/locale/fr_FR/LC_MESSAGES/django.po +942 -942
  20. ledger/locale/it_IT/LC_MESSAGES/django.po +942 -942
  21. ledger/locale/ja/LC_MESSAGES/django.po +943 -943
  22. ledger/locale/ko_KR/LC_MESSAGES/django.po +942 -942
  23. ledger/locale/nl_NL/LC_MESSAGES/django.po +942 -942
  24. ledger/locale/pl_PL/LC_MESSAGES/django.po +942 -942
  25. ledger/locale/ru/LC_MESSAGES/django.po +945 -945
  26. ledger/locale/sk/LC_MESSAGES/django.po +944 -944
  27. ledger/locale/uk/LC_MESSAGES/django.po +946 -946
  28. ledger/locale/zh_Hans/LC_MESSAGES/django.po +943 -943
  29. ledger/managers/character_mining_manager.py +239 -239
  30. ledger/managers/character_planetary_manager.py +1 -1
  31. ledger/migrations/0016_characterminingledger_price_per_unit.py +21 -21
  32. ledger/models/characteraudit.py +496 -496
  33. ledger/static/ledger/css/cards.css +1 -1
  34. ledger/static/ledger/css/table.css +1 -1
  35. ledger/static/ledger/js/charts.js +221 -221
  36. ledger/static/ledger/js/planetary.js +143 -143
  37. ledger/tasks.py +442 -449
  38. ledger/templates/ledger/allyledger/admin/alliance_administration.html +46 -46
  39. ledger/templates/ledger/allyledger/admin/alliance_overview.html +108 -108
  40. ledger/templates/ledger/allyledger/alliance_ledger.html +86 -86
  41. ledger/templates/ledger/bundles/ally-administration-bundles.html +59 -59
  42. ledger/templates/ledger/bundles/char-administration-bundles.html +66 -66
  43. ledger/templates/ledger/bundles/character-ledger-bundles.html +66 -66
  44. ledger/templates/ledger/bundles/corp-administration-bundles.html +68 -68
  45. ledger/templates/ledger/bundles/corporation-ledger-bundles.html +75 -75
  46. ledger/templates/ledger/charledger/admin/character_administration.html +39 -39
  47. ledger/templates/ledger/charledger/admin/character_overview.html +106 -106
  48. ledger/templates/ledger/charledger/character_ledger.html +94 -94
  49. ledger/templates/ledger/charledger/planetary/planetary_ledger.html +54 -54
  50. ledger/templates/ledger/corpledger/admin/corporation_administration.html +39 -39
  51. ledger/templates/ledger/corpledger/admin/corporation_overview.html +108 -108
  52. ledger/templates/ledger/corpledger/corporation_ledger.html +129 -86
  53. ledger/templates/ledger/partials/administration/alliance.html +37 -37
  54. ledger/templates/ledger/partials/administration/alliance_corporations.html +58 -58
  55. ledger/templates/ledger/partials/administration/corporation_characters.html +34 -34
  56. ledger/templates/ledger/partials/information/daily.html +56 -56
  57. ledger/templates/ledger/partials/information/day.html +48 -48
  58. ledger/templates/ledger/partials/information/hourly.html +53 -53
  59. ledger/templates/ledger/partials/information/summary.html +88 -88
  60. ledger/templates/ledger/partials/information/view_character_content.html +35 -35
  61. ledger/templates/ledger/partials/table/char-ledger.html +85 -85
  62. ledger/templates/ledger/partials/table/corp-ledger.html +66 -66
  63. ledger/templates/ledger/partials/view/card.html +160 -160
  64. ledger/tests/test_decarators.py +102 -17
  65. ledger/tests/test_helpers/test_etag.py +149 -149
  66. ledger/tests/test_managers/test_character_mining_manager.py +54 -54
  67. ledger/tests/test_models/test_characterminingledger.py +107 -106
  68. ledger/tests/test_tasks.py +282 -282
  69. ledger/tests/test_templatetags.py +5 -2
  70. ledger/tests/test_views/test_access.py +852 -852
  71. ledger/tests/testdata/esi.json +1 -2
  72. ledger/tests/testdata/eveuniverse.json +391 -391
  73. ledger/urls.py +66 -21
  74. ledger/views/alliance/alliance_ledger.py +203 -203
  75. ledger/views/corporation/corporation_ledger.py +25 -9
  76. {aa_ledger-0.9.9.dist-info → aa_ledger-0.9.9.1.dist-info}/WHEEL +0 -0
  77. {aa_ledger-0.9.9.dist-info → aa_ledger-0.9.9.1.dist-info}/licenses/LICENSE +0 -0
ledger/helpers/core.py CHANGED
@@ -1,565 +1,565 @@
1
- """
2
- Core View Helper
3
- """
4
-
5
- # Standard Library
6
- from decimal import Decimal
7
- from hashlib import md5
8
-
9
- # Django
10
- from django.db.models import Q, QuerySet, Sum
11
- from django.urls import reverse
12
- from django.utils import timezone
13
- from django.utils.safestring import mark_safe
14
- from django.utils.translation import gettext as _
15
-
16
- # Alliance Auth
17
- from allianceauth.authentication.models import CharacterOwnership, UserProfile
18
- from allianceauth.eveonline.models import (
19
- EveAllianceInfo,
20
- EveCharacter,
21
- EveCorporationInfo,
22
- )
23
- from allianceauth.services.hooks import get_extension_logger
24
-
25
- # Alliance Auth (External Libs)
26
- from app_utils.logging import LoggerAddTag
27
-
28
- # AA Ledger
29
- from ledger import __title__
30
- from ledger.api.api_helper.billboard_helper import BillboardSystem
31
- from ledger.app_settings import LEDGER_CACHE_KEY
32
- from ledger.helpers.ref_type import RefTypeManager
33
- from ledger.models.general import EveEntity
34
-
35
- logger = LoggerAddTag(get_extension_logger(__name__), __title__)
36
-
37
-
38
- def add_info_to_context(request, context: dict) -> dict:
39
- """Add additional information to the context for the view."""
40
- # pylint: disable=import-outside-toplevel
41
- # AA Ledger
42
- from ledger.models.characteraudit import CharacterAudit
43
-
44
- total_issues = (
45
- CharacterAudit.objects.annotate_total_update_status_user(user=request.user)
46
- .aggregate(total_failed=Sum("num_sections_failed"))
47
- .get("total_failed", 0)
48
- )
49
-
50
- new_context = {
51
- **{
52
- "issues": total_issues,
53
- },
54
- **context,
55
- }
56
- return new_context
57
-
58
-
59
- class DummyEveEntity:
60
- """Dummy Eve Entity class for fallback when no entity is found."""
61
-
62
- def __init__(self, entity_id, entity_name="Unknown"):
63
- self.entity_id = entity_id
64
- self.entity_name = entity_name
65
- self.type = "character"
66
-
67
-
68
- class LedgerEntity:
69
- """Class to hold character or corporation data for the ledger."""
70
-
71
- # pylint: disable=too-many-positional-arguments
72
- def __init__(
73
- self,
74
- entity_id,
75
- character_obj: EveCharacter = None,
76
- corporation_obj: EveCorporationInfo = None,
77
- alliance_obj: EveAllianceInfo = None,
78
- details_url=None,
79
- ):
80
- self.type = "character"
81
- self.entity = None
82
- self.entity_id = entity_id
83
- self.entity_name = None
84
- self.details_url = details_url
85
- if character_obj and hasattr(character_obj, "character_id"):
86
- self.entity = character_obj
87
- self.entity_id = character_obj.character_id
88
- self.entity_name = character_obj.character_name
89
- elif corporation_obj and hasattr(corporation_obj, "corporation_id"):
90
- self.entity = corporation_obj
91
- self.entity_id = corporation_obj.corporation_id
92
- self.entity_name = corporation_obj.corporation_name
93
- self.type = "corporation"
94
- elif alliance_obj and hasattr(alliance_obj, "alliance_id"):
95
- self.entity = alliance_obj
96
- self.entity_id = alliance_obj.alliance_id
97
- self.entity_name = alliance_obj.alliance_name
98
- self.type = "alliance"
99
- else:
100
- try:
101
- entity_obj = EveEntity.objects.get(eve_id=entity_id)
102
- self.entity = entity_obj
103
- self.entity_id = entity_obj.eve_id
104
- self.entity_name = entity_obj.name
105
- self.type = entity_obj.category
106
- except EveEntity.DoesNotExist:
107
- self.entity = DummyEveEntity(entity_id, "Unknown")
108
- self.entity_id = entity_id
109
- self.entity_name = "Unknown"
110
-
111
- @property
112
- def is_eve_character(self):
113
- """Check if the entity is an Eve Character."""
114
- return isinstance(self.entity, EveCharacter)
115
-
116
- @property
117
- def is_eve_corporation(self):
118
- """Check if the entity is an Eve Corporation."""
119
- return isinstance(self.entity, EveCorporationInfo)
120
-
121
- @property
122
- def is_eve_entity(self):
123
- """Check if the entity is an Eve Entity."""
124
- return isinstance(self.entity, EveEntity)
125
-
126
- @property
127
- def alts(self) -> QuerySet[EveCharacter]:
128
- """Get all alts for this character."""
129
- if not isinstance(self.entity, EveCharacter):
130
- raise ValueError("Entity is not an EveCharacter.")
131
- alts = EveCharacter.objects.filter(
132
- character_ownership__user=self.entity.character_ownership.user
133
- ).select_related(
134
- "character_ownership",
135
- )
136
- return alts
137
-
138
- def portrait_url(self):
139
- """Return the portrait URL for the entity."""
140
- try:
141
- if isinstance(self.entity, EveCorporationInfo):
142
- return self.entity.logo_url_32
143
-
144
- if hasattr(self.entity, "portrait_url"):
145
- return self.entity.portrait_url(size=32)
146
-
147
- if self.entity.category == "faction":
148
- return ""
149
- return self.entity.icon_url(size=32)
150
- except Exception as e: # pylint: disable=broad-except
151
- logger.error(f"Error getting portrait URL for {self.entity_id}: {e}")
152
- return ""
153
-
154
- def add_details_url(self, details_url):
155
- """Set the details URL for the entity."""
156
- self.details_url = details_url
157
-
158
- def get_alts_ids_or_self(self):
159
- """Return the IDs of all alternative characters or the character ID itself."""
160
- try:
161
- character = EveCharacter.objects.get(character_id=self.entity_id)
162
- if hasattr(character, "character_ownership"):
163
- alt_ids = character.character_ownership.user.character_ownerships.all().values_list(
164
- "character__character_id", flat=True
165
- )
166
- return list(alt_ids)
167
- except (
168
- EveCharacter.DoesNotExist,
169
- AttributeError,
170
- CharacterOwnership.DoesNotExist,
171
- ):
172
- pass
173
- return [self.entity_id]
174
-
175
- @property
176
- def create_button(self):
177
- """Generate the URL for character details."""
178
- title = _("View Details")
179
- button_html = f"<button class='btn btn-primary btn-sm btn-square' data-bs-toggle='modal' data-bs-target='#modalViewCharacterContainer' data-ajax_url='{self.details_url}' title='{title}' data-tooltip-toggle='ledger-tooltip'><span class='fas fa-info'></span></button>"
180
- return mark_safe(button_html)
181
-
182
-
183
- class LedgerCore:
184
- """Core View Helper for Ledger."""
185
-
186
- def __init__(self, year=None, month=None, day=None):
187
- self.date_info = {"year": year, "month": month, "day": day}
188
- self.ledger_type = "ledger"
189
-
190
- # If all are None, default to 'month' view
191
- if year is None and month is None and day is None:
192
- self.view = "month"
193
- else:
194
- self.view = "day" if day else "month" if month else "year"
195
-
196
- self.journal = None
197
- self.mining = None
198
- self.billboard = BillboardSystem(self.view)
199
-
200
- @property
201
- def year(self):
202
- return self.date_info["year"]
203
-
204
- @property
205
- def month(self):
206
- return self.date_info["month"]
207
-
208
- @property
209
- def day(self):
210
- return self.date_info["day"]
211
-
212
- @property
213
- def filter_date(self):
214
- """
215
- Generate a date filter for the ledger based on year, month, and day.
216
- Returns:
217
- Q: A Django Q object representing the date filter.
218
- """
219
- now = timezone.now()
220
- # If all are None, use current year and month
221
- if self.year is None and self.month is None and self.day is None:
222
- filter_date = Q(date__year=now.year) & Q(date__month=now.month)
223
- else:
224
- filter_date = (
225
- Q(date__year=self.year) if self.year else Q(date__year=now.year)
226
- )
227
- if self.month:
228
- filter_date &= Q(date__month=self.month)
229
- if self.day:
230
- filter_date &= Q(date__day=self.day)
231
- return filter_date
232
-
233
- @property
234
- def get_details_title(self):
235
- """
236
- Generate a title for the details view based on the date information.
237
-
238
- Returns:
239
- str: A formatted string representing the date or a default title.
240
- """
241
- if self.year and self.month and self.day:
242
- return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
243
- if self.year and self.month:
244
- return f"{self.year:04d}-{self.month:02d}"
245
- if self.year:
246
- return f"{self.year:04d}"
247
- return "Character Ledger Details"
248
-
249
- @property
250
- def auth_accounts(self):
251
- """Get all user accounts with a main character."""
252
- return (
253
- UserProfile.objects.filter(
254
- main_character__isnull=False,
255
- )
256
- .prefetch_related(
257
- "user__profile__main_character",
258
- )
259
- .order_by(
260
- "user__profile__main_character__character_name",
261
- )
262
- )
263
-
264
- @property
265
- def auth_character_ids(self) -> set:
266
- """Get all account Character IDs from Alliance Auth."""
267
- account_character_ids = set()
268
- for account in self.auth_accounts:
269
- alts = account.user.character_ownerships.all()
270
- account_character_ids.update(
271
- alts.values_list("character__character_id", flat=True)
272
- )
273
- return account_character_ids
274
-
275
- def _get_ledger_journal_hash(self, journal: list[str]) -> str:
276
- """Generate a hash for the ledger journal."""
277
- return md5(",".join(str(x) for x in sorted(journal)).encode()).hexdigest()
278
-
279
- def _get_ledger_hash(self, header_key: str) -> str:
280
- """Generate a hash for the ledger journal."""
281
- return md5(header_key.encode()).hexdigest()
282
-
283
- def _get_ledger_header(
284
- self, entity_id: int, year: int, month: int, day: int
285
- ) -> str:
286
- """Generate a header string for the ledger."""
287
- return f"{entity_id}_{year}_{month}_{day}"
288
-
289
- def _build_ledger_cache_key(self, header_key: str) -> str:
290
- """Build a cache key for the ledger."""
291
- return f"{LEDGER_CACHE_KEY}-{self._get_ledger_hash(header_key)}"
292
-
293
- def _calculate_totals(self, ledger) -> dict:
294
- """
295
- Calculate the total amounts for each category in the ledger.
296
-
297
- Args:
298
- ledger (list or dict): The ledger data to calculate totals from.
299
-
300
- Returns:
301
- dict: A dictionary containing the totals for each category.
302
- """
303
- totals = {
304
- "bounty": Decimal(0),
305
- "ess": Decimal(0),
306
- "costs": Decimal(0),
307
- "mining": Decimal(0),
308
- "miscellaneous": Decimal(0),
309
- "total": Decimal(0),
310
- }
311
-
312
- if not ledger:
313
- return totals
314
-
315
- if isinstance(ledger, dict):
316
- ledger = [ledger]
317
-
318
- for total in ledger:
319
-
320
- if total is None:
321
- continue
322
-
323
- totals["bounty"] += total["ledger"].get("bounty", 0)
324
- totals["ess"] += total["ledger"].get("ess", 0)
325
- totals["costs"] += total["ledger"].get("costs", 0)
326
- totals["mining"] += total["ledger"].get("mining", 0)
327
- totals["miscellaneous"] += total["ledger"].get("miscellaneous", 0)
328
- totals["total"] += total["ledger"].get("total", 0)
329
- return totals
330
-
331
- def create_url(self, viewname: str, **kwargs):
332
- """
333
- Create a URL for the given view and entity using kwargs.
334
- Args:
335
- viewname: The name of the view to create the URL for.
336
- kwargs: All needed parameters for the URL (e.g. character_id, corporation_id, etc.)
337
- Returns:
338
- A URL string for the specified view.
339
- """
340
- if self.year and self.month and self.day:
341
- return reverse(
342
- f"ledger:{viewname}_year_month_day",
343
- kwargs={
344
- **kwargs,
345
- "year": self.year,
346
- "month": self.month,
347
- "day": self.day,
348
- },
349
- )
350
- if self.year and self.month:
351
- return reverse(
352
- f"ledger:{viewname}_year_month",
353
- kwargs={
354
- **kwargs,
355
- "year": self.year,
356
- "month": self.month,
357
- },
358
- )
359
- if self.year:
360
- return reverse(
361
- f"ledger:{viewname}_year",
362
- kwargs={**kwargs, "year": self.year},
363
- )
364
- return reverse(
365
- f"ledger:{viewname}_year_month",
366
- kwargs={
367
- **kwargs,
368
- "year": timezone.now().year,
369
- "month": timezone.now().month,
370
- },
371
- )
372
-
373
- def create_view_data(self, viewname: str, **kwargs) -> dict:
374
- """
375
- Create view data for the ledger using kwargs.
376
- Args:
377
- viewname (str): The name of the view to create the URL for.
378
- kwargs: All needed parameters for the URL (e.g. character_id, corporation_id, etc.)
379
- Returns:
380
- dict: A dictionary containing the type, date, and details URL.
381
- """
382
- return {
383
- "type": self.ledger_type,
384
- "date": {
385
- "current": {
386
- "year": timezone.now().year,
387
- "month": timezone.now().month,
388
- "day": timezone.now().day,
389
- },
390
- "year": self.year,
391
- "month": self.month,
392
- "day": self.day,
393
- },
394
- "details_url": self.create_url(
395
- viewname=viewname,
396
- **kwargs,
397
- ),
398
- }
399
-
400
- # pylint: disable=too-many-locals
401
- def _generate_amounts(
402
- self,
403
- income_types: list,
404
- entity: LedgerEntity,
405
- is_old_ess: bool = False,
406
- char_ids: list = None,
407
- ) -> dict:
408
- """Generate amounts for the entity based on income types and reference types."""
409
- amounts = {}
410
-
411
- ref_types = RefTypeManager.get_all_categories()
412
-
413
- # Bounty Income
414
- if not entity.entity_id == 1000125: # Remove Concord Bountys
415
- bounty_income = self.journal.aggregate_bounty()
416
- if bounty_income > 0:
417
- amounts["bounty_income"] = {
418
- "total_amount": bounty_income,
419
- "ref_types": ["bounty_prizes"],
420
- }
421
-
422
- if isinstance(entity.entity, EveCharacter):
423
- # Mining Income
424
- mining_income = self.mining.aggregate_mining()
425
- if mining_income > 0:
426
- amounts["mining_income"] = {
427
- "total_amount": mining_income,
428
- "ref_types": ["mining"],
429
- }
430
-
431
- # ESS Income (nur wenn bounty_income existiert)
432
- ess_income = (
433
- bounty_income * Decimal(0.667)
434
- if is_old_ess and bounty_income
435
- else self.journal.aggregate_ess()
436
- )
437
- if ess_income > 0:
438
- amounts["ess_income"] = {
439
- "total_amount": ess_income,
440
- "ref_types": ["ess_escrow_transfer"],
441
- }
442
-
443
- # Income/Cost Ref Types (DRY)
444
- for ref_type, value in ref_types.items():
445
- ref_type_name = ref_type.lower()
446
- for kind, income_flag in (("income", True), ("cost", False)):
447
- kwargs = {"ref_type": value, "income": income_flag}
448
- kwargs = RefTypeManager.special_cases_details(
449
- value, entity, kwargs, journal_type=entity.type, char_ids=char_ids
450
- )
451
- agg = self.journal.aggregate_ref_type(**kwargs)
452
- if (income_flag and agg > 0) or (not income_flag and agg < 0):
453
- amounts[f"{ref_type_name}_{kind}"] = {
454
- "total_amount": agg,
455
- "ref_types": value,
456
- }
457
-
458
- # Summary (exclude mining_income)
459
- summary = sum(
460
- amount["total_amount"]
461
- for key, amount in amounts.items()
462
- if key != "mining_income"
463
- and isinstance(amount, dict)
464
- and "total_amount" in amount
465
- )
466
-
467
- if summary == 0:
468
- return None
469
-
470
- amounts["summary"] = {
471
- "total_amount": summary,
472
- }
473
-
474
- # Dynamische Income/Cost-Typen für das Template
475
- income_types += [
476
- (f"{ref_type.lower()}_income", _(ref_type.replace("_", " ").title()))
477
- for ref_type in ref_types
478
- ]
479
- cost_types = [
480
- (f"{ref_type.lower()}_cost", _(ref_type.replace("_", " ").title()))
481
- for ref_type in ref_types
482
- ]
483
- amounts["income_types"] = income_types
484
- amounts["cost_types"] = cost_types
485
- return amounts
486
-
487
- def _create_corporation_details(self, entity: LedgerEntity) -> dict:
488
- """Create the corporation amounts for the Information View."""
489
- # NOTE (can only used if setup_ledger is defined in the subclass)
490
- self.setup_ledger(entity=entity) # pylint: disable=no-member
491
-
492
- income_types = [
493
- ("bounty_income", _("Bounty")),
494
- ("ess_income", _("Encounter Surveillance System")),
495
- ]
496
- amounts = self._generate_amounts(income_types=income_types, entity=entity)
497
- return amounts
498
-
499
- # pylint: disable=no-member
500
- def _create_character_details(self) -> dict:
501
- """
502
- Create the character amounts for the Information View.
503
- Only work with CharacterData Class
504
- """
505
- if not self.character:
506
- raise ValueError("No Character Data found.")
507
-
508
- # NOTE (can only used if setup_ledger is defined in the subclass)
509
- self.setup_ledger(self.character)
510
-
511
- entity = LedgerEntity(
512
- entity_id=self.character.eve_character.character_id,
513
- character_obj=self.character.eve_character,
514
- )
515
-
516
- income_types = [
517
- ("bounty_income", _("Bounty")),
518
- ("ess_income", _("Encounter Surveillance System")),
519
- ("mining_income", _("Mining")),
520
- ]
521
- amounts = self._generate_amounts(
522
- income_types=income_types,
523
- entity=entity,
524
- is_old_ess=self.is_old_ess,
525
- char_ids=self.alts_ids,
526
- )
527
- return amounts
528
-
529
- def _add_average_details(self, request, amounts, day: int = None):
530
- """Add average details to the amounts dictionary, skipping if no data or total is 0."""
531
- if amounts is None:
532
- return None
533
-
534
- avg = day if day else timezone.now().day
535
- if request.GET.get("all", False):
536
- avg = 365
537
-
538
- for key in amounts:
539
- if (
540
- isinstance(amounts[key], dict)
541
- and "total_amount" in amounts[key]
542
- and amounts[key]["total_amount"] not in (None, 0, 0.0, Decimal(0))
543
- ):
544
- total = amounts[key]["total_amount"]
545
- amounts[key]["average_day"] = total / avg
546
- amounts[key]["average_hour"] = total / avg / 24
547
- amounts[key]["average_tick"] = total / 20
548
- amounts[key]["current_day_tick"] = (
549
- amounts[key].get("total_amount_day", 0) / 20
550
- )
551
- amounts[key]["average_day_tick"] = total / avg / 20
552
- amounts[key]["average_hour_tick"] = total / avg / 24 / 20
553
- return amounts
554
-
555
- def _build_xy_chart(self, title: str):
556
- """Build the XY chart for the billboard."""
557
- if not self.billboard.results:
558
- return
559
-
560
- xy_data, categories = self.billboard.generate_xy_series()
561
- self.billboard.create_xy_chart(
562
- title=title,
563
- categories=categories,
564
- series=xy_data,
565
- )
1
+ """
2
+ Core View Helper
3
+ """
4
+
5
+ # Standard Library
6
+ from decimal import Decimal
7
+ from hashlib import md5
8
+
9
+ # Django
10
+ from django.db.models import Q, QuerySet, Sum
11
+ from django.urls import reverse
12
+ from django.utils import timezone
13
+ from django.utils.safestring import mark_safe
14
+ from django.utils.translation import gettext as _
15
+
16
+ # Alliance Auth
17
+ from allianceauth.authentication.models import CharacterOwnership, UserProfile
18
+ from allianceauth.eveonline.models import (
19
+ EveAllianceInfo,
20
+ EveCharacter,
21
+ EveCorporationInfo,
22
+ )
23
+ from allianceauth.services.hooks import get_extension_logger
24
+
25
+ # Alliance Auth (External Libs)
26
+ from app_utils.logging import LoggerAddTag
27
+
28
+ # AA Ledger
29
+ from ledger import __title__
30
+ from ledger.api.api_helper.billboard_helper import BillboardSystem
31
+ from ledger.app_settings import LEDGER_CACHE_KEY
32
+ from ledger.helpers.ref_type import RefTypeManager
33
+ from ledger.models.general import EveEntity
34
+
35
+ logger = LoggerAddTag(get_extension_logger(__name__), __title__)
36
+
37
+
38
+ def add_info_to_context(request, context: dict) -> dict:
39
+ """Add additional information to the context for the view."""
40
+ # pylint: disable=import-outside-toplevel
41
+ # AA Ledger
42
+ from ledger.models.characteraudit import CharacterAudit
43
+
44
+ total_issues = (
45
+ CharacterAudit.objects.annotate_total_update_status_user(user=request.user)
46
+ .aggregate(total_failed=Sum("num_sections_failed"))
47
+ .get("total_failed", 0)
48
+ )
49
+
50
+ new_context = {
51
+ **{
52
+ "issues": total_issues,
53
+ },
54
+ **context,
55
+ }
56
+ return new_context
57
+
58
+
59
+ class DummyEveEntity:
60
+ """Dummy Eve Entity class for fallback when no entity is found."""
61
+
62
+ def __init__(self, entity_id, entity_name="Unknown"):
63
+ self.entity_id = entity_id
64
+ self.entity_name = entity_name
65
+ self.type = "character"
66
+
67
+
68
+ class LedgerEntity:
69
+ """Class to hold character or corporation data for the ledger."""
70
+
71
+ # pylint: disable=too-many-positional-arguments
72
+ def __init__(
73
+ self,
74
+ entity_id,
75
+ character_obj: EveCharacter = None,
76
+ corporation_obj: EveCorporationInfo = None,
77
+ alliance_obj: EveAllianceInfo = None,
78
+ details_url=None,
79
+ ):
80
+ self.type = "character"
81
+ self.entity = None
82
+ self.entity_id = entity_id
83
+ self.entity_name = None
84
+ self.details_url = details_url
85
+ if character_obj and hasattr(character_obj, "character_id"):
86
+ self.entity = character_obj
87
+ self.entity_id = character_obj.character_id
88
+ self.entity_name = character_obj.character_name
89
+ elif corporation_obj and hasattr(corporation_obj, "corporation_id"):
90
+ self.entity = corporation_obj
91
+ self.entity_id = corporation_obj.corporation_id
92
+ self.entity_name = corporation_obj.corporation_name
93
+ self.type = "corporation"
94
+ elif alliance_obj and hasattr(alliance_obj, "alliance_id"):
95
+ self.entity = alliance_obj
96
+ self.entity_id = alliance_obj.alliance_id
97
+ self.entity_name = alliance_obj.alliance_name
98
+ self.type = "alliance"
99
+ else:
100
+ try:
101
+ entity_obj = EveEntity.objects.get(eve_id=entity_id)
102
+ self.entity = entity_obj
103
+ self.entity_id = entity_obj.eve_id
104
+ self.entity_name = entity_obj.name
105
+ self.type = entity_obj.category
106
+ except EveEntity.DoesNotExist:
107
+ self.entity = DummyEveEntity(entity_id, "Unknown")
108
+ self.entity_id = entity_id
109
+ self.entity_name = "Unknown"
110
+
111
+ @property
112
+ def is_eve_character(self):
113
+ """Check if the entity is an Eve Character."""
114
+ return isinstance(self.entity, EveCharacter)
115
+
116
+ @property
117
+ def is_eve_corporation(self):
118
+ """Check if the entity is an Eve Corporation."""
119
+ return isinstance(self.entity, EveCorporationInfo)
120
+
121
+ @property
122
+ def is_eve_entity(self):
123
+ """Check if the entity is an Eve Entity."""
124
+ return isinstance(self.entity, EveEntity)
125
+
126
+ @property
127
+ def alts(self) -> QuerySet[EveCharacter]:
128
+ """Get all alts for this character."""
129
+ if not isinstance(self.entity, EveCharacter):
130
+ raise ValueError("Entity is not an EveCharacter.")
131
+ alts = EveCharacter.objects.filter(
132
+ character_ownership__user=self.entity.character_ownership.user
133
+ ).select_related(
134
+ "character_ownership",
135
+ )
136
+ return alts
137
+
138
+ def portrait_url(self):
139
+ """Return the portrait URL for the entity."""
140
+ try:
141
+ if isinstance(self.entity, EveCorporationInfo):
142
+ return self.entity.logo_url_32
143
+
144
+ if hasattr(self.entity, "portrait_url"):
145
+ return self.entity.portrait_url(size=32)
146
+
147
+ if self.entity.category == "faction":
148
+ return ""
149
+ return self.entity.icon_url(size=32)
150
+ except Exception as e: # pylint: disable=broad-except
151
+ logger.error(f"Error getting portrait URL for {self.entity_id}: {e}")
152
+ return ""
153
+
154
+ def add_details_url(self, details_url):
155
+ """Set the details URL for the entity."""
156
+ self.details_url = details_url
157
+
158
+ def get_alts_ids_or_self(self):
159
+ """Return the IDs of all alternative characters or the character ID itself."""
160
+ try:
161
+ character = EveCharacter.objects.get(character_id=self.entity_id)
162
+ if hasattr(character, "character_ownership"):
163
+ alt_ids = character.character_ownership.user.character_ownerships.all().values_list(
164
+ "character__character_id", flat=True
165
+ )
166
+ return list(alt_ids)
167
+ except (
168
+ EveCharacter.DoesNotExist,
169
+ AttributeError,
170
+ CharacterOwnership.DoesNotExist,
171
+ ):
172
+ pass
173
+ return [self.entity_id]
174
+
175
+ @property
176
+ def create_button(self):
177
+ """Generate the URL for character details."""
178
+ title = _("View Details")
179
+ button_html = f"<button class='btn btn-primary btn-sm btn-square' data-bs-toggle='modal' data-bs-target='#modalViewCharacterContainer' data-ajax_url='{self.details_url}' title='{title}' data-tooltip-toggle='ledger-tooltip'><span class='fas fa-info'></span></button>"
180
+ return mark_safe(button_html)
181
+
182
+
183
+ class LedgerCore:
184
+ """Core View Helper for Ledger."""
185
+
186
+ def __init__(self, year=None, month=None, day=None):
187
+ self.date_info = {"year": year, "month": month, "day": day}
188
+ self.ledger_type = "ledger"
189
+
190
+ # If all are None, default to 'month' view
191
+ if year is None and month is None and day is None:
192
+ self.view = "month"
193
+ else:
194
+ self.view = "day" if day else "month" if month else "year"
195
+
196
+ self.journal = None
197
+ self.mining = None
198
+ self.billboard = BillboardSystem(self.view)
199
+
200
+ @property
201
+ def year(self):
202
+ return self.date_info["year"]
203
+
204
+ @property
205
+ def month(self):
206
+ return self.date_info["month"]
207
+
208
+ @property
209
+ def day(self):
210
+ return self.date_info["day"]
211
+
212
+ @property
213
+ def filter_date(self):
214
+ """
215
+ Generate a date filter for the ledger based on year, month, and day.
216
+ Returns:
217
+ Q: A Django Q object representing the date filter.
218
+ """
219
+ now = timezone.now()
220
+ # If all are None, use current year and month
221
+ if self.year is None and self.month is None and self.day is None:
222
+ filter_date = Q(date__year=now.year) & Q(date__month=now.month)
223
+ else:
224
+ filter_date = (
225
+ Q(date__year=self.year) if self.year else Q(date__year=now.year)
226
+ )
227
+ if self.month:
228
+ filter_date &= Q(date__month=self.month)
229
+ if self.day:
230
+ filter_date &= Q(date__day=self.day)
231
+ return filter_date
232
+
233
+ @property
234
+ def get_details_title(self):
235
+ """
236
+ Generate a title for the details view based on the date information.
237
+
238
+ Returns:
239
+ str: A formatted string representing the date or a default title.
240
+ """
241
+ if self.year and self.month and self.day:
242
+ return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
243
+ if self.year and self.month:
244
+ return f"{self.year:04d}-{self.month:02d}"
245
+ if self.year:
246
+ return f"{self.year:04d}"
247
+ return "Character Ledger Details"
248
+
249
+ @property
250
+ def auth_accounts(self):
251
+ """Get all user accounts with a main character."""
252
+ return (
253
+ UserProfile.objects.filter(
254
+ main_character__isnull=False,
255
+ )
256
+ .prefetch_related(
257
+ "user__profile__main_character",
258
+ )
259
+ .order_by(
260
+ "user__profile__main_character__character_name",
261
+ )
262
+ )
263
+
264
+ @property
265
+ def auth_character_ids(self) -> set:
266
+ """Get all account Character IDs from Alliance Auth."""
267
+ account_character_ids = set()
268
+ for account in self.auth_accounts:
269
+ alts = account.user.character_ownerships.all()
270
+ account_character_ids.update(
271
+ alts.values_list("character__character_id", flat=True)
272
+ )
273
+ return account_character_ids
274
+
275
+ def _get_ledger_journal_hash(self, journal: list[str]) -> str:
276
+ """Generate a hash for the ledger journal."""
277
+ return md5(",".join(str(x) for x in sorted(journal)).encode()).hexdigest()
278
+
279
+ def _get_ledger_hash(self, header_key: str) -> str:
280
+ """Generate a hash for the ledger journal."""
281
+ return md5(header_key.encode()).hexdigest()
282
+
283
+ def _get_ledger_header(
284
+ self, ledger_args: str, year: int, month: int, day: int
285
+ ) -> str:
286
+ """Generate a header string for the ledger."""
287
+ return f"{ledger_args}_{year}_{month}_{day}"
288
+
289
+ def _build_ledger_cache_key(self, header_key: str) -> str:
290
+ """Build a cache key for the ledger."""
291
+ return f"{LEDGER_CACHE_KEY}-{self._get_ledger_hash(header_key)}"
292
+
293
+ def _calculate_totals(self, ledger) -> dict:
294
+ """
295
+ Calculate the total amounts for each category in the ledger.
296
+
297
+ Args:
298
+ ledger (list or dict): The ledger data to calculate totals from.
299
+
300
+ Returns:
301
+ dict: A dictionary containing the totals for each category.
302
+ """
303
+ totals = {
304
+ "bounty": Decimal(0),
305
+ "ess": Decimal(0),
306
+ "costs": Decimal(0),
307
+ "mining": Decimal(0),
308
+ "miscellaneous": Decimal(0),
309
+ "total": Decimal(0),
310
+ }
311
+
312
+ if not ledger:
313
+ return totals
314
+
315
+ if isinstance(ledger, dict):
316
+ ledger = [ledger]
317
+
318
+ for total in ledger:
319
+
320
+ if total is None:
321
+ continue
322
+
323
+ totals["bounty"] += total["ledger"].get("bounty", 0)
324
+ totals["ess"] += total["ledger"].get("ess", 0)
325
+ totals["costs"] += total["ledger"].get("costs", 0)
326
+ totals["mining"] += total["ledger"].get("mining", 0)
327
+ totals["miscellaneous"] += total["ledger"].get("miscellaneous", 0)
328
+ totals["total"] += total["ledger"].get("total", 0)
329
+ return totals
330
+
331
+ def create_url(self, viewname: str, **kwargs):
332
+ """
333
+ Create a URL for the given view and entity using kwargs.
334
+ Args:
335
+ viewname: The name of the view to create the URL for.
336
+ kwargs: All needed parameters for the URL (e.g. character_id, corporation_id, etc.)
337
+ Returns:
338
+ A URL string for the specified view.
339
+ """
340
+ if self.year and self.month and self.day:
341
+ return reverse(
342
+ f"ledger:{viewname}",
343
+ kwargs={
344
+ **kwargs,
345
+ "year": self.year,
346
+ "month": self.month,
347
+ "day": self.day,
348
+ },
349
+ )
350
+ if self.year and self.month:
351
+ return reverse(
352
+ f"ledger:{viewname}",
353
+ kwargs={
354
+ **kwargs,
355
+ "year": self.year,
356
+ "month": self.month,
357
+ },
358
+ )
359
+ if self.year:
360
+ return reverse(
361
+ f"ledger:{viewname}",
362
+ kwargs={**kwargs, "year": self.year},
363
+ )
364
+ return reverse(
365
+ f"ledger:{viewname}",
366
+ kwargs={
367
+ **kwargs,
368
+ "year": timezone.now().year,
369
+ "month": timezone.now().month,
370
+ },
371
+ )
372
+
373
+ def create_view_data(self, viewname: str, **kwargs) -> dict:
374
+ """
375
+ Create view data for the ledger using kwargs.
376
+ Args:
377
+ viewname (str): The name of the view to create the URL for.
378
+ kwargs: All needed parameters for the URL (e.g. character_id, corporation_id, etc.)
379
+ Returns:
380
+ dict: A dictionary containing the type, date, and details URL.
381
+ """
382
+ return {
383
+ "type": self.ledger_type,
384
+ "date": {
385
+ "current": {
386
+ "year": timezone.now().year,
387
+ "month": timezone.now().month,
388
+ "day": timezone.now().day,
389
+ },
390
+ "year": self.year,
391
+ "month": self.month,
392
+ "day": self.day,
393
+ },
394
+ "details_url": self.create_url(
395
+ viewname=viewname,
396
+ **kwargs,
397
+ ),
398
+ }
399
+
400
+ # pylint: disable=too-many-locals
401
+ def _generate_amounts(
402
+ self,
403
+ income_types: list,
404
+ entity: LedgerEntity,
405
+ is_old_ess: bool = False,
406
+ char_ids: list = None,
407
+ ) -> dict:
408
+ """Generate amounts for the entity based on income types and reference types."""
409
+ amounts = {}
410
+
411
+ ref_types = RefTypeManager.get_all_categories()
412
+
413
+ # Bounty Income
414
+ if not entity.entity_id == 1000125: # Remove Concord Bountys
415
+ bounty_income = self.journal.aggregate_bounty()
416
+ if bounty_income > 0:
417
+ amounts["bounty_income"] = {
418
+ "total_amount": bounty_income,
419
+ "ref_types": ["bounty_prizes"],
420
+ }
421
+
422
+ if isinstance(entity.entity, EveCharacter):
423
+ # Mining Income
424
+ mining_income = self.mining.aggregate_mining()
425
+ if mining_income > 0:
426
+ amounts["mining_income"] = {
427
+ "total_amount": mining_income,
428
+ "ref_types": ["mining"],
429
+ }
430
+
431
+ # ESS Income (nur wenn bounty_income existiert)
432
+ ess_income = (
433
+ bounty_income * Decimal(0.667)
434
+ if is_old_ess and bounty_income
435
+ else self.journal.aggregate_ess()
436
+ )
437
+ if ess_income > 0:
438
+ amounts["ess_income"] = {
439
+ "total_amount": ess_income,
440
+ "ref_types": ["ess_escrow_transfer"],
441
+ }
442
+
443
+ # Income/Cost Ref Types (DRY)
444
+ for ref_type, value in ref_types.items():
445
+ ref_type_name = ref_type.lower()
446
+ for kind, income_flag in (("income", True), ("cost", False)):
447
+ kwargs = {"ref_type": value, "income": income_flag}
448
+ kwargs = RefTypeManager.special_cases_details(
449
+ value, entity, kwargs, journal_type=entity.type, char_ids=char_ids
450
+ )
451
+ agg = self.journal.aggregate_ref_type(**kwargs)
452
+ if (income_flag and agg > 0) or (not income_flag and agg < 0):
453
+ amounts[f"{ref_type_name}_{kind}"] = {
454
+ "total_amount": agg,
455
+ "ref_types": value,
456
+ }
457
+
458
+ # Summary (exclude mining_income)
459
+ summary = sum(
460
+ amount["total_amount"]
461
+ for key, amount in amounts.items()
462
+ if key != "mining_income"
463
+ and isinstance(amount, dict)
464
+ and "total_amount" in amount
465
+ )
466
+
467
+ if summary == 0:
468
+ return None
469
+
470
+ amounts["summary"] = {
471
+ "total_amount": summary,
472
+ }
473
+
474
+ # Dynamische Income/Cost-Typen für das Template
475
+ income_types += [
476
+ (f"{ref_type.lower()}_income", _(ref_type.replace("_", " ").title()))
477
+ for ref_type in ref_types
478
+ ]
479
+ cost_types = [
480
+ (f"{ref_type.lower()}_cost", _(ref_type.replace("_", " ").title()))
481
+ for ref_type in ref_types
482
+ ]
483
+ amounts["income_types"] = income_types
484
+ amounts["cost_types"] = cost_types
485
+ return amounts
486
+
487
+ def _create_corporation_details(self, entity: LedgerEntity) -> dict:
488
+ """Create the corporation amounts for the Information View."""
489
+ # NOTE (can only used if setup_ledger is defined in the subclass)
490
+ self.setup_ledger(entity=entity) # pylint: disable=no-member
491
+
492
+ income_types = [
493
+ ("bounty_income", _("Bounty")),
494
+ ("ess_income", _("Encounter Surveillance System")),
495
+ ]
496
+ amounts = self._generate_amounts(income_types=income_types, entity=entity)
497
+ return amounts
498
+
499
+ # pylint: disable=no-member
500
+ def _create_character_details(self) -> dict:
501
+ """
502
+ Create the character amounts for the Information View.
503
+ Only work with CharacterData Class
504
+ """
505
+ if not self.character:
506
+ raise ValueError("No Character Data found.")
507
+
508
+ # NOTE (can only used if setup_ledger is defined in the subclass)
509
+ self.setup_ledger(self.character)
510
+
511
+ entity = LedgerEntity(
512
+ entity_id=self.character.eve_character.character_id,
513
+ character_obj=self.character.eve_character,
514
+ )
515
+
516
+ income_types = [
517
+ ("bounty_income", _("Bounty")),
518
+ ("ess_income", _("Encounter Surveillance System")),
519
+ ("mining_income", _("Mining")),
520
+ ]
521
+ amounts = self._generate_amounts(
522
+ income_types=income_types,
523
+ entity=entity,
524
+ is_old_ess=self.is_old_ess,
525
+ char_ids=self.alts_ids,
526
+ )
527
+ return amounts
528
+
529
+ def _add_average_details(self, request, amounts, day: int = None):
530
+ """Add average details to the amounts dictionary, skipping if no data or total is 0."""
531
+ if amounts is None:
532
+ return None
533
+
534
+ avg = day if day else timezone.now().day
535
+ if request.GET.get("all", False):
536
+ avg = 365
537
+
538
+ for key in amounts:
539
+ if (
540
+ isinstance(amounts[key], dict)
541
+ and "total_amount" in amounts[key]
542
+ and amounts[key]["total_amount"] not in (None, 0, 0.0, Decimal(0))
543
+ ):
544
+ total = amounts[key]["total_amount"]
545
+ amounts[key]["average_day"] = total / avg
546
+ amounts[key]["average_hour"] = total / avg / 24
547
+ amounts[key]["average_tick"] = total / 20
548
+ amounts[key]["current_day_tick"] = (
549
+ amounts[key].get("total_amount_day", 0) / 20
550
+ )
551
+ amounts[key]["average_day_tick"] = total / avg / 20
552
+ amounts[key]["average_hour_tick"] = total / avg / 24 / 20
553
+ return amounts
554
+
555
+ def _build_xy_chart(self, title: str):
556
+ """Build the XY chart for the billboard."""
557
+ if not self.billboard.results:
558
+ return
559
+
560
+ xy_data, categories = self.billboard.generate_xy_series()
561
+ self.billboard.create_xy_chart(
562
+ title=title,
563
+ categories=categories,
564
+ series=xy_data,
565
+ )