Add personal monthly viewing reports and CSV exports
This commit is contained in:
@@ -1,17 +1,51 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..services.insights import get_insights
|
||||
from ..services.insights_artwork import get_artwork
|
||||
from ..services.monthly_reports import get_monthly_report, report_csv
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||
|
||||
|
||||
class MonthlyReportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
|
||||
|
||||
|
||||
async def monthly_data(user: dict, month: str | None) -> dict:
|
||||
try:
|
||||
return await get_monthly_report(user, month)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
|
||||
except HistoryLimitError as exc:
|
||||
raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
|
||||
except JellystatError as exc:
|
||||
raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
|
||||
|
||||
|
||||
@router.get("/reports/monthly")
|
||||
async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
|
||||
user: dict = Depends(get_current_user)) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await monthly_data(user, query.month)
|
||||
|
||||
|
||||
@router.get("/reports/monthly.csv")
|
||||
async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
|
||||
report = await monthly_data(user, query.month)
|
||||
if report["state"] != "ready":
|
||||
raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
|
||||
return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
|
||||
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
|
||||
|
||||
|
||||
@router.get("/artwork/{item_id}")
|
||||
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
|
||||
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
|
||||
|
||||
@@ -99,8 +99,9 @@ async def resolve_identity(user: dict, runtime) -> str | None:
|
||||
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
||||
|
||||
|
||||
def request_summary(user: dict, start: datetime, end: datetime) -> dict:
|
||||
clause = "julianday(created_at) >= julianday(?) AND julianday(created_at) <= julianday(?)"
|
||||
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||
operator = "<" if end_exclusive else "<="
|
||||
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
|
||||
params = [start.isoformat(), end.isoformat()]
|
||||
if user.get("jellyseerr_user_id") is not None:
|
||||
clause += " AND requested_by_id = ?"
|
||||
@@ -121,7 +122,7 @@ def request_summary(user: dict, start: datetime, end: datetime) -> dict:
|
||||
return {**dict(counts), "recent": [dict(row) for row in recent]}
|
||||
|
||||
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime) -> dict:
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
||||
daily_seconds = defaultdict(float)
|
||||
clients = defaultdict(float)
|
||||
@@ -142,7 +143,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
seen.add(row_id)
|
||||
date = _date(row.get("ActivityDateInserted"))
|
||||
# Defend against older upstream versions ignoring the range filter.
|
||||
if not start <= date <= end:
|
||||
if date < start or (date >= end if end_exclusive else date > end):
|
||||
continue
|
||||
duration = _duration(row.get("PlaybackDuration"))
|
||||
if duration <= 0:
|
||||
@@ -172,7 +173,8 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
|
||||
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
|
||||
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
|
||||
count = (end.date() - start.date()).days + 1
|
||||
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
|
||||
count = (last_date - start.date()).days + 1
|
||||
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
|
||||
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
|
||||
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
|
||||
@@ -181,7 +183,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
run = run + 1 if day["date"] in active_days else 0
|
||||
longest = max(longest, run)
|
||||
current = 0
|
||||
cursor = end.date() if end.date().isoformat() in active_days else end.date() - timedelta(days=1)
|
||||
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
|
||||
while cursor.isoformat() in active_days:
|
||||
current += 1
|
||||
cursor -= timedelta(days=1)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Personal calendar-month reports built from retained Jellystat history."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..clients.jellystat import JellystatClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .insights import request_summary, resolve_identity, summarize
|
||||
from .insights_artwork import with_artwork
|
||||
|
||||
_cache: dict[tuple, tuple[float, dict]] = {}
|
||||
CACHE_SECONDS = 60
|
||||
MONTH_COUNT = 24
|
||||
|
||||
|
||||
def shift_month(value: datetime, offset: int) -> datetime:
|
||||
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
|
||||
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def month_periods(month: str | None, now: datetime) -> dict:
|
||||
now = now.astimezone(timezone.utc)
|
||||
this_month = shift_month(now, 0)
|
||||
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
|
||||
selected = month if month is not None else available[1]
|
||||
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
|
||||
raise ValueError("Choose the current month or one of the previous 23 months.")
|
||||
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
|
||||
calendar_end = shift_month(start, 1)
|
||||
end = min(calendar_end, now)
|
||||
previous_start = shift_month(start, -1)
|
||||
partial = end < calendar_end
|
||||
previous_end = min(previous_start + (end - start), start) if partial else start
|
||||
return {"month": selected, "available_months": available, "timezone": "UTC",
|
||||
"period_start": start.isoformat(), "period_end": end.isoformat(),
|
||||
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
|
||||
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
|
||||
"comparison_capped": partial and previous_start + (end - start) > start}
|
||||
|
||||
|
||||
def change(current: float, previous: float) -> dict:
|
||||
difference = round(current - previous, 1)
|
||||
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
|
||||
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
|
||||
|
||||
|
||||
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
periods = month_periods(month, now)
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
|
||||
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
if not client.configured():
|
||||
return {**base, "state": "not_configured"}
|
||||
identity = await resolve_identity(user, runtime)
|
||||
if not identity:
|
||||
return {**base, "state": "unlinked"}
|
||||
# Cache playback only. Request ownership and request statuses are read afresh.
|
||||
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
||||
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
data = cached[1]
|
||||
else:
|
||||
history, libraries = await client.get_user_history(identity,
|
||||
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
|
||||
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
|
||||
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
|
||||
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
|
||||
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
|
||||
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
|
||||
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
|
||||
_cache.pop(expired, None)
|
||||
if len(_cache) >= 128:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
|
||||
requests, previous_requests = await asyncio.gather(
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
|
||||
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
|
||||
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
|
||||
changes = {name: change(data["summary"][name], data["previous_summary"][name])
|
||||
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
|
||||
changes["requests"] = change(requests["total"], previous_requests["total"])
|
||||
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
|
||||
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
|
||||
"changes": changes}
|
||||
|
||||
|
||||
def report_csv(report: dict) -> str:
|
||||
"""Export normalized data only; protect text cells from spreadsheet formulas."""
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
|
||||
def row(*cells):
|
||||
safe = []
|
||||
for cell in cells:
|
||||
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
|
||||
cell = "'" + cell
|
||||
safe.append(cell)
|
||||
writer.writerow(safe)
|
||||
|
||||
row("Magent monthly viewing report", report["month"])
|
||||
row("Timezone", "UTC")
|
||||
row("Period start (inclusive)", report["period_start"])
|
||||
row("Period end (exclusive)", report["period_end"])
|
||||
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
|
||||
row("Comparison start (inclusive)", report["comparison_start"])
|
||||
row("Comparison end (exclusive)", report["comparison_end"])
|
||||
row("Generated at", report["updated_at"])
|
||||
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
|
||||
row()
|
||||
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
|
||||
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
|
||||
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
|
||||
for name, label in labels.items():
|
||||
value = report["changes"][name]
|
||||
row(label, value["current"], value["previous"], value["difference"], value["percent"])
|
||||
row()
|
||||
row("Date (UTC)", "Minutes watched")
|
||||
for day in report["daily"]:
|
||||
row(day["date"], day["minutes"])
|
||||
row()
|
||||
row("Most watched title", "Media type", "Minutes", "Plays")
|
||||
for title in report["top_titles"]:
|
||||
row(title["title"], title["type"], title["minutes"], title["plays"])
|
||||
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
|
||||
row()
|
||||
row(label, "Playback minutes")
|
||||
for entry in report[field]:
|
||||
row(entry["name"], entry["minutes"])
|
||||
row()
|
||||
row("Transcoding", "Playback minutes")
|
||||
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
|
||||
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
|
||||
("unknown_hardware_minutes", "Video hardware not recorded"),
|
||||
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
|
||||
row(label, report["transcoding"][field])
|
||||
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
|
||||
row()
|
||||
row("Requests", "Count")
|
||||
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
|
||||
row(label, report["requests"][field])
|
||||
return "\ufeff" + output.getvalue()
|
||||
@@ -0,0 +1,209 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
|
||||
from backend.app.routers import insights as router
|
||||
from backend.app.services import insights, monthly_reports as reports
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
from backend.tests.test_insights import LIBRARIES, NOW, USER, play
|
||||
|
||||
|
||||
class MonthlyPeriodTests(unittest.TestCase):
|
||||
def test_default_is_last_complete_calendar_month(self):
|
||||
period = reports.month_periods(None, NOW)
|
||||
self.assertEqual(period['month'], '2026-08')
|
||||
self.assertEqual(period['period_start'], '2026-08-01T00:00:00+00:00')
|
||||
self.assertEqual(period['period_end'], '2026-09-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_start'], '2026-07-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_end'], period['period_start'])
|
||||
self.assertFalse(period['is_partial'])
|
||||
self.assertEqual(len(period['available_months']), 24)
|
||||
|
||||
def test_leap_year_and_year_rollover(self):
|
||||
period = reports.month_periods('2024-02', datetime(2024, 3, 2, tzinfo=timezone.utc))
|
||||
data = insights.summarize([], LIBRARIES, datetime.fromisoformat(period['period_start']),
|
||||
datetime.fromisoformat(period['period_end']), end_exclusive=True)
|
||||
self.assertEqual(len(data['daily']), 29)
|
||||
self.assertEqual(data['daily'][-1]['date'], '2024-02-29')
|
||||
period = reports.month_periods(None, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['month'], '2025-12')
|
||||
self.assertEqual(period['comparison_month'], '2025-11')
|
||||
|
||||
def test_partial_month_matches_elapsed_time_and_caps_short_month(self):
|
||||
period = reports.month_periods('2026-09', NOW)
|
||||
self.assertTrue(period['is_partial'])
|
||||
self.assertEqual(period['comparison_end'], '2026-08-07T12:00:00+00:00')
|
||||
self.assertFalse(period['comparison_capped'])
|
||||
period = reports.month_periods('2026-03', datetime(2026, 3, 31, 12, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['comparison_end'], '2026-03-01T00:00:00+00:00')
|
||||
self.assertTrue(period['comparison_capped'])
|
||||
|
||||
def test_utc_month_is_used_near_local_month_boundary(self):
|
||||
local = datetime(2026, 9, 1, 0, 30, tzinfo=timezone(timedelta(hours=12)))
|
||||
self.assertEqual(reports.month_periods(None, local)['month'], '2026-07')
|
||||
|
||||
def test_invalid_future_and_out_of_range_months_are_rejected(self):
|
||||
for month in ['', '2026-9', '2026-00', '2026-13', '2026-10', '2024-09', '2026-08\r\nheader', '../../file']:
|
||||
with self.subTest(month=month), self.assertRaises(ValueError):
|
||||
reports.month_periods(month, NOW)
|
||||
|
||||
def test_changes_handle_zero_baselines_and_decreases(self):
|
||||
self.assertEqual(reports.change(0, 0), {'current': 0, 'previous': 0, 'difference': 0, 'percent': 0})
|
||||
self.assertIsNone(reports.change(5, 0)['percent'])
|
||||
self.assertEqual(reports.change(30, 60)['percent'], -50)
|
||||
|
||||
def test_adjacent_months_never_double_count_boundary_play(self):
|
||||
start = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
row = play(ActivityDateInserted=start.isoformat())
|
||||
previous = insights.summarize([row], LIBRARIES, reports.shift_month(start, -1), start, end_exclusive=True)
|
||||
current = insights.summarize([row, row, play('next-month', ActivityDateInserted=end.isoformat())], LIBRARIES, start, end, end_exclusive=True)
|
||||
self.assertEqual(previous['summary']['plays'], 0)
|
||||
self.assertEqual(current['summary']['plays'], 1)
|
||||
self.assertEqual(current['daily'][-1]['date'], '2026-08-31')
|
||||
|
||||
|
||||
class MonthlyReportTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
reports._cache.clear()
|
||||
db.create_user('viewer', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jellyfin', jellyfin_api_key='PRIVATE-JF-KEY',
|
||||
jellystat_base_url='http://jellystat', jellystat_api_key='PRIVATE-STATS-KEY')
|
||||
self.runtime_patch = patch.object(reports, 'get_runtime_settings', return_value=self.runtime)
|
||||
self.runtime_patch.start()
|
||||
self.addCleanup(self.runtime_patch.stop)
|
||||
self.clock = patch.object(reports, 'datetime', wraps=datetime)
|
||||
self.clock.start().now.return_value = NOW
|
||||
self.addCleanup(self.clock.stop)
|
||||
|
||||
def add_request(self, request_id, date, owner=42, status=2):
|
||||
db.upsert_request_cache(request_id, request_id, 'movie', status, 'Request', 2026,
|
||||
'viewer', 'viewer', owner, date, date, '{}')
|
||||
|
||||
async def test_report_uses_linked_identity_and_separates_periods_and_request_owners(self):
|
||||
rows = [play('previous', PlaybackDuration=1800, ActivityDateInserted='2026-07-31T23:59:59Z'),
|
||||
play('current', ActivityDateInserted='2026-08-01T00:00:00Z'),
|
||||
play('future', ActivityDateInserted='2026-09-01T00:00:00Z')]
|
||||
for request_id, date, owner in [(1, '2026-07-31T23:59:59Z', 42), (2, '2026-08-01T00:00:00Z', 42),
|
||||
(3, '2026-08-15T00:00:00Z', 99), (4, '2026-09-01T00:00:00Z', 42)]:
|
||||
self.add_request(request_id, date, owner)
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)) as remote, \
|
||||
patch.object(insights.JellyfinClient, 'get_users', new_callable=AsyncMock) as directory:
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
self.assertEqual(result['previous_summary']['minutes'], 30)
|
||||
self.assertEqual(result['changes']['minutes']['percent'], 100)
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(result['previous_requests']['total'], 1)
|
||||
self.assertEqual(result['requests']['recent'][0]['request_id'], 2)
|
||||
self.assertEqual(remote.await_args.args[0], 'jf-viewer')
|
||||
directory.assert_not_called()
|
||||
self.assertNotIn('PRIVATE', json.dumps(result))
|
||||
self.assertNotIn('artwork_item_id', json.dumps(result))
|
||||
self.assertNotIn('jf-viewer', json.dumps(result))
|
||||
|
||||
async def test_partial_comparison_ignores_later_days_in_previous_month(self):
|
||||
rows = [play('previous', ActivityDateInserted='2026-08-07T11:59:59Z'),
|
||||
play('cutoff', ActivityDateInserted='2026-08-07T12:00:00Z'),
|
||||
play('later', ActivityDateInserted='2026-08-30T12:00:00Z'),
|
||||
play('current', ActivityDateInserted='2026-09-01T12:00:00Z')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
result = await reports.get_monthly_report(USER, '2026-09')
|
||||
self.assertEqual(result['previous_summary']['minutes'], 60)
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
|
||||
async def test_cache_isolated_by_identity_month_and_connections_but_requests_refresh(self):
|
||||
db.create_user('second', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('second', 'jf-second', 'http://jellyfin')
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.add_request(1, '2026-08-15T12:00:00Z')
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await reports.get_monthly_report({**USER, 'username': 'second'}, '2026-08')
|
||||
await reports.get_monthly_report(USER, '2026-07')
|
||||
self.runtime.jellystat_api_key = 'rotated-key'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.runtime.jellystat_base_url = 'http://other-jellystat'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(remote.await_count, 5)
|
||||
|
||||
async def test_unlinked_and_unconfigured_never_fetch_history(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock) as remote:
|
||||
result = await reports.get_monthly_report({**USER, 'username': 'unlinked', 'auth_provider': 'local'})
|
||||
self.assertEqual(result['state'], 'unlinked')
|
||||
self.runtime.jellystat_api_key = None
|
||||
result = await reports.get_monthly_report(USER)
|
||||
self.assertEqual(result['state'], 'not_configured')
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_failed_history_is_not_cached_or_returned_as_a_partial_report(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, side_effect=HistoryLimitError()):
|
||||
with self.assertRaises(HistoryLimitError):
|
||||
await reports.get_monthly_report(USER)
|
||||
self.assertEqual(reports._cache, {})
|
||||
|
||||
async def test_csv_preserves_unicode_and_quotes_but_blocks_formulas_and_private_fields(self):
|
||||
rows = [play('csv', ActivityDateInserted='2026-08-10T12:00:00Z', NowPlayingItemName='=HYPERLINK("x")', Client='\t\ufeff@SUM(1,2)'),
|
||||
play('unicode', NowPlayingItemId='second-film', ActivityDateInserted='2026-08-11T12:00:00Z', NowPlayingItemName='Amélie, "Paris"')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
report = await reports.get_monthly_report(USER)
|
||||
exported = reports.report_csv(report)
|
||||
cells = [cell for row in csv.reader(io.StringIO(exported.lstrip('\ufeff'))) for cell in row]
|
||||
self.assertIn('\'=HYPERLINK("x")', cells)
|
||||
self.assertIn("'\t\ufeff@SUM(1,2)", cells)
|
||||
self.assertIn('Amélie, "Paris"', cells)
|
||||
for private in ['PRIVATE', 'jf-viewer', 'artwork/', 'token=', 'http://jellystat']:
|
||||
self.assertNotIn(private, exported)
|
||||
|
||||
|
||||
class MonthlyReportRouteTests(unittest.TestCase):
|
||||
def client(self, authenticated=True):
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[router.get_current_user] = lambda: USER
|
||||
return TestClient(app)
|
||||
|
||||
def test_both_formats_require_auth_and_reject_scope_overrides(self):
|
||||
for path in ['/insights/reports/monthly', '/insights/reports/monthly.csv']:
|
||||
self.assertEqual(self.client(False).get(path).status_code, 401)
|
||||
for query in ['userid=other', 'scope=server', 'user_id=1', 'month=2026-9', 'month=2026-08%0D%0Ax']:
|
||||
self.assertEqual(self.client().get(path+'?'+query).status_code, 422)
|
||||
|
||||
def test_report_no_store_and_export_attachment_headers(self):
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'ready', 'month':'2026-08'}) as report, \
|
||||
patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
|
||||
response = self.client().get('/insights/reports/monthly?month=2026-08')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
report.assert_awaited_with(USER, '2026-08')
|
||||
response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(response.headers['x-content-type-options'], 'nosniff')
|
||||
self.assertEqual(response.headers['content-disposition'], 'attachment; filename="magent-monthly-report-2026-08.csv"')
|
||||
self.assertTrue(response.content.startswith(b'\xef\xbb\xbf'))
|
||||
|
||||
def test_errors_are_sanitized_and_unlinked_export_is_blocked(self):
|
||||
for exception, status in [(JellystatError('PRIVATE'), 502), (HistoryLimitError('PRIVATE'), 422), (ValueError('PRIVATE'), 422)]:
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, side_effect=exception):
|
||||
for suffix in ['', '.csv']:
|
||||
response = self.client().get('/insights/reports/monthly'+suffix)
|
||||
self.assertEqual(response.status_code, status)
|
||||
self.assertNotIn('PRIVATE', response.text)
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'unlinked'}):
|
||||
self.assertEqual(self.client().get('/insights/reports/monthly.csv').status_code, 409)
|
||||
Reference in New Issue
Block a user