argeinfina commited on
Commit
ae77c4a
·
verified ·
1 Parent(s): ea5ebd3

feat: add Excel exports for history details

Browse files
arena/application/services/history_export_service.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import BytesIO
2
+ from typing import Callable, Iterable
3
+
4
+ from django.utils import timezone
5
+ from openpyxl import Workbook
6
+ from openpyxl.styles import Alignment, Font
7
+ from openpyxl.utils import get_column_letter
8
+
9
+ from arena.models import PromptRun
10
+
11
+
12
+ class HistoryExportService:
13
+ def build_run_workbook(
14
+ self,
15
+ run: PromptRun,
16
+ provider_labeler: Callable[[str], str] | None = None,
17
+ ) -> bytes:
18
+ workbook = Workbook()
19
+ summary_sheet = workbook.active
20
+ summary_sheet.title = "Kayit Ozeti"
21
+
22
+ summary_rows = [
23
+ ("Kayit ID", run.id),
24
+ ("Olusturma Tarihi", self._format_datetime(run.created_at)),
25
+ ("Temperature", run.temperature),
26
+ ("Max Tokens", "limitsiz" if run.max_tokens == 0 else run.max_tokens),
27
+ ("Prompt", run.prompt),
28
+ ("System Prompt", run.system_prompt),
29
+ ]
30
+ self._write_key_value_rows(summary_sheet, summary_rows)
31
+
32
+ responses_sheet = workbook.create_sheet("Model Cevaplari")
33
+ response_headers = [
34
+ "Provider",
35
+ "Provider Etiketi",
36
+ "Model",
37
+ "Durum",
38
+ "Gecikme (ms)",
39
+ "Cevap",
40
+ "Hata Mesaji",
41
+ "Olusturma Tarihi",
42
+ ]
43
+ responses_sheet.append(response_headers)
44
+ self._style_header_row(responses_sheet)
45
+
46
+ for response in run.responses.all():
47
+ responses_sheet.append(
48
+ [
49
+ response.provider,
50
+ self._provider_label(provider_labeler, response.provider),
51
+ response.model_id,
52
+ response.status,
53
+ response.latency_ms,
54
+ response.content,
55
+ response.error_message,
56
+ self._format_datetime(response.created_at),
57
+ ]
58
+ )
59
+
60
+ self._wrap_text_columns(responses_sheet, ["F", "G"])
61
+ self._auto_size_columns(summary_sheet)
62
+ self._auto_size_columns(responses_sheet)
63
+ responses_sheet.freeze_panes = "A2"
64
+ return self._to_bytes(workbook)
65
+
66
+ def build_all_runs_workbook(
67
+ self,
68
+ runs: Iterable[PromptRun],
69
+ provider_labeler: Callable[[str], str] | None = None,
70
+ ) -> bytes:
71
+ workbook = Workbook()
72
+ sheet = workbook.active
73
+ sheet.title = "Tum Gecmis Detay"
74
+
75
+ headers = [
76
+ "Kayit ID",
77
+ "Kayit Tarihi",
78
+ "Prompt",
79
+ "System Prompt",
80
+ "Temperature",
81
+ "Max Tokens",
82
+ "Response ID",
83
+ "Provider",
84
+ "Provider Etiketi",
85
+ "Model",
86
+ "Durum",
87
+ "Gecikme (ms)",
88
+ "Cevap",
89
+ "Hata Mesaji",
90
+ "Response Tarihi",
91
+ ]
92
+ sheet.append(headers)
93
+ self._style_header_row(sheet)
94
+
95
+ for run in runs:
96
+ responses = list(run.responses.all())
97
+ if not responses:
98
+ sheet.append(
99
+ [
100
+ run.id,
101
+ self._format_datetime(run.created_at),
102
+ run.prompt,
103
+ run.system_prompt,
104
+ run.temperature,
105
+ "limitsiz" if run.max_tokens == 0 else run.max_tokens,
106
+ "",
107
+ "",
108
+ "",
109
+ "",
110
+ "",
111
+ "",
112
+ "",
113
+ "",
114
+ "",
115
+ ]
116
+ )
117
+ continue
118
+
119
+ for response in responses:
120
+ sheet.append(
121
+ [
122
+ run.id,
123
+ self._format_datetime(run.created_at),
124
+ run.prompt,
125
+ run.system_prompt,
126
+ run.temperature,
127
+ "limitsiz" if run.max_tokens == 0 else run.max_tokens,
128
+ response.id,
129
+ response.provider,
130
+ self._provider_label(provider_labeler, response.provider),
131
+ response.model_id,
132
+ response.status,
133
+ response.latency_ms,
134
+ response.content,
135
+ response.error_message,
136
+ self._format_datetime(response.created_at),
137
+ ]
138
+ )
139
+
140
+ self._wrap_text_columns(sheet, ["C", "D", "M", "N"])
141
+ self._auto_size_columns(sheet)
142
+ sheet.freeze_panes = "A2"
143
+ return self._to_bytes(workbook)
144
+
145
+ def _to_bytes(self, workbook: Workbook) -> bytes:
146
+ stream = BytesIO()
147
+ workbook.save(stream)
148
+ stream.seek(0)
149
+ return stream.getvalue()
150
+
151
+ def _style_header_row(self, sheet) -> None:
152
+ for cell in sheet[1]:
153
+ cell.font = Font(bold=True)
154
+ cell.alignment = Alignment(vertical="top")
155
+
156
+ def _write_key_value_rows(self, sheet, rows: list[tuple[str, object]]) -> None:
157
+ for index, (key, value) in enumerate(rows, start=1):
158
+ key_cell = sheet.cell(row=index, column=1, value=key)
159
+ value_cell = sheet.cell(row=index, column=2, value=value)
160
+ key_cell.font = Font(bold=True)
161
+ key_cell.alignment = Alignment(vertical="top")
162
+ value_cell.alignment = Alignment(vertical="top", wrap_text=True)
163
+
164
+ def _auto_size_columns(self, sheet) -> None:
165
+ for column_cells in sheet.columns:
166
+ column_letter = get_column_letter(column_cells[0].column)
167
+ width = max((len(str(cell.value or "")) for cell in column_cells), default=0)
168
+ sheet.column_dimensions[column_letter].width = min(max(width + 2, 12), 80)
169
+
170
+ def _wrap_text_columns(self, sheet, columns: list[str]) -> None:
171
+ for column in columns:
172
+ for cell in sheet[column]:
173
+ cell.alignment = Alignment(vertical="top", wrap_text=True)
174
+
175
+ def _provider_label(
176
+ self,
177
+ provider_labeler: Callable[[str], str] | None,
178
+ provider_id: str,
179
+ ) -> str:
180
+ if provider_labeler:
181
+ return provider_labeler(provider_id)
182
+ return provider_id
183
+
184
+ def _format_datetime(self, value) -> str:
185
+ if not value:
186
+ return ""
187
+ return timezone.localtime(value).strftime("%Y-%m-%d %H:%M:%S")
arena/static/arena/styles.css CHANGED
@@ -361,6 +361,15 @@ code,
361
  align-items: center;
