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