aa-fatanalysis 0.1.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.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: aa-fatanalysis
3
+ Version: 0.1.0
4
+ Summary: FAT Analysis Plugin for Alliance Auth
5
+ Author-email: Maddog Broekman <maddogbroekman@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Maddog Broekman
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: allianceauth,fatanalysis
29
+ Classifier: Environment :: Web Environment
30
+ Classifier: Framework :: Django
31
+ Classifier: Framework :: Django :: 5.2
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python
36
+ Classifier: Programming Language :: Python :: 3 :: Only
37
+ Classifier: Topic :: Internet :: WWW/HTTP
38
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
39
+ Requires-Python: <3.14,>=3.10
40
+ Requires-Dist: allianceauth>=5
41
+ Description-Content-Type: text/markdown
42
+
43
+ # FAT Analysis
44
+
45
+ Alliance Auth plugin for analyzing FATs (Fleet Activity Tracking). This plugin allows for the upload of 30-day and 90-day FAT CSV files, parses them dynamically, and provides overviews and interactive charts on both a per-character and per-corporation basis.
46
+
47
+ ## Installation
48
+
49
+ 1. **Activate your Alliance Auth virtual environment**
50
+ ```bash
51
+ source /home/allianceserver/venv/auth/bin/activate
52
+ ```
53
+
54
+ 2. **Install the plugin**
55
+ Navigate to the directory containing this plugin and run:
56
+ ```bash
57
+ pip install -e .
58
+ ```
59
+ *(Or install via git / pip once published to a repository)*
60
+
61
+ 3. **Update your Alliance Auth settings**
62
+ Add `'fatanalysis'` to your `INSTALLED_APPS` in `myauth/myauth/settings/local.py`:
63
+ ```python
64
+ INSTALLED_APPS += [
65
+ 'fatanalysis',
66
+ ]
67
+ ```
68
+
69
+ 4. **Run migrations**
70
+ ```bash
71
+ python manage.py makemigrations
72
+ python manage.py migrate
73
+ ```
74
+
75
+ 5. **Restart Alliance Auth services**
76
+ ```bash
77
+ sudo systemctl restart supervisor
78
+ ```
79
+ *(Or restart gunicorn and celery according to your specific setup)*
80
+
81
+ ## Permissions
82
+ Assign the `fatanalysis.basic_access` permission in the admin panel to any user or group that should have access to view and upload FAT analysis data.
@@ -0,0 +1,24 @@
1
+ fatanalysis/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ fatanalysis/apps.py,sha256=KWWdeiQvY4fT68_uSH0nnms2yoq-tRxw5pe9a_yKZaY,156
3
+ fatanalysis/auth_hooks.py,sha256=LOzksVwH4AcsBjLNwhQU9HJzEfXCasPlLW5hXumu90s,912
4
+ fatanalysis/forms.py,sha256=dJHghi0VUaF8kK752poLOWAqzE3P4HiZM5lhttqXjdg,586
5
+ fatanalysis/models.py,sha256=wmbXla9zm32sH53Iv7gkSfBzfPg0iUCL5hK9w-rVCas,1114
6
+ fatanalysis/urls.py,sha256=LLBv3OhFYHpR2aSVHY5CDHao9mD_0qiXk4ctKOY8JyE,654
7
+ fatanalysis/views.py,sha256=hSQ8DgoIyxT0EMUfZ-BEFNhuTgJRbjEnZp55AgXYe-4,14433
8
+ fatanalysis/migrations/0001_initial.py,sha256=tg_VFhVcRwBenv77D0e4QWEBFNKKv9a0Hex0WEG70PY,1508
9
+ fatanalysis/migrations/0002_alter_uploadperiod_upload_date.py,sha256=NaNY4vtLKttvD0JKre0tLILd45dQgiW_GBWZNC7y3JY,444
10
+ fatanalysis/migrations/__init__.py,sha256=YqBpMx21tXZquHbEpYhi5ZhuIYcvG3SWJ6ZL9eVU6-c,20
11
+ fatanalysis/templates/fatanalysis/base.html,sha256=l_sAAOYg_c0nkPFm7S2av3k5pwKTiOU96ghEaHkI2nw,584
12
+ fatanalysis/templates/fatanalysis/import_csv.html,sha256=xGt8OQM86zPaDG3sv_iTrsu9L1F3CYdrvq2Zwpsltek,866
13
+ fatanalysis/templates/fatanalysis/index.html,sha256=4JziR2JMjmxKV4MOLm3qMaMdS9Z272LyPUnLcxxhc8U,3555
14
+ fatanalysis/templates/fatanalysis/overview.html,sha256=WJOTq2mvSfSzJ_5Ytnh3DVt5UyF6jnZpNxVCrwRr0yk,5440
15
+ fatanalysis/templates/fatanalysis/period_delete.html,sha256=oAeo5XPgqK2dwcILPcGEEIf6UWqgB-0XJc5mPUUoYF8,1203
16
+ fatanalysis/templates/fatanalysis/period_detail.html,sha256=nzW-lzLUUjb14HoYSeZeezGspZSGqR54xPP0gqW3DHs,3053
17
+ fatanalysis/templates/fatanalysis/period_list.html,sha256=pGedOZaBSIHX_EIdjjhrBA1_ToAHUy8zZovdz-FgYvs,2529
18
+ fatanalysis/templates/fatanalysis/player_detail.html,sha256=GKzuqx0iVM2oe-wS75rCj8KMyk9U74_OvAk4UDLa9ik,5491
19
+ fatanalysis/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ fatanalysis/tests/test_settings.py,sha256=gTRcJjt7e6rVas-JzCx4I1vm08ptLitH5NEdY7EkyC8,587
21
+ aa_fatanalysis-0.1.0.dist-info/METADATA,sha256=OmjqR8NqfGRcMlCg9UJjiphOX2KSXokbxumnHRwPICQ,3264
22
+ aa_fatanalysis-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
23
+ aa_fatanalysis-0.1.0.dist-info/licenses/LICENSE,sha256=VpXurLL80VG0dI9a9tuQl8t4iSOxm_6dbRscbI9LVdY,1072
24
+ aa_fatanalysis-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maddog Broekman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
fatanalysis/apps.py ADDED
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+ class FatAnalysisConfig(AppConfig):
4
+ name = 'fatanalysis'
5
+ label = 'fatanalysis'
6
+ verbose_name = 'FAT Analysis'
@@ -0,0 +1,30 @@
1
+ from allianceauth import hooks
2
+ from allianceauth.services.hooks import MenuItemHook, UrlHook
3
+ from django.utils.translation import gettext_lazy as _
4
+
5
+ from . import urls
6
+
7
+ class FatAnalysisMenuItem(MenuItemHook):
8
+ """This class ensures only authorized users will see the menu entry"""
9
+ def __init__(self):
10
+ # Setup menu entry for sidebar
11
+ MenuItemHook.__init__(
12
+ self,
13
+ _('FAT Analysis'),
14
+ 'fas fa-chart-line fa-fw',
15
+ 'fatanalysis:index',
16
+ navactive=['fatanalysis:']
17
+ )
18
+
19
+ def render(self, request):
20
+ if request.user.has_perm('fatanalysis.basic_access'):
21
+ return MenuItemHook.render(self, request)
22
+ return ''
23
+
24
+ @hooks.register('menu_item_hook')
25
+ def register_menu():
26
+ return FatAnalysisMenuItem()
27
+
28
+ @hooks.register('url_hook')
29
+ def register_urls():
30
+ return UrlHook(urls, 'fatanalysis', r'^fatanalysis/')
fatanalysis/forms.py ADDED
@@ -0,0 +1,15 @@
1
+ from django import forms
2
+
3
+ class CsvImportForm(forms.Form):
4
+ PERIOD_CHOICES = (
5
+ (30, '30 Days'),
6
+ (90, '90 Days'),
7
+ )
8
+ csv_file = forms.FileField(label='FAT CSV File')
9
+ period_type = forms.ChoiceField(choices=PERIOD_CHOICES, label='Period Type')
10
+ custom_date = forms.DateTimeField(
11
+ required=False,
12
+ label='Custom Date (Optional)',
13
+ help_text='Leave empty to use current date and time. Use format YYYY-MM-DD HH:MM if your browser does not show a date picker.',
14
+ widget=forms.DateTimeInput(attrs={'type': 'datetime-local'})
15
+ )
@@ -0,0 +1,36 @@
1
+ # Generated by Django 5.2.16 on 2026-07-31 17:05
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('eveonline', '0025_remove_evecharacter_last_updated_and_more'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='UploadPeriod',
18
+ fields=[
19
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+ ('upload_date', models.DateTimeField(auto_now_add=True)),
21
+ ('uploader', models.CharField(max_length=255)),
22
+ ('period_type', models.IntegerField(choices=[(30, '30 Days'), (90, '90 Days')])),
23
+ ],
24
+ ),
25
+ migrations.CreateModel(
26
+ name='FatEntry',
27
+ fields=[
28
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29
+ ('character_name', models.CharField(max_length=255)),
30
+ ('total_fats', models.IntegerField(default=0)),
31
+ ('fat_details', models.JSONField(blank=True, default=dict)),
32
+ ('character', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='eveonline.evecharacter')),
33
+ ('period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fat_entries', to='fatanalysis.uploadperiod')),
34
+ ],
35
+ ),
36
+ ]
@@ -0,0 +1,19 @@
1
+ # Generated by Django 5.2.15 on 2026-07-31 17:31
2
+
3
+ import django.utils.timezone
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('fatanalysis', '0001_initial'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterField(
15
+ model_name='uploadperiod',
16
+ name='upload_date',
17
+ field=models.DateTimeField(default=django.utils.timezone.now),
18
+ ),
19
+ ]
@@ -0,0 +1 @@
1
+ # Migrations module
fatanalysis/models.py ADDED
@@ -0,0 +1,27 @@
1
+ from django.db import models
2
+ from allianceauth.eveonline.models import EveCharacter
3
+ from django.utils import timezone
4
+
5
+ class UploadPeriod(models.Model):
6
+ PERIOD_CHOICES = (
7
+ (30, '30 Days'),
8
+ (90, '90 Days'),
9
+ )
10
+ upload_date = models.DateTimeField(default=timezone.now)
11
+ uploader = models.CharField(max_length=255)
12
+ period_type = models.IntegerField(choices=PERIOD_CHOICES)
13
+
14
+ def __str__(self):
15
+ return f"{self.get_period_type_display()} Upload ({self.upload_date.strftime('%Y-%m-%d %H:%M')})"
16
+
17
+ class FatEntry(models.Model):
18
+ period = models.ForeignKey(UploadPeriod, on_delete=models.CASCADE, related_name='fat_entries')
19
+ character = models.ForeignKey(EveCharacter, on_delete=models.SET_NULL, null=True, blank=True)
20
+ character_name = models.CharField(max_length=255)
21
+ total_fats = models.IntegerField(default=0)
22
+
23
+ # Stores dynamic fleet types (e.g. Strategic, QRF, Astartes) without hardcoding columns
24
+ fat_details = models.JSONField(default=dict, blank=True)
25
+
26
+ def __str__(self):
27
+ return f"{self.character_name} - {self.total_fats} FATs"
@@ -0,0 +1,21 @@
1
+ {% extends "allianceauth/base-bs5.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block page_title %}{% trans "FAT Analysis" %}{% endblock page_title %}
5
+
6
+ {% block content %}
7
+ <div class="row">
8
+ <div class="col-md-12">
9
+ <h1 class="page-header text-center">{% trans "FAT Analysis Dashboard" %}</h1>
10
+ {% block fatanalysis_content %}{% endblock %}
11
+ </div>
12
+ </div>
13
+ {% endblock %}
14
+
15
+ {% block extra_css %}
16
+ {% include 'bundles/datatables-css-bs5.html' %}
17
+ {% endblock %}
18
+
19
+ {% block extra_javascript %}
20
+ {% include 'bundles/datatables-js-bs5.html' %}
21
+ {% endblock %}
@@ -0,0 +1,20 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+ {% load django_bootstrap5 %}
4
+
5
+ {% block fatanalysis_content %}
6
+ <div class="panel panel-default">
7
+ <div class="panel-heading">
8
+ <h3 class="panel-title">{% trans "Import FAT CSV" %}</h3>
9
+ </div>
10
+ <div class="panel-body">
11
+ <p>{% trans "Upload your 30 or 90 days FAT CSV file here. The system will automatically parse all columns, including any dynamic fleet types." %}</p>
12
+ <form method="POST" enctype="multipart/form-data">
13
+ {% csrf_token %}
14
+ {% bootstrap_form form %}
15
+ <button type="submit" class="btn btn-primary">{% trans "Upload" %}</button>
16
+ <a href="{% url 'fatanalysis:index' %}" class="btn btn-default">{% trans "Cancel" %}</a>
17
+ </form>
18
+ </div>
19
+ </div>
20
+ {% endblock %}
@@ -0,0 +1,94 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <a href="{% url 'fatanalysis:period_list' %}" class="btn btn-default pull-right" style="margin-left: 10px;">
8
+ <i class="fas fa-list"></i> {% trans "Manage Periods" %}
9
+ </a>
10
+ <a href="{% url 'fatanalysis:import_csv' %}" class="btn btn-primary pull-right">
11
+ <i class="fas fa-upload"></i> {% trans "Import CSV" %}
12
+ </a>
13
+ </div>
14
+ </div>
15
+ <br>
16
+
17
+ <div class="panel panel-default">
18
+ <div class="panel-heading">
19
+ <h3 class="panel-title">{% trans "FAT Trend Over Time (Alliance Total)" %}</h3>
20
+ </div>
21
+ <div class="panel-body">
22
+ <canvas id="trendChart" height="100"></canvas>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="row">
27
+ <div class="col-md-6">
28
+ <div class="panel panel-default text-center">
29
+ <div class="panel-body">
30
+ <h3>{% trans "30 Days Dashboard" %}</h3>
31
+ <p>{% trans "View the latest 30 days FATs per character." %}</p>
32
+ <a href="{% url 'fatanalysis:overview_30' %}" class="btn btn-primary btn-lg">
33
+ <i class="fas fa-calendar-alt"></i> {% trans "30 Days Overview" %}
34
+ </a>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div class="col-md-6">
39
+ <div class="panel panel-default text-center">
40
+ <div class="panel-body">
41
+ <h3>{% trans "90 Days Dashboard" %}</h3>
42
+ <p>{% trans "View the latest 90 days FATs per character." %}</p>
43
+ <a href="{% url 'fatanalysis:overview_90' %}" class="btn btn-primary btn-lg">
44
+ <i class="fas fa-calendar-alt"></i> {% trans "90 Days Overview" %}
45
+ </a>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ {% endblock %}
51
+
52
+ {% block extra_javascript %}
53
+ {{ block.super }}
54
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
55
+ <script>
56
+ $(document).ready(function() {
57
+
58
+ // Trend Chart
59
+ const trendCtx = document.getElementById('trendChart').getContext('2d');
60
+ new Chart(trendCtx, {
61
+ type: 'bar',
62
+ data: {
63
+ labels: {{ trend_labels|safe }},
64
+ datasets: [
65
+ {
66
+ label: '{% trans "30 Days FATs" %}',
67
+ data: {{ trend_30|safe }},
68
+ borderColor: '#36A2EB',
69
+ backgroundColor: 'rgba(54, 162, 235, 0.2)',
70
+ tension: 0.1,
71
+ fill: true
72
+ },
73
+ {
74
+ label: '{% trans "90 Days FATs" %}',
75
+ data: {{ trend_90|safe }},
76
+ borderColor: '#FF6384',
77
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
78
+ tension: 0.1,
79
+ fill: true
80
+ }
81
+ ]
82
+ },
83
+ options: {
84
+ responsive: true,
85
+ scales: {
86
+ y: {
87
+ beginAtZero: true
88
+ }
89
+ }
90
+ }
91
+ });
92
+ });
93
+ </script>
94
+ {% endblock %}
@@ -0,0 +1,141 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <a href="{% url 'fatanalysis:index' %}" class="btn btn-default pull-right">
8
+ <i class="fas fa-arrow-left"></i> {% trans "Back to Dashboard" %}
9
+ </a>
10
+ <h2>{{ title }} <small>({{ period.upload_date|date:"Y-m-d"|default:"No uploads yet" }})</small></h2>
11
+ </div>
12
+ </div>
13
+ {% if period %}
14
+ <div class="row">
15
+ <div class="col-md-12">
16
+ <div class="panel panel-default">
17
+ <div class="panel-heading">
18
+ <h3 class="panel-title">{% trans "Historical FAT Trend" %} ({{ period_type }} {% trans "Days" %})</h3>
19
+ </div>
20
+ <div class="panel-body">
21
+ <canvas id="historicalTrendChart" height="100"></canvas>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="row">
28
+ <div class="col-md-4">
29
+ <div class="panel panel-default">
30
+ <div class="panel-heading">
31
+ <h3 class="panel-title">{% trans "Top 10 Players" %}</h3>
32
+ </div>
33
+ <div class="panel-body">
34
+ <canvas id="topPlayersChart"></canvas>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div class="col-md-8">
39
+ <div class="panel panel-default">
40
+ <div class="panel-heading">
41
+ <h3 class="panel-title">{% trans "All Players" %}</h3>
42
+ </div>
43
+ <div class="panel-body">
44
+ <table class="table table-striped table-hover" id="overview-table">
45
+ <thead>
46
+ <tr>
47
+ <th>{% trans "Character" %}</th>
48
+ <th>{% trans "Total FATs" %}</th>
49
+ {% for header in dynamic_headers %}
50
+ <th>{{ header }}</th>
51
+ {% endfor %}
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {% for entry in entries %}
56
+ <tr>
57
+ <td>
58
+ <a href="{% url 'fatanalysis:player_detail' character_name=entry.character_name period_type=period_type %}">
59
+ {{ entry.character_name }}
60
+ </a>
61
+ </td>
62
+ <td><strong>{{ entry.total_fats }}</strong></td>
63
+ {% for value in entry.dynamic_values %}
64
+ <td>{{ value }}</td>
65
+ {% endfor %}
66
+ </tr>
67
+ {% empty %}
68
+ <tr>
69
+ <td colspan="{{ dynamic_headers|length|add:2 }}" class="text-center">{% trans "No FAT data available." %}</td>
70
+ </tr>
71
+ {% endfor %}
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ {% else %}
79
+ <div class="alert alert-info">
80
+ {% trans "No uploads found for this period type yet." %}
81
+ </div>
82
+ {% endif %}
83
+ {% endblock %}
84
+
85
+ {% block extra_javascript %}
86
+ {{ block.super }}
87
+ {% if period %}
88
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
89
+ <script>
90
+ $(document).ready(function() {
91
+ $('#overview-table').DataTable({
92
+ "order": [[ 1, "desc" ]]
93
+ });
94
+
95
+ // Pie Chart for Top Players
96
+ const topCtx = document.getElementById('topPlayersChart').getContext('2d');
97
+ new Chart(topCtx, {
98
+ type: 'pie',
99
+ data: {
100
+ labels: {{ top_labels|safe }},
101
+ datasets: [{
102
+ data: {{ top_data|safe }},
103
+ backgroundColor: [
104
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
105
+ '#FF9F40', '#E7E9ED', '#8AC926', '#1982C4', '#6A4C93'
106
+ ]
107
+ }]
108
+ },
109
+ options: {
110
+ responsive: true
111
+ }
112
+ });
113
+
114
+ // Trend Chart
115
+ const trendCtx = document.getElementById('historicalTrendChart').getContext('2d');
116
+ new Chart(trendCtx, {
117
+ type: 'line',
118
+ data: {
119
+ labels: {{ trend_labels|safe }},
120
+ datasets: [{
121
+ label: '{{ period_type }} {% trans "Days FATs" %}',
122
+ data: {{ trend_data|safe }},
123
+ borderColor: '#36A2EB',
124
+ backgroundColor: 'rgba(54, 162, 235, 0.2)',
125
+ tension: 0.1,
126
+ fill: true
127
+ }]
128
+ },
129
+ options: {
130
+ responsive: true,
131
+ scales: {
132
+ y: {
133
+ beginAtZero: true
134
+ }
135
+ }
136
+ }
137
+ });
138
+ });
139
+ </script>
140
+ {% endif %}
141
+ {% endblock %}
@@ -0,0 +1,24 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-6 col-md-offset-3">
7
+ <div class="panel panel-danger">
8
+ <div class="panel-heading">
9
+ <h3 class="panel-title">{% trans "Delete Upload Period" %}</h3>
10
+ </div>
11
+ <div class="panel-body">
12
+ <p>{% trans "Are you sure you want to delete the FAT upload for" %} <strong>{{ period.get_period_type_display }}</strong> {% trans "on" %} <strong>{{ period.upload_date|date:"Y-m-d H:i" }}</strong>?</p>
13
+ <p class="text-danger">{% trans "This will also delete all associated FAT entries and data. This action cannot be undone." %}</p>
14
+
15
+ <form method="POST">
16
+ {% csrf_token %}
17
+ <button type="submit" class="btn btn-danger">{% trans "Yes, Delete" %}</button>
18
+ <a href="{% url 'fatanalysis:period_list' %}" class="btn btn-default">{% trans "Cancel" %}</a>
19
+ </form>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ {% endblock %}
@@ -0,0 +1,90 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <a href="{% url 'fatanalysis:index' %}" class="btn btn-default pull-right">
8
+ <i class="fas fa-arrow-left"></i> {% trans "Back to Overview" %}
9
+ </a>
10
+ <h2>{% trans "Period Details" %} - {{ period.get_period_type_display }} ({{ period.upload_date|date:"Y-m-d" }})</h2>
11
+ </div>
12
+ </div>
13
+ <br>
14
+
15
+ <div class="row">
16
+ <div class="col-md-12">
17
+ <div class="panel panel-default">
18
+ <div class="panel-heading">
19
+ <h3 class="panel-title">{% trans "Top 20 Players" %}</h3>
20
+ </div>
21
+ <div class="panel-body">
22
+ <canvas id="playerChart" width="400" height="100"></canvas>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="panel panel-default">
29
+ <div class="panel-heading">
30
+ <h3 class="panel-title">{% trans "All Entries" %}</h3>
31
+ </div>
32
+ <div class="panel-body">
33
+ <table class="table table-striped table-hover" id="entries-table">
34
+ <thead>
35
+ <tr>
36
+ <th>{% trans "Character" %}</th>
37
+ <th>{% trans "Total FATs" %}</th>
38
+ {% for header in dynamic_headers %}
39
+ <th>{{ header }}</th>
40
+ {% endfor %}
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {% for entry in entries %}
45
+ <tr>
46
+ <td>{{ entry.character_name }}</td>
47
+ <td>{{ entry.total_fats }}</td>
48
+ {% for val in entry.dynamic_values %}
49
+ <td>{{ val }}</td>
50
+ {% endfor %}
51
+ </tr>
52
+ {% endfor %}
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ </div>
57
+ {% endblock %}
58
+
59
+ {% block extra_javascript %}
60
+ {{ block.super }}
61
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
62
+ <script>
63
+ $(document).ready(function() {
64
+ $('#entries-table').DataTable({
65
+ "order": [[ 1, "desc" ]]
66
+ });
67
+
68
+ const playerCtx = document.getElementById('playerChart').getContext('2d');
69
+
70
+ new Chart(playerCtx, {
71
+ type: 'bar',
72
+ data: {
73
+ labels: {{ player_labels|safe }},
74
+ datasets: [{
75
+ label: '{% trans "FATs" %}',
76
+ data: {{ player_values|safe }},
77
+ backgroundColor: '#36A2EB'
78
+ }]
79
+ },
80
+ options: {
81
+ scales: {
82
+ y: {
83
+ beginAtZero: true
84
+ }
85
+ }
86
+ }
87
+ });
88
+ });
89
+ </script>
90
+ {% endblock %}
@@ -0,0 +1,66 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <a href="{% url 'fatanalysis:index' %}" class="btn btn-default pull-right">
8
+ <i class="fas fa-arrow-left"></i> {% trans "Back to Overview" %}
9
+ </a>
10
+ <h2>{% trans "Manage Uploaded Periods" %}</h2>
11
+ </div>
12
+ </div>
13
+ <br>
14
+
15
+ <div class="panel panel-default">
16
+ <div class="panel-heading">
17
+ <h3 class="panel-title">{% trans "Uploaded Periods" %}</h3>
18
+ </div>
19
+ <div class="panel-body">
20
+ <table class="table table-striped table-hover" id="periods-table">
21
+ <thead>
22
+ <tr>
23
+ <th>{% trans "Date" %}</th>
24
+ <th>{% trans "Type" %}</th>
25
+ <th>{% trans "Uploaded By" %}</th>
26
+ <th>{% trans "FAT Entries" %}</th>
27
+ <th>{% trans "Actions" %}</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ {% for period in periods %}
32
+ <tr>
33
+ <td>{{ period.upload_date|date:"Y-m-d H:i" }}</td>
34
+ <td>{{ period.get_period_type_display }}</td>
35
+ <td>{{ period.uploader }}</td>
36
+ <td>{{ period.fat_entries.count }}</td>
37
+ <td>
38
+ <a href="{% url 'fatanalysis:period_detail' period.id %}" class="btn btn-info btn-xs">
39
+ <i class="fas fa-eye"></i> {% trans "View" %}
40
+ </a>
41
+ <a href="{% url 'fatanalysis:period_delete' period.id %}" class="btn btn-danger btn-xs">
42
+ <i class="fas fa-trash"></i> {% trans "Delete" %}
43
+ </a>
44
+ </td>
45
+ </tr>
46
+ {% empty %}
47
+ <tr>
48
+ <td colspan="5" class="text-center">{% trans "No periods uploaded yet." %}</td>
49
+ </tr>
50
+ {% endfor %}
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ </div>
55
+ {% endblock %}
56
+
57
+ {% block extra_javascript %}
58
+ {{ block.super }}
59
+ <script>
60
+ $(document).ready(function() {
61
+ $('#periods-table').DataTable({
62
+ "order": [[ 0, "desc" ]]
63
+ });
64
+ });
65
+ </script>
66
+ {% endblock %}
@@ -0,0 +1,139 @@
1
+ {% extends "fatanalysis/base.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block fatanalysis_content %}
5
+ <div class="row">
6
+ <div class="col-md-12">
7
+ <h2>{{ title }} <small>({{ period_type }} {% trans "Days Data" %})</small></h2>
8
+ <a href="javascript:history.back()" class="btn btn-default pull-right"><i class="fas fa-arrow-left"></i> {% trans "Back" %}</a>
9
+ </div>
10
+ </div>
11
+ <br>
12
+
13
+ <div class="row">
14
+ <div class="col-md-12">
15
+ <div class="panel panel-default">
16
+ <div class="panel-heading">
17
+ <h3 class="panel-title">{% trans "Historical FAT Trend per Type" %}</h3>
18
+ </div>
19
+ <div class="panel-body">
20
+ <canvas id="playerTrendChart" height="100"></canvas>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="row">
27
+ <div class="col-md-4">
28
+ <div class="panel panel-default">
29
+ <div class="panel-heading">
30
+ <h3 class="panel-title">{% trans "Current FAT Breakdown" %} <small>({{ latest_period.upload_date|date:"Y-m-d" }})</small></h3>
31
+ </div>
32
+ <div class="panel-body">
33
+ {% if pie_data %}
34
+ <canvas id="playerPieChart"></canvas>
35
+ {% else %}
36
+ <p class="text-center text-muted">{% trans "No FATs in the latest period." %}</p>
37
+ {% endif %}
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <div class="col-md-8">
42
+ <div class="panel panel-default">
43
+ <div class="panel-heading">
44
+ <h3 class="panel-title">{% trans "Historical Raw Data" %}</h3>
45
+ </div>
46
+ <div class="panel-body">
47
+ <table class="table table-striped table-hover" id="player-table">
48
+ <thead>
49
+ <tr>
50
+ <th>{% trans "Upload Date" %}</th>
51
+ <th>{% trans "Total FATs" %}</th>
52
+ {% for header in dynamic_headers %}
53
+ <th>{{ header }}</th>
54
+ {% endfor %}
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {% for entry in entries reversed %}
59
+ <tr>
60
+ <td>{{ entry.period.upload_date|date:"Y-m-d H:i" }}</td>
61
+ <td><strong>{{ entry.total_fats }}</strong></td>
62
+ {% for header in dynamic_headers %}
63
+ {% for key, val in entry.fat_details.items %}
64
+ {% if key == header %}
65
+ <td>{{ val }}</td>
66
+ {% endif %}
67
+ {% endfor %}
68
+ {% if header not in entry.fat_details %}
69
+ <td>0</td>
70
+ {% endif %}
71
+ {% endfor %}
72
+ </tr>
73
+ {% empty %}
74
+ <tr>
75
+ <td colspan="{{ dynamic_headers|length|add:2 }}" class="text-center">{% trans "No FAT data available." %}</td>
76
+ </tr>
77
+ {% endfor %}
78
+ </tbody>
79
+ </table>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ {% endblock %}
85
+
86
+ {% block extra_javascript %}
87
+ {{ block.super }}
88
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
89
+ <script>
90
+ $(document).ready(function() {
91
+ $('#player-table').DataTable({
92
+ "order": [[ 0, "desc" ]]
93
+ });
94
+
95
+ // Trend Chart
96
+ const trendCtx = document.getElementById('playerTrendChart').getContext('2d');
97
+ new Chart(trendCtx, {
98
+ type: 'line',
99
+ data: {
100
+ labels: {{ trend_labels|safe }},
101
+ datasets: {{ trend_datasets|safe }}
102
+ },
103
+ options: {
104
+ responsive: true,
105
+ interaction: {
106
+ mode: 'index',
107
+ intersect: false,
108
+ },
109
+ scales: {
110
+ y: {
111
+ beginAtZero: true
112
+ }
113
+ }
114
+ }
115
+ });
116
+
117
+ // Pie Chart
118
+ {% if pie_data %}
119
+ const pieCtx = document.getElementById('playerPieChart').getContext('2d');
120
+ new Chart(pieCtx, {
121
+ type: 'pie',
122
+ data: {
123
+ labels: {{ pie_labels|safe }},
124
+ datasets: [{
125
+ data: {{ pie_data|safe }},
126
+ backgroundColor: [
127
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
128
+ '#FF9F40', '#E7E9ED', '#8AC926', '#1982C4', '#6A4C93'
129
+ ]
130
+ }]
131
+ },
132
+ options: {
133
+ responsive: true
134
+ }
135
+ });
136
+ {% endif %}
137
+ });
138
+ </script>
139
+ {% endblock %}
File without changes
@@ -0,0 +1,26 @@
1
+ INSTALLED_APPS = [
2
+ 'django.contrib.auth',
3
+ 'django.contrib.contenttypes',
4
+ 'esi',
5
+ 'eveuniverse',
6
+ 'allianceauth',
7
+ 'allianceauth.authentication',
8
+ 'allianceauth.eveonline',
9
+ 'fatanalysis',
10
+ ]
11
+ DATABASES = {
12
+ 'default': {
13
+ 'ENGINE': 'django.db.backends.sqlite3',
14
+ 'NAME': 'db.sqlite3',
15
+ }
16
+ }
17
+ SECRET_KEY = 'dummy'
18
+ CACHES = {
19
+ 'default': {
20
+ 'BACKEND': 'django_redis.cache.RedisCache',
21
+ 'LOCATION': 'redis://127.0.0.1:6379/1',
22
+ 'OPTIONS': {
23
+ 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
24
+ }
25
+ }
26
+ }
fatanalysis/urls.py ADDED
@@ -0,0 +1,15 @@
1
+ from django.urls import path
2
+ from . import views
3
+
4
+ app_name = 'fatanalysis'
5
+
6
+ urlpatterns = [
7
+ path('', views.index, name='index'),
8
+ path('overview/30/', views.overview_30, name='overview_30'),
9
+ path('overview/90/', views.overview_90, name='overview_90'),
10
+ path('player/<str:character_name>/<int:period_type>/', views.player_detail, name='player_detail'),
11
+ path('import/', views.import_csv, name='import_csv'),
12
+ path('periods/', views.period_list, name='period_list'),
13
+ path('period/<int:period_id>/', views.period_detail, name='period_detail'),
14
+ path('period/<int:period_id>/delete/', views.period_delete, name='period_delete'),
15
+ ]
fatanalysis/views.py ADDED
@@ -0,0 +1,374 @@
1
+ import csv
2
+ from django.shortcuts import render, redirect
3
+ from django.contrib.auth.decorators import login_required, permission_required
4
+ from django.contrib import messages
5
+ from django.db import transaction
6
+ from django.db.models import Sum
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ from allianceauth.eveonline.models import EveCharacter
10
+ from .forms import CsvImportForm
11
+ from .models import UploadPeriod, FatEntry
12
+
13
+ @login_required
14
+ @permission_required('fatanalysis.basic_access')
15
+ def index(request):
16
+ from django.db.models import Sum
17
+
18
+ # Data for the Trend Chart
19
+ all_periods = UploadPeriod.objects.all().order_by('upload_date')
20
+ trend_data = {}
21
+
22
+ for period in all_periods:
23
+ # Use period ID in the key to ensure uniqueness if multiple uploads happen on the same day
24
+ date_str = f"{period.upload_date.strftime('%Y-%m-%d')} (ID:{period.id})"
25
+ if date_str not in trend_data:
26
+ trend_data[date_str] = {30: 0, 90: 0}
27
+
28
+ # Get total fats for this period
29
+ total_fats = FatEntry.objects.filter(period=period).aggregate(Sum('total_fats'))['total_fats__sum'] or 0
30
+
31
+ trend_data[date_str][period.period_type] += total_fats
32
+
33
+ trend_labels = list(trend_data.keys())
34
+ trend_30 = [trend_data[d][30] for d in trend_labels]
35
+ trend_90 = [trend_data[d][90] for d in trend_labels]
36
+
37
+ # Data for the 30-Day and 90-Day tables (Removed from index)
38
+
39
+ context = {
40
+ 'trend_labels': trend_labels,
41
+ 'trend_30': trend_30,
42
+ 'trend_90': trend_90,
43
+ }
44
+ return render(request, 'fatanalysis/index.html', context)
45
+
46
+ @login_required
47
+ @permission_required('fatanalysis.basic_access')
48
+ def overview_30(request):
49
+ latest_30 = UploadPeriod.objects.filter(period_type=30).order_by('-upload_date').first()
50
+ entries = FatEntry.objects.filter(period=latest_30) if latest_30 else []
51
+
52
+ player_data = []
53
+ dynamic_headers_set = set()
54
+ if entries:
55
+ for entry in entries:
56
+ dynamic_headers_set.update(entry.fat_details.keys())
57
+
58
+ dynamic_headers = sorted(list(dynamic_headers_set))
59
+
60
+ enriched_entries = []
61
+ if entries:
62
+ for entry in entries:
63
+ player_data.append({'name': entry.character_name, 'fats': entry.total_fats})
64
+
65
+ row_details = []
66
+ for header in dynamic_headers:
67
+ row_details.append(entry.fat_details.get(header, 0))
68
+
69
+ entry.dynamic_values = row_details
70
+ enriched_entries.append(entry)
71
+
72
+ player_data = sorted(player_data, key=lambda x: x['fats'], reverse=True)[:10]
73
+
74
+ all_30 = UploadPeriod.objects.filter(period_type=30).order_by('upload_date')
75
+ trend_labels = []
76
+ trend_data = []
77
+ for p in all_30:
78
+ trend_labels.append(f"{p.upload_date.strftime('%Y-%m-%d')} (ID:{p.id})")
79
+ total = FatEntry.objects.filter(period=p).aggregate(Sum('total_fats'))['total_fats__sum'] or 0
80
+ trend_data.append(total)
81
+
82
+ context = {
83
+ 'title': _('Latest 30 Days FATs Overview'),
84
+ 'period': latest_30,
85
+ 'entries': enriched_entries,
86
+ 'dynamic_headers': dynamic_headers,
87
+ 'top_labels': [p['name'] for p in player_data],
88
+ 'top_data': [p['fats'] for p in player_data],
89
+ 'trend_labels': trend_labels,
90
+ 'trend_data': trend_data,
91
+ 'period_type': 30,
92
+ }
93
+ return render(request, 'fatanalysis/overview.html', context)
94
+
95
+ @login_required
96
+ @permission_required('fatanalysis.basic_access')
97
+ def overview_90(request):
98
+ latest_90 = UploadPeriod.objects.filter(period_type=90).order_by('-upload_date').first()
99
+ entries = FatEntry.objects.filter(period=latest_90) if latest_90 else []
100
+
101
+ player_data = []
102
+ dynamic_headers_set = set()
103
+ if entries:
104
+ for entry in entries:
105
+ dynamic_headers_set.update(entry.fat_details.keys())
106
+
107
+ dynamic_headers = sorted(list(dynamic_headers_set))
108
+
109
+ enriched_entries = []
110
+ if entries:
111
+ for entry in entries:
112
+ player_data.append({'name': entry.character_name, 'fats': entry.total_fats})
113
+
114
+ row_details = []
115
+ for header in dynamic_headers:
116
+ row_details.append(entry.fat_details.get(header, 0))
117
+
118
+ entry.dynamic_values = row_details
119
+ enriched_entries.append(entry)
120
+
121
+ player_data = sorted(player_data, key=lambda x: x['fats'], reverse=True)[:10]
122
+
123
+ all_90 = UploadPeriod.objects.filter(period_type=90).order_by('upload_date')
124
+ trend_labels = []
125
+ trend_data = []
126
+ for p in all_90:
127
+ trend_labels.append(f"{p.upload_date.strftime('%Y-%m-%d')} (ID:{p.id})")
128
+ total = FatEntry.objects.filter(period=p).aggregate(Sum('total_fats'))['total_fats__sum'] or 0
129
+ trend_data.append(total)
130
+
131
+ context = {
132
+ 'title': _('Latest 90 Days FATs Overview'),
133
+ 'period': latest_90,
134
+ 'entries': enriched_entries,
135
+ 'dynamic_headers': dynamic_headers,
136
+ 'top_labels': [p['name'] for p in player_data],
137
+ 'top_data': [p['fats'] for p in player_data],
138
+ 'trend_labels': trend_labels,
139
+ 'trend_data': trend_data,
140
+ 'period_type': 90,
141
+ }
142
+ return render(request, 'fatanalysis/overview.html', context)
143
+
144
+ @login_required
145
+ @permission_required('fatanalysis.basic_access')
146
+ def import_csv(request):
147
+ if request.method == 'POST':
148
+ form = CsvImportForm(request.POST, request.FILES)
149
+ if form.is_valid():
150
+ csv_file = request.FILES['csv_file']
151
+ period_type = int(form.cleaned_data['period_type'])
152
+
153
+ if not csv_file.name.endswith('.csv'):
154
+ messages.error(request, _("Please upload a valid CSV file."))
155
+ return redirect('fatanalysis:import_csv')
156
+
157
+ try:
158
+ decoded_file = csv_file.read().decode('utf-8').splitlines()
159
+ reader = csv.DictReader(decoded_file)
160
+
161
+ with transaction.atomic():
162
+ # Create Upload Period
163
+ kwargs = {
164
+ 'uploader': request.user.username,
165
+ 'period_type': period_type
166
+ }
167
+ if form.cleaned_data.get('custom_date'):
168
+ kwargs['upload_date'] = form.cleaned_data['custom_date']
169
+ upload_period = UploadPeriod.objects.create(**kwargs)
170
+
171
+ entries_to_create = []
172
+
173
+ for row in reader:
174
+ char_name = row.get('Main Character', '').strip()
175
+ if not char_name:
176
+ continue
177
+
178
+ # Try to find character in DB
179
+ eve_char = EveCharacter.objects.filter(character_name=char_name).first()
180
+
181
+ try:
182
+ total_fats = int(row.get('Total FATs', 0))
183
+ except ValueError:
184
+ total_fats = 0
185
+
186
+ # Get all other columns as details
187
+ details = {}
188
+ standard_keys = ['Main Character', 'Total FATs']
189
+ for key, value in row.items():
190
+ if key not in standard_keys:
191
+ try:
192
+ details[key] = int(value) if value else 0
193
+ except ValueError:
194
+ details[key] = value
195
+
196
+ entries_to_create.append(
197
+ FatEntry(
198
+ period=upload_period,
199
+ character=eve_char,
200
+ character_name=char_name,
201
+ total_fats=total_fats,
202
+ fat_details=details
203
+ )
204
+ )
205
+
206
+ FatEntry.objects.bulk_create(entries_to_create)
207
+ messages.success(request, _(f"Successfully imported {len(entries_to_create)} FAT entries."))
208
+ return redirect('fatanalysis:period_detail', period_id=upload_period.id)
209
+
210
+ except Exception as e:
211
+ messages.error(request, _(f"Error parsing CSV: {e}"))
212
+ return redirect('fatanalysis:import_csv')
213
+
214
+ else:
215
+ form = CsvImportForm()
216
+
217
+ return render(request, 'fatanalysis/import_csv.html', {'form': form})
218
+
219
+ @login_required
220
+ @permission_required('fatanalysis.basic_access')
221
+ def period_detail(request, period_id):
222
+ from django.shortcuts import get_object_or_404
223
+ from django.db.models import Sum
224
+
225
+ period = get_object_or_404(UploadPeriod, id=period_id)
226
+ entries = FatEntry.objects.filter(period=period).select_related('character')
227
+
228
+ # Aggregations for charts
229
+ player_data = []
230
+
231
+ # Collect dynamic headers
232
+ dynamic_headers_set = set()
233
+ for entry in entries:
234
+ dynamic_headers_set.update(entry.fat_details.keys())
235
+
236
+ # Sort headers to ensure consistent column ordering
237
+ dynamic_headers = sorted(list(dynamic_headers_set))
238
+
239
+ # Create enriched entries for template rendering
240
+ enriched_entries = []
241
+ for entry in entries:
242
+ player_data.append({
243
+ 'name': entry.character_name,
244
+ 'fats': entry.total_fats
245
+ })
246
+
247
+ row_details = []
248
+ for header in dynamic_headers:
249
+ row_details.append(entry.fat_details.get(header, 0))
250
+
251
+ enriched_entries.append({
252
+ 'character_name': entry.character_name,
253
+ 'total_fats': entry.total_fats,
254
+ 'dynamic_values': row_details
255
+ })
256
+
257
+ # Sort for charts
258
+ player_data = sorted(player_data, key=lambda x: x['fats'], reverse=True)[:20] # Top 20 players
259
+
260
+ player_labels = [p['name'] for p in player_data]
261
+ player_values = [p['fats'] for p in player_data]
262
+
263
+ context = {
264
+ 'period': period,
265
+ 'entries': enriched_entries,
266
+ 'dynamic_headers': dynamic_headers,
267
+ 'player_labels': player_labels,
268
+ 'player_values': player_values,
269
+ }
270
+ return render(request, 'fatanalysis/period_detail.html', context)
271
+
272
+ @login_required
273
+ @permission_required('fatanalysis.basic_access')
274
+ def period_list(request):
275
+ periods = UploadPeriod.objects.all().order_by('-upload_date')
276
+ return render(request, 'fatanalysis/period_list.html', {'periods': periods})
277
+
278
+ @login_required
279
+ @permission_required('fatanalysis.basic_access')
280
+ def period_delete(request, period_id):
281
+ from django.shortcuts import get_object_or_404
282
+ period = get_object_or_404(UploadPeriod, id=period_id)
283
+ if request.method == 'POST':
284
+ period.delete()
285
+ messages.success(request, _("Upload period and all associated data deleted successfully."))
286
+ return redirect('fatanalysis:period_list')
287
+ return render(request, 'fatanalysis/period_delete.html', {'period': period})
288
+
289
+
290
+ @login_required
291
+ @permission_required('fatanalysis.basic_access')
292
+ def player_detail(request, character_name, period_type):
293
+ from collections import defaultdict
294
+ # Get all entries for this character and period type, chronologically
295
+ periods = UploadPeriod.objects.filter(period_type=period_type).order_by('upload_date')
296
+ entries = FatEntry.objects.filter(character_name=character_name, period__period_type=period_type).select_related('period').order_by('period__upload_date')
297
+
298
+ if not entries:
299
+ messages.warning(request, _(f"No data found for {character_name}."))
300
+ return redirect('fatanalysis:index')
301
+
302
+ dynamic_headers_set = set()
303
+ for entry in entries:
304
+ dynamic_headers_set.update(entry.fat_details.keys())
305
+ dynamic_headers = sorted(list(dynamic_headers_set))
306
+
307
+ # Prepare Line Chart Data (Trend per FAT type)
308
+ trend_labels = []
309
+ trend_datasets_dict = defaultdict(list)
310
+ total_fats_trend = []
311
+
312
+ # We want a data point for ALL periods of this type, even if the player had 0
313
+ # Create a lookup for player entries
314
+ entry_lookup = {e.period.id: e for e in entries}
315
+
316
+ for p in periods:
317
+ trend_labels.append(f"{p.upload_date.strftime('%Y-%m-%d')} (ID:{p.id})")
318
+
319
+ entry = entry_lookup.get(p.id)
320
+ if entry:
321
+ total_fats_trend.append(entry.total_fats)
322
+ for header in dynamic_headers:
323
+ trend_datasets_dict[header].append(entry.fat_details.get(header, 0))
324
+ else:
325
+ total_fats_trend.append(0)
326
+ for header in dynamic_headers:
327
+ trend_datasets_dict[header].append(0)
328
+
329
+ # Convert datasets to Chart.js format
330
+ trend_datasets = []
331
+ colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#E7E9ED', '#8AC926', '#1982C4', '#6A4C93']
332
+ for i, header in enumerate(dynamic_headers):
333
+ trend_datasets.append({
334
+ 'label': header,
335
+ 'data': trend_datasets_dict[header],
336
+ 'borderColor': colors[i % len(colors)],
337
+ 'tension': 0.1,
338
+ 'fill': False,
339
+ 'hidden': False
340
+ })
341
+ # Add Total line
342
+ trend_datasets.append({
343
+ 'label': 'Total FATs',
344
+ 'data': total_fats_trend,
345
+ 'borderColor': '#000000',
346
+ 'borderDash': [5, 5],
347
+ 'tension': 0.1,
348
+ 'fill': False
349
+ })
350
+
351
+ # Prepare Pie Chart Data (Latest Period Distribution)
352
+ latest_entry = entries.last()
353
+ pie_labels = []
354
+ pie_data = []
355
+ if latest_entry:
356
+ for header in dynamic_headers:
357
+ val = latest_entry.fat_details.get(header, 0)
358
+ if val > 0:
359
+ pie_labels.append(header)
360
+ pie_data.append(val)
361
+
362
+ context = {
363
+ 'title': _(f"Player Detail: {character_name}"),
364
+ 'character_name': character_name,
365
+ 'period_type': period_type,
366
+ 'entries': entries,
367
+ 'dynamic_headers': dynamic_headers,
368
+ 'trend_labels': trend_labels,
369
+ 'trend_datasets': trend_datasets,
370
+ 'pie_labels': pie_labels,
371
+ 'pie_data': pie_data,
372
+ 'latest_period': latest_entry.period if latest_entry else None,
373
+ }
374
+ return render(request, 'fatanalysis/player_detail.html', context)