362
  }
363
 
 
 
 
 
 
 
 
 
 
364
  .history-main p {
365
  margin: 0.35rem 0;
366
  }
 
361
  align-items: center;
362
  }
363
 
364
+ .history-actions {
365
+ display: flex;
366
+ gap: 0.5rem;
367
+ }
368
+
369
+ .actions-row {
370
+ margin-bottom: 0.8rem;
371
+ }
372
+
373
  .history-main p {
374
  margin: 0.35rem 0;
375
  }
arena/templates/arena/history_detail.html CHANGED
@@ -6,6 +6,9 @@
6
  <section class="card">
7
  <h1>Kayit #{{ run.id }}</h1>
8
  <p class="muted">{{ run.created_at|date:"Y-m-d H:i:s" }}</p>
 
 
 
9
 
10
  <h3>Prompt</h3>
11
  <pre class="response-box">{{ run.prompt }}</pre>
 
6
  <section class="card">
7
  <h1>Kayit #{{ run.id }}</h1>
8
  <p class="muted">{{ run.created_at|date:"Y-m-d H:i:s" }}</p>
9
+ <div class="actions-row">
10
+ <a class="btn btn-primary" href="{% url 'arena:history_export_run_excel' run.id %}">Bu Kaydi Excel Olarak Indir</a>
11
+ </div>
12
 
