Add personal monthly viewing reports and CSV exports
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s

This commit is contained in:
2026-09-09 16:25:35 +12:00
parent 437836243c
commit 333a799e21
13 changed files with 836 additions and 92 deletions
+35 -1
View File
@@ -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)
+8 -6
View File
@@ -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)
+149
View File
@@ -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()
+209
View File
@@ -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)