13
  <h3>Prompt</h3>
14
  <pre class="response-box">{{ run.prompt }}</pre>
arena/templates/arena/history_list.html CHANGED
@@ -6,6 +6,9 @@
6
  <section class="card">
7
  <h1>Gecmis Karsilastirmalar</h1>
8
  <p class="muted">Tum prompt/coklu model sonuc kayitlari.</p>
 
 
 
9
 
10
  <div class="stack-sm">
11
  {% for run in page_obj %}
@@ -15,7 +18,10 @@
15
  <p>{{ run.prompt|truncatechars:180 }}</p>
16
  <small class="muted">{{ run.responses.count }} model sonucu</small>
17
  </div>
18
- <a class="btn btn-secondary" href="{% url 'arena:history_detail' run.id %}">Detay</a>
 
 
 
19
  </article>
20
  {% empty %}
21
  <p>Henuz kayit yok.</p>
 
6
  <section class="card">
7
  <h1>Gecmis Karsilastirmalar</h1>
8
  <p class="muted">Tum prompt/coklu model sonuc kayitlari.</p>
9
+ <div class="actions-row">
10
+ <a class="btn btn-primary" href="{% url 'arena:history_export_all_excel' %}">Tum Gecmisi Excel Olarak Indir</a>
11
+ </div>
12
 
13
  <div class="stack-sm">
14
  {% for run in page_obj %}
 
18
  <p>{{ run.prompt|truncatechars:180 }}</p>
19
  <small class="muted">{{ run.responses.count }} model sonucu</small>
20
  </div>
21
+ <div class="history-actions">
22
+ <a class="btn btn-secondary" href="{% url 'arena:history_detail' run.id %}">Detay</a>
23
+ <a class="btn btn-secondary" href="{% url 'arena:history_export_run_excel' run.id %}">Excel</a>
24
+ </div>
25
  </article>
26
  {% empty %}
27
  <p>Henuz kayit yok.</p>
arena/tests.py CHANGED
@@ -1,3 +1,77 @@
1
- from django.test import TestCase
2
-
3
- # Create your tests here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import BytesIO
2
+
3
+ from django.contrib.auth import get_user_model
4
+ from django.test import TestCase
5
+ from django.urls import reverse
6
+ from openpyxl import load_workbook
7
+
8
+ from arena.models import ModelResponse, PromptRun
9
+
10
+
11
+ class HistoryExcelExportTests(TestCase):
12
+ def setUp(self):
13
+ user_model = get_user_model()
14
+ self.user = user_model.objects.create_user(username="tester", password="secret123")
15
+ self.client.force_login(self.user)
16
+
17
+ self.run = PromptRun.objects.create(
18
+ prompt="Ana prompt metni",
19
+ system_prompt="Sistem prompt metni",
20
+ temperature=0.55,
21
+ max_tokens=2048,
22
+ )
23
+ ModelResponse.objects.create(
24
+ run=self.run,
25
+ provider="openai",
26
+ model_id="gpt-4o-mini",
27
+ content="Model cevabi",
28
+ status=ModelResponse.STATUS_SUCCESS,
29
+ latency_ms=321,
30
+ )
31
+
32
+ def test_single_history_excel_export(self):
33
+ response = self.client.get(reverse("arena:history_export_run_excel", args=[self.run.id]))
34
+
35
+ self.assertEqual(response.status_code, 200)
36
+ self.assertEqual(
37
+ response["Content-Type"],
38
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39
+ )
40
+ self.assertIn(".xlsx", response["Content-Disposition"])
41
+
42
+ workbook = load_workbook(filename=BytesIO(response.content))
43
+ self.assertIn("Kayit Ozeti", workbook.sheetnames)
44
+ self.assertIn("Model Cevaplari", workbook.sheetnames)
45
+
46
+ summary_sheet = workbook["Kayit Ozeti"]
47
+ response_sheet = workbook["Model Cevaplari"]
48
+
49
+ self.assertEqual(summary_sheet["B5"].value, "Ana prompt metni")
50
+ self.assertEqual(summary_sheet["B6"].value, "Sistem prompt metni")
51
+ self.assertEqual(response_sheet["A2"].value, "openai")
52
+ self.assertEqual(response_sheet["C2"].value, "gpt-4o-mini")
53
+
54
+ def test_all_history_excel_export(self):
55
+ empty_run = PromptRun.objects.create(
56
+ prompt="Ikinci prompt",
57
+ system_prompt="",
58
+ temperature=0.2,
59
+ max_tokens=0,
60
+ )
61
+
62
+ response = self.client.get(reverse("arena:history_export_all_excel"))
63
+
64
+ self.assertEqual(response.status_code, 200)
65
+ workbook = load_workbook(filename=BytesIO(response.content))
66
+ self.assertIn("Tum Gecmis Detay", workbook.sheetnames)
67
+
68
+ detail_sheet = workbook["Tum Gecmis Detay"]
69
+ data_rows = list(detail_sheet.iter_rows(min_row=2, values_only=True))
70
+
71
+ run_ids = {row[0] for row in data_rows}
72
+ self.assertIn(self.run.id, run_ids)
73
+ self.assertIn(empty_run.id, run_ids)
74
+
75
+ empty_run_rows = [row for row in data_rows if row[0] == empty_run.id]
76
+ self.assertTrue(empty_run_rows)
77
+ self.assertEqual(empty_run_rows[0][5], "limitsiz")
arena/urls.py CHANGED
@@ -7,7 +7,13 @@ app_name = "arena"
7
  urlpatterns = [
8
  path("", views.index_view, name="index"),
9
  path("history/", views.history_list_view, name="history_list"),
 
10
  path("history/<int:run_id>/", views.history_detail_view, name="history_detail"),
 
 
 
 
 
11
  path("catalog/", views.catalog_view, name="catalog"),
12
  path("settings/credentials/", views.provider_credentials_view, name="provider_credentials"),
13
  path("api/models/<str:provider_id>/", views.provider_models_api, name="provider_models_api"),
 
7
  urlpatterns = [
8
  path("", views.index_view, name="index"),
9
  path("history/", views.history_list_view, name="history_list"),
10
+ path("history/export/excel/", views.history_export_all_excel_view, name="history_export_all_excel"),
11
  path("history/<int:run_id>/", views.history_detail_view, name="history_detail"),
12
+ path(
13
+ "history/<int:run_id>/export/excel/",
14
+ views.history_export_run_excel_view,
15
+ name="history_export_run_excel",
16
+ ),
17
  path("catalog/", views.catalog_view, name="catalog"),
18
  path("settings/credentials/", views.provider_credentials_view, name="provider_credentials"),
19
  path("api/models/<str:provider_id>/", views.provider_models_api, name="provider_models_api"),
arena/views.py CHANGED
@@ -8,14 +8,16 @@ from django.conf import settings
8
  from django.contrib.auth.decorators import login_required
9
  from django.contrib import messages
10
  from django.core.paginator import Paginator
11
- from django.http import Http404, JsonResponse, StreamingHttpResponse
12
  from django.shortcuts import redirect, render
13
  from django.urls import reverse
 
14
  from django.views.decorators.http import require_http_methods
15
 
16
  from arena.application.dto import ModelTarget
17
  from arena.application.services.catalog_service import CatalogService
18
  from arena.application.services.comparison_service import ComparisonService
 
19
  from arena.application.services.history_service import HistoryService
20
  from arena.application.services.provider_config_service import ProviderConfigService
21
  from arena.forms import CatalogModelForm, ModelRefreshForm, PromptInputForm, ProviderCredentialForm
@@ -24,6 +26,7 @@ from arena.models import PromptRun, ProviderCredential
24
  catalog_service = CatalogService()
25
  comparison_service = ComparisonService()
26
  history_service = HistoryService()
 
27
  provider_config_service = ProviderConfigService()
28
  logger = logging.getLogger("arena.views")
29
 
@@ -92,6 +95,18 @@ def _masked_token_preview(token: str) -> str:
92
  return f"{cleaned[:4]}...{cleaned[-4:]}"
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  @login_required
96
  def index_view(request):
97
  catalog_service.ensure_seed_models()
@@ -202,6 +217,30 @@ def history_detail_view(request, run_id: int):
202
  return render(request, "arena/history_detail.html", context)
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  @login_required
206
  def catalog_view(request):
207
  catalog_service.ensure_seed_models()
 
8
  from django.contrib.auth.decorators import login_required
9
  from django.contrib import messages
10
  from django.core.paginator import Paginator
11
+ from django.http import Http404, HttpResponse, JsonResponse, StreamingHttpResponse
12
  from django.shortcuts import redirect, render
13
  from django.urls import reverse
14
+ from django.utils import timezone
15
  from django.views.decorators.http import require_http_methods
16
 
17
  from arena.application.dto import ModelTarget
18
  from arena.application.services.catalog_service import CatalogService
19
  from arena.application.services.comparison_service import ComparisonService
20
+ from arena.application.services.history_export_service import HistoryExportService
21
  from arena.application.services.history_service import HistoryService
22
  from arena.application.services.provider_config_service import ProviderConfigService
23
  from arena.forms import CatalogModelForm, ModelRefreshForm, PromptInputForm, ProviderCredentialForm
 
26
  catalog_service = CatalogService()
27
  comparison_service = ComparisonService()
28
  history_service = HistoryService()
29
+ history_export_service = HistoryExportService()
30
  provider_config_service = ProviderConfigService()
31
  logger = logging.getLogger("arena.views")
32
 
 
95
  return f"{cleaned[:4]}...{cleaned[-4:]}"
96
 
97
 
98
+ def _excel_download_response(content: bytes, file_prefix: str) -> HttpResponse:
99
+ timestamp = timezone.localtime().strftime("%Y%m%d_%H%M%S")
100
+ response = HttpResponse(
101
+ content,
102
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
103
+ )
104
+ response["Content-Disposition"] = (
105
+ f'attachment; filename="{file_prefix}_{timestamp}.xlsx"'
106
+ )
107
+ return response
108
+
109
+
110
  @login_required
111
  def index_view(request):
112
  catalog_service.ensure_seed_models()
 
217
  return render(request, "arena/history_detail.html", context)
218
 
219
 
220
+ @login_required
221
+ def history_export_all_excel_view(request):
222
+ runs = history_service.list_runs().prefetch_related("responses")
223
+ workbook_content = history_export_service.build_all_runs_workbook(
224
+ runs=runs,
225
+ provider_labeler=catalog_service.provider_label,
226
+ )
227
+ return _excel_download_response(workbook_content, "tum_gecmis_detay")
228
+
229
+
230
+ @login_required
231
+ def history_export_run_excel_view(request, run_id: int):
232
+ try:
233
+ run = history_service.get_run(run_id)
234
+ except PromptRun.DoesNotExist as exc:
235
+ raise Http404("Kayit bulunamadi.") from exc
236
+
237
+ workbook_content = history_export_service.build_run_workbook(
238
+ run=run,
239
+ provider_labeler=catalog_service.provider_label,
240
+ )
241
+ return _excel_download_response(workbook_content, f"kayit_{run_id}_detay")
242
+
243
+
244
  @login_required
245
  def catalog_view(request):
246
  catalog_service.ensure_seed_models()
requirements.txt CHANGED
@@ -7,3 +7,4 @@ langchain-openai==0.2.14
7
  langchain-anthropic==0.3.7
8
  langchain-google-genai==2.1.8
9
  langchain-ollama==0.2.3
 
 
7
  langchain-anthropic==0.3.7
8
  langchain-google-genai==2.1.8
9
  langchain-ollama==0.2.3
10
+ openpyxl==3.1.5