databricks-tellr-app 0.3.11.dev8__tar.gz → 0.3.11.dev9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/PKG-INFO +1 -1
  2. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app.egg-info/PKG-INFO +1 -1
  3. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/pyproject.toml +1 -1
  4. databricks_tellr_app-0.3.11.dev9/src/api/routes/admin_usage.py +145 -0
  5. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/usage_service.py +208 -86
  6. databricks_tellr_app-0.3.11.dev8/src/api/routes/admin_usage.py +0 -69
  7. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/README.md +0 -0
  8. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app/__init__.py +0 -0
  9. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app/run.py +0 -0
  10. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app.egg-info/SOURCES.txt +0 -0
  11. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app.egg-info/dependency_links.txt +0 -0
  12. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app.egg-info/requires.txt +0 -0
  13. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/databricks_tellr_app.egg-info/top_level.txt +0 -0
  14. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/setup.cfg +0 -0
  15. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/setup.py +0 -0
  16. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/__init__.py +0 -0
  17. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/__init__.py +0 -0
  18. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/_otel_bootstrap.py +0 -0
  19. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/fixtures/tour_demo_deck.json +0 -0
  20. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/main.py +0 -0
  21. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/mcp_auth.py +0 -0
  22. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/mcp_server.py +0 -0
  23. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/middleware/__init__.py +0 -0
  24. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/middleware/request_logging.py +0 -0
  25. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/__init__.py +0 -0
  26. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/admin.py +0 -0
  27. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/agent_config.py +0 -0
  28. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/chat.py +0 -0
  29. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/deck_contributors.py +0 -0
  30. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/export.py +0 -0
  31. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/feedback.py +0 -0
  32. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/google_slides.py +0 -0
  33. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/images.py +0 -0
  34. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/local_version.py +0 -0
  35. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/profiles.py +0 -0
  36. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/sessions.py +0 -0
  37. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/settings/__init__.py +0 -0
  38. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/settings/contributors.py +0 -0
  39. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/settings/deck_prompts.py +0 -0
  40. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/settings/identities.py +0 -0
  41. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/settings/slide_styles.py +0 -0
  42. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/setup.py +0 -0
  43. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/slides.py +0 -0
  44. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/tools.py +0 -0
  45. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/tour.py +0 -0
  46. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/verification.py +0 -0
  47. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/routes/version.py +0 -0
  48. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/__init__.py +0 -0
  49. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/agent_config.py +0 -0
  50. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/feedback.py +0 -0
  51. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/requests.py +0 -0
  52. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/responses.py +0 -0
  53. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/settings/__init__.py +0 -0
  54. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/settings/requests.py +0 -0
  55. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/settings/responses.py +0 -0
  56. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/schemas/streaming.py +0 -0
  57. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/__init__.py +0 -0
  58. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/chat_service.py +0 -0
  59. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/export_job_queue.py +0 -0
  60. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/feedback_service.py +0 -0
  61. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/job_queue.py +0 -0
  62. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/session_manager.py +0 -0
  63. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/session_naming.py +0 -0
  64. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/services/usage_events.py +0 -0
  65. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/utils/__init__.py +0 -0
  66. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/api/utils/validation.py +0 -0
  67. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/__init__.py +0 -0
  68. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/config_loader.py +0 -0
  69. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/context_utils.py +0 -0
  70. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/database.py +0 -0
  71. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/databricks_client.py +0 -0
  72. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/defaults.py +0 -0
  73. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/encryption.py +0 -0
  74. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/init_default_profile.py +0 -0
  75. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/lakebase.py +0 -0
  76. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/migrate_profiles_to_agent_config.py +0 -0
  77. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/mlflow_agent_spans.py +0 -0
  78. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/mlflow_tracing.py +0 -0
  79. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/otel_logging.py +0 -0
  80. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/permission_context.py +0 -0
  81. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/prompt_modules.py +0 -0
  82. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/settings_db.py +0 -0
  83. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/core/user_context.py +0 -0
  84. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/__init__.py +0 -0
  85. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/__init__.py +0 -0
  86. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/deck_contributor.py +0 -0
  87. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/feedback.py +0 -0
  88. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/genie_space.py +0 -0
  89. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/google_global_credentials.py +0 -0
  90. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/google_oauth_token.py +0 -0
  91. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/identity.py +0 -0
  92. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/image.py +0 -0
  93. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/profile.py +0 -0
  94. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/profile_contributor.py +0 -0
  95. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/prompts.py +0 -0
  96. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/request_log.py +0 -0
  97. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/session.py +0 -0
  98. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/slide_deck_prompt.py +0 -0
  99. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/slide_style_library.py +0 -0
  100. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/usage_event.py +0 -0
  101. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/database/models/user_preference.py +0 -0
  102. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/domain/__init__.py +0 -0
  103. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/domain/slide.py +0 -0
  104. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/domain/slide_deck.py +0 -0
  105. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/__init__.py +0 -0
  106. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/agent.py +0 -0
  107. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/agent_factory.py +0 -0
  108. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/config_service.py +0 -0
  109. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/config_validator.py +0 -0
  110. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/drive_uploader.py +0 -0
  111. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/evaluation/__init__.py +0 -0
  112. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/evaluation/llm_judge.py +0 -0
  113. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/genie_service.py +0 -0
  114. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/google_slides_auth.py +0 -0
  115. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/google_slides_prompts_defaults.py +0 -0
  116. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/html_to_google_slides.py +0 -0
  117. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/html_to_pptx.py +0 -0
  118. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/identity_provider.py +0 -0
  119. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/identity_providers/__init__.py +0 -0
  120. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/identity_providers/account_provider.py +0 -0
  121. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/identity_providers/local_provider.py +0 -0
  122. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/identity_providers/workspace_provider.py +0 -0
  123. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/image_service.py +0 -0
  124. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/image_tools.py +0 -0
  125. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/permission_service.py +0 -0
  126. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/pptx_from_html_huashu.py +0 -0
  127. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/pptx_from_records.py +0 -0
  128. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/pptx_prompts_defaults.py +0 -0
  129. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/profile_service.py +0 -0
  130. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/streaming_callback.py +0 -0
  131. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/__init__.py +0 -0
  132. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/agent_bricks_tool.py +0 -0
  133. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/genie_tool.py +0 -0
  134. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/mcp_tool.py +0 -0
  135. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/model_endpoint_tool.py +0 -0
  136. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/tools/vector_tool.py +0 -0
  137. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/services/validator.py +0 -0
  138. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/__init__.py +0 -0
  139. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/css_utils.py +0 -0
  140. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/error_handling.py +0 -0
  141. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/html_safety.py +0 -0
  142. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/html_utils.py +0 -0
  143. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/image_utils.py +0 -0
  144. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/js_validator.py +0 -0
  145. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/logging_config.py +0 -0
  146. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/pi_filter.py +0 -0
  147. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/slide_hash.py +0 -0
  148. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/spotlight.py +0 -0
  149. {databricks_tellr_app-0.3.11.dev8 → databricks_tellr_app-0.3.11.dev9}/src/utils/text_caps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr-app
3
- Version: 0.3.11.dev8
3
+ Version: 0.3.11.dev9
4
4
  Summary: Tellr application package for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr-app
3
- Version: 0.3.11.dev8
3
+ Version: 0.3.11.dev9
4
4
  Summary: Tellr application package for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-tellr-app"
7
- version = "0.3.11.dev8"
7
+ version = "0.3.11.dev9"
8
8
  description = "Tellr application package for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,145 @@
1
+ """Admin usage-analytics endpoints (ungated, like the rest of /admin)."""
2
+
3
+ import logging
4
+ from datetime import date as dt_date
5
+ from datetime import datetime
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
8
+ from sqlalchemy.orm import Session
9
+
10
+ from src.api.services.usage_service import MAX_WINDOW_DAYS, UsageService
11
+ from src.core.database import get_db
12
+
13
+ logger = logging.getLogger(__name__)
14
+ router = APIRouter(prefix="/api/admin/usage", tags=["admin-usage"])
15
+
16
+
17
+ def _parse_date(value: str, name: str) -> dt_date:
18
+ try:
19
+ return datetime.strptime(value, "%Y-%m-%d").date()
20
+ except ValueError:
21
+ raise HTTPException(
22
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
23
+ detail=f"{name} must be a YYYY-MM-DD date",
24
+ )
25
+
26
+
27
+ def _window_kwargs(
28
+ days: int | None,
29
+ start: str | None,
30
+ end: str | None,
31
+ all_data: bool,
32
+ ) -> dict:
33
+ """Validate window query params into UsageService kwargs.
34
+
35
+ Precedence: all > start/end range > days > default (7, applied by the
36
+ service). ``start`` and ``end`` must be provided together.
37
+ """
38
+ if all_data:
39
+ return {"all_data": True}
40
+ if start is not None or end is not None:
41
+ if start is None or end is None:
42
+ raise HTTPException(
43
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
44
+ detail="start and end must be provided together",
45
+ )
46
+ start_date = _parse_date(start, "start")
47
+ end_date = _parse_date(end, "end")
48
+ if start_date > end_date:
49
+ raise HTTPException(
50
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
51
+ detail="start must be on or before end",
52
+ )
53
+ return {"start": start_date, "end": end_date}
54
+ if days is not None:
55
+ if not 1 <= days <= MAX_WINDOW_DAYS:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
58
+ detail=f"days must be between 1 and {MAX_WINDOW_DAYS}",
59
+ )
60
+ return {"days": days}
61
+ return {}
62
+
63
+
64
+ def _handle(callable_, *args, **kwargs):
65
+ try:
66
+ return callable_(*args, **kwargs)
67
+ except HTTPException:
68
+ raise
69
+ except Exception as e:
70
+ logger.error(f"Usage analytics error: {e}", exc_info=True)
71
+ raise HTTPException(
72
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
73
+ detail="Failed to compute usage analytics",
74
+ )
75
+
76
+
77
+ _DAYS = Query(default=None, description=f"Rolling window, 1..{MAX_WINDOW_DAYS}")
78
+ _START = Query(default=None, description="Range start (YYYY-MM-DD, inclusive)")
79
+ _END = Query(default=None, description="Range end (YYYY-MM-DD, inclusive)")
80
+ _ALL = Query(default=False, alias="all", description="Report over all data")
81
+
82
+
83
+ @router.get("/summary")
84
+ def get_summary(
85
+ days: int | None = _DAYS,
86
+ start: str | None = _START,
87
+ end: str | None = _END,
88
+ all_data: bool = _ALL,
89
+ db: Session = Depends(get_db),
90
+ ):
91
+ kwargs = _window_kwargs(days, start, end, all_data)
92
+ return _handle(UsageService().get_summary, db, **kwargs)
93
+
94
+
95
+ @router.get("/daily")
96
+ def get_daily(
97
+ days: int | None = _DAYS,
98
+ start: str | None = _START,
99
+ end: str | None = _END,
100
+ all_data: bool = _ALL,
101
+ db: Session = Depends(get_db),
102
+ ):
103
+ kwargs = _window_kwargs(days, start, end, all_data)
104
+ return _handle(UsageService().get_daily, db, **kwargs)
105
+
106
+
107
+ @router.get("/top-users")
108
+ def get_top_users(
109
+ days: int | None = _DAYS,
110
+ start: str | None = _START,
111
+ end: str | None = _END,
112
+ all_data: bool = _ALL,
113
+ db: Session = Depends(get_db),
114
+ ):
115
+ kwargs = _window_kwargs(days, start, end, all_data)
116
+ return _handle(UsageService().get_top_users, db, **kwargs)
117
+
118
+
119
+ @router.get("/funnel")
120
+ def get_funnel(
121
+ days: int | None = _DAYS,
122
+ start: str | None = _START,
123
+ end: str | None = _END,
124
+ all_data: bool = _ALL,
125
+ db: Session = Depends(get_db),
126
+ ):
127
+ kwargs = _window_kwargs(days, start, end, all_data)
128
+ return _handle(UsageService().get_funnel, db, **kwargs)
129
+
130
+
131
+ @router.get("/retention")
132
+ def get_retention(db: Session = Depends(get_db)):
133
+ return _handle(UsageService().get_retention, db)
134
+
135
+
136
+ @router.get("/heatmap")
137
+ def get_heatmap(
138
+ days: int | None = _DAYS,
139
+ start: str | None = _START,
140
+ end: str | None = _END,
141
+ all_data: bool = _ALL,
142
+ db: Session = Depends(get_db),
143
+ ):
144
+ kwargs = _window_kwargs(days, start, end, all_data)
145
+ return _handle(UsageService().get_heatmap, db, **kwargs)
@@ -10,6 +10,8 @@ use `user_sessions` as a labeled proxy.
10
10
  """
11
11
 
12
12
  from collections import defaultdict
13
+ from dataclasses import dataclass
14
+ from datetime import date as dt_date
13
15
  from datetime import datetime, timedelta
14
16
  from datetime import time as dt_time
15
17
 
@@ -28,7 +30,68 @@ from src.database.models.usage_event import (
28
30
  EVENT_LOGIN,
29
31
  )
30
32
 
33
+ # Preset windows offered by the UI; the API accepts any 1..MAX_WINDOW_DAYS,
34
+ # an explicit start/end date range, or all=true for the full history.
31
35
  ALLOWED_WINDOWS = {7, 14, 21, 28}
36
+ MAX_WINDOW_DAYS = 365
37
+ # Hard cap on rows returned by get_daily in all-data/range mode
38
+ MAX_DAILY_ROWS = 730
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Window:
43
+ """Resolved reporting window.
44
+
45
+ ``start`` is an inclusive datetime lower bound (None = beginning of data);
46
+ ``end`` is an exclusive upper bound. ``days`` is set only in preset/days
47
+ mode and is echoed back in responses for backward compatibility.
48
+ """
49
+
50
+ start: datetime | None
51
+ end: datetime
52
+ days: int | None
53
+ all_data: bool
54
+
55
+ def to_meta(self) -> dict:
56
+ return {
57
+ "days": self.days,
58
+ "start": self.start.strftime("%Y-%m-%d") if self.start else None,
59
+ "end": (self.end - timedelta(days=1)).strftime("%Y-%m-%d"),
60
+ "all": self.all_data,
61
+ }
62
+
63
+
64
+ def resolve_window(
65
+ days: int | None = None,
66
+ start: dt_date | None = None,
67
+ end: dt_date | None = None,
68
+ all_data: bool = False,
69
+ ) -> Window:
70
+ """Resolve query params into a concrete Window.
71
+
72
+ Precedence: all_data > explicit start/end range > days > default 7 days.
73
+ ``end`` is inclusive as a date (callers pass the last day they want to
74
+ see); internally it becomes an exclusive midnight bound.
75
+ """
76
+ tomorrow = datetime.combine(
77
+ datetime.utcnow().date() + timedelta(days=1), dt_time.min
78
+ )
79
+ if all_data:
80
+ return Window(start=None, end=tomorrow, days=None, all_data=True)
81
+ if start is not None and end is not None:
82
+ return Window(
83
+ start=datetime.combine(start, dt_time.min),
84
+ end=datetime.combine(end + timedelta(days=1), dt_time.min),
85
+ days=None,
86
+ all_data=False,
87
+ )
88
+ resolved_days = days if days is not None else 7
89
+ return Window(
90
+ start=_window_start(resolved_days),
91
+ end=tomorrow,
92
+ days=resolved_days,
93
+ all_data=False,
94
+ )
32
95
 
33
96
  # Route template logged by the request-logging middleware for deck opens
34
97
  _DECK_OPEN_ROUTE = "/api/sessions/{session_id}"
@@ -58,35 +121,50 @@ def _window_start(days: int) -> datetime:
58
121
  return datetime.combine(today - timedelta(days=days - 1), dt_time.min)
59
122
 
60
123
 
61
- def _day_list(days: int) -> list[str]:
62
- today = datetime.utcnow().date()
63
- return [
64
- (today - timedelta(days=offset)).strftime("%Y-%m-%d")
65
- for offset in range(days - 1, -1, -1)
66
- ]
67
-
68
-
69
124
  class UsageService:
70
125
  """Aggregates usage metrics for /api/admin/usage endpoints."""
71
126
 
72
127
  # ---------- shared helpers ----------
73
128
 
74
- def _events_in_window(self, db: Session, start: datetime) -> list[UsageEvent]:
75
- return db.query(UsageEvent).filter(UsageEvent.ts >= start).all()
76
-
77
- def _sessions_in_window(self, db: Session, start: datetime) -> list[UserSession]:
78
- return (
79
- db.query(UserSession)
80
- .filter(UserSession.created_at >= start)
81
- .all()
82
- )
83
-
84
- def _decks_in_window(self, db: Session, start: datetime) -> list[SessionSlideDeck]:
85
- return (
86
- db.query(SessionSlideDeck)
87
- .filter(SessionSlideDeck.created_at >= start)
88
- .all()
89
- )
129
+ def _events_in_window(self, db: Session, w: Window) -> list[UsageEvent]:
130
+ query = db.query(UsageEvent).filter(UsageEvent.ts < w.end)
131
+ if w.start is not None:
132
+ query = query.filter(UsageEvent.ts >= w.start)
133
+ return query.all()
134
+
135
+ def _sessions_in_window(self, db: Session, w: Window) -> list[UserSession]:
136
+ query = db.query(UserSession).filter(UserSession.created_at < w.end)
137
+ if w.start is not None:
138
+ query = query.filter(UserSession.created_at >= w.start)
139
+ return query.all()
140
+
141
+ def _decks_in_window(self, db: Session, w: Window) -> list[SessionSlideDeck]:
142
+ query = db.query(SessionSlideDeck).filter(SessionSlideDeck.created_at < w.end)
143
+ if w.start is not None:
144
+ query = query.filter(SessionSlideDeck.created_at >= w.start)
145
+ return query.all()
146
+
147
+ def _window_day_list(self, w: Window, earliest: datetime | None) -> list[str]:
148
+ """Calendar days covered by the window, oldest first, capped.
149
+
150
+ In all-data mode the range starts at the earliest observed data point
151
+ (or today when the database is empty).
152
+ """
153
+ last_day = (w.end - timedelta(days=1)).date()
154
+ if w.start is not None:
155
+ first_day = w.start.date()
156
+ elif earliest is not None:
157
+ first_day = earliest.date()
158
+ else:
159
+ first_day = last_day
160
+ total = (last_day - first_day).days + 1
161
+ if total > MAX_DAILY_ROWS:
162
+ first_day = last_day - timedelta(days=MAX_DAILY_ROWS - 1)
163
+ total = MAX_DAILY_ROWS
164
+ return [
165
+ (first_day + timedelta(days=offset)).strftime("%Y-%m-%d")
166
+ for offset in range(total)
167
+ ]
90
168
 
91
169
  def _history_boundary(self, db: Session) -> datetime | None:
92
170
  first = (
@@ -119,8 +197,15 @@ class UsageService:
119
197
 
120
198
  # ---------- endpoints ----------
121
199
 
122
- def get_summary(self, db: Session, days: int) -> dict:
123
- start = _window_start(days)
200
+ def get_summary(
201
+ self,
202
+ db: Session,
203
+ days: int | None = None,
204
+ start: dt_date | None = None,
205
+ end: dt_date | None = None,
206
+ all_data: bool = False,
207
+ ) -> dict:
208
+ w = resolve_window(days=days, start=start, end=end, all_data=all_data)
124
209
 
125
210
  identity_users = {
126
211
  name
@@ -149,25 +234,23 @@ class UsageService:
149
234
  }
150
235
  total_decks_ever = len(live_deck_session_ids) + len(orphaned_deck_events)
151
236
 
152
- events = self._events_in_window(db, start)
153
- sessions = self._sessions_in_window(db, start)
154
- active_from_last_activity = {
155
- name
156
- for (name,) in db.query(UserSession.created_by).filter(
157
- UserSession.last_activity >= start,
158
- UserSession.created_by.isnot(None),
237
+ events = self._events_in_window(db, w)
238
+ sessions = self._sessions_in_window(db, w)
239
+ activity_query = db.query(UserSession.created_by).filter(
240
+ UserSession.last_activity < w.end,
241
+ UserSession.created_by.isnot(None),
242
+ )
243
+ if w.start is not None:
244
+ activity_query = activity_query.filter(
245
+ UserSession.last_activity >= w.start
159
246
  )
160
- }
247
+ active_from_last_activity = {name for (name,) in activity_query}
161
248
  active_users = (
162
249
  {e.username for e in events}
163
250
  | {s.created_by for s in sessions if s.created_by}
164
251
  | active_from_last_activity
165
252
  )
166
- decks_created = (
167
- db.query(SessionSlideDeck)
168
- .filter(SessionSlideDeck.created_at >= start)
169
- .count()
170
- )
253
+ decks_created = len(self._decks_in_window(db, w))
171
254
  logins = _login_visit_count(
172
255
  e for e in events if e.event_type == EVENT_LOGIN
173
256
  )
@@ -177,7 +260,7 @@ class UsageService:
177
260
  "total_users_ever": total_users_ever,
178
261
  "total_decks_ever": total_decks_ever,
179
262
  "window": {
180
- "days": days,
263
+ **w.to_meta(),
181
264
  "active_users": len(active_users),
182
265
  "decks_created": decks_created,
183
266
  "avg_decks_per_active_user": avg,
@@ -185,14 +268,21 @@ class UsageService:
185
268
  },
186
269
  }
187
270
 
188
- def get_daily(self, db: Session, days: int) -> dict:
189
- start = _window_start(days)
271
+ def get_daily(
272
+ self,
273
+ db: Session,
274
+ days: int | None = None,
275
+ start: dt_date | None = None,
276
+ end: dt_date | None = None,
277
+ all_data: bool = False,
278
+ ) -> dict:
279
+ w = resolve_window(days=days, start=start, end=end, all_data=all_data)
190
280
  boundary = self._history_boundary(db)
191
281
  boundary_day = _day_key(boundary) if boundary else None
192
282
 
193
- events = self._events_in_window(db, start)
194
- sessions = self._sessions_in_window(db, start)
195
- decks = self._decks_in_window(db, start)
283
+ events = self._events_in_window(db, w)
284
+ sessions = self._sessions_in_window(db, w)
285
+ decks = self._decks_in_window(db, w)
196
286
  first_seen = self._first_seen_map(db)
197
287
 
198
288
  login_events_by_day: dict[str, list[UsageEvent]] = defaultdict(list)
@@ -225,16 +315,31 @@ class UsageService:
225
315
 
226
316
  # Anonymous deck-open volume proxy from request_logs (30-day retention)
227
317
  proxy_retrievals_by_day: dict[str, int] = defaultdict(int)
228
- for (ts,) in db.query(RequestLog.timestamp).filter(
229
- RequestLog.timestamp >= start,
318
+ proxy_query = db.query(RequestLog.timestamp).filter(
319
+ RequestLog.timestamp < w.end,
230
320
  RequestLog.method == "GET",
231
321
  RequestLog.path == _DECK_OPEN_ROUTE,
232
322
  RequestLog.status_code < 400,
233
- ):
323
+ )
324
+ if w.start is not None:
325
+ proxy_query = proxy_query.filter(RequestLog.timestamp >= w.start)
326
+ for (ts,) in proxy_query:
234
327
  proxy_retrievals_by_day[_day_key(ts)] += 1
235
328
 
329
+ earliest = min(
330
+ (
331
+ ts
332
+ for ts in (
333
+ min((e.ts for e in events), default=None),
334
+ min((s.created_at for s in sessions), default=None),
335
+ min((d.created_at for d in decks), default=None),
336
+ )
337
+ if ts is not None
338
+ ),
339
+ default=None,
340
+ )
236
341
  rows = []
237
- for day in _day_list(days):
342
+ for day in self._window_day_list(w, earliest):
238
343
  pre_boundary = boundary_day is None or day < boundary_day
239
344
  if pre_boundary:
240
345
  logins = sessions_by_day.get(day, 0)
@@ -263,36 +368,51 @@ class UsageService:
263
368
  ),
264
369
  }
265
370
  )
266
- return {"history_boundary": boundary_day, "days": rows}
371
+ return {
372
+ "history_boundary": boundary_day,
373
+ "window": w.to_meta(),
374
+ "days": rows,
375
+ }
267
376
 
268
- def get_top_users(self, db: Session, days: int) -> list[dict]:
269
- start = _window_start(days)
377
+ def _deck_creator_rows(self, db: Session, w: Window) -> list:
378
+ query = (
379
+ db.query(UserSession.created_by)
380
+ .join(SessionSlideDeck, SessionSlideDeck.session_id == UserSession.id)
381
+ .filter(
382
+ SessionSlideDeck.created_at < w.end,
383
+ UserSession.created_by.isnot(None),
384
+ )
385
+ )
386
+ if w.start is not None:
387
+ query = query.filter(SessionSlideDeck.created_at >= w.start)
388
+ return query.all()
389
+
390
+ def get_top_users(
391
+ self,
392
+ db: Session,
393
+ days: int | None = None,
394
+ start: dt_date | None = None,
395
+ end: dt_date | None = None,
396
+ all_data: bool = False,
397
+ ) -> list[dict]:
398
+ w = resolve_window(days=days, start=start, end=end, all_data=all_data)
270
399
  stats: dict[str, dict] = defaultdict(
271
400
  lambda: {"logins": 0, "sessions_created": 0, "decks_created": 0}
272
401
  )
273
402
 
274
403
  login_events_by_user: dict[str, list[UsageEvent]] = defaultdict(list)
275
- for e in self._events_in_window(db, start):
404
+ for e in self._events_in_window(db, w):
276
405
  if e.event_type == EVENT_LOGIN:
277
406
  login_events_by_user[e.username].append(e)
278
407
  for name, user_events in login_events_by_user.items():
279
408
  # Collapse duplicate per-worker login rows into visits
280
409
  stats[name]["logins"] = _login_visit_count(user_events)
281
410
 
282
- for s in self._sessions_in_window(db, start):
411
+ for s in self._sessions_in_window(db, w):
283
412
  if s.created_by:
284
413
  stats[s.created_by]["sessions_created"] += 1
285
414
 
286
- deck_rows = (
287
- db.query(UserSession.created_by)
288
- .join(SessionSlideDeck, SessionSlideDeck.session_id == UserSession.id)
289
- .filter(
290
- SessionSlideDeck.created_at >= start,
291
- UserSession.created_by.isnot(None),
292
- )
293
- .all()
294
- )
295
- for (name,) in deck_rows:
415
+ for (name,) in self._deck_creator_rows(db, w):
296
416
  stats[name]["decks_created"] += 1
297
417
 
298
418
  ranked = sorted(
@@ -305,15 +425,22 @@ class UsageService:
305
425
  )
306
426
  return ranked[:20]
307
427
 
308
- def get_funnel(self, db: Session, days: int) -> dict:
309
- start = _window_start(days)
310
- events = self._events_in_window(db, start)
428
+ def get_funnel(
429
+ self,
430
+ db: Session,
431
+ days: int | None = None,
432
+ start: dt_date | None = None,
433
+ end: dt_date | None = None,
434
+ all_data: bool = False,
435
+ ) -> dict:
436
+ w = resolve_window(days=days, start=start, end=end, all_data=all_data)
437
+ events = self._events_in_window(db, w)
311
438
 
312
439
  login_events = [e for e in events if e.event_type == EVENT_LOGIN]
313
440
  proxy = len(login_events) == 0
314
441
 
315
442
  if proxy:
316
- sessions = self._sessions_in_window(db, start)
443
+ sessions = self._sessions_in_window(db, w)
317
444
  logins = len(sessions)
318
445
  logged_in_users = {s.created_by for s in sessions if s.created_by}
319
446
  else:
@@ -323,20 +450,8 @@ class UsageService:
323
450
  deck_event_users = {
324
451
  e.username for e in events if e.event_type == EVENT_DECK_CREATED
325
452
  }
326
- deck_session_users = {
327
- name
328
- for (name,) in db.query(UserSession.created_by)
329
- .join(SessionSlideDeck, SessionSlideDeck.session_id == UserSession.id)
330
- .filter(
331
- SessionSlideDeck.created_at >= start,
332
- UserSession.created_by.isnot(None),
333
- )
334
- }
335
- decks_created = (
336
- db.query(SessionSlideDeck)
337
- .filter(SessionSlideDeck.created_at >= start)
338
- .count()
339
- )
453
+ deck_session_users = {name for (name,) in self._deck_creator_rows(db, w)}
454
+ decks_created = len(self._decks_in_window(db, w))
340
455
  return {
341
456
  "logins": logins,
342
457
  "users_who_logged_in": len(logged_in_users),
@@ -388,13 +503,20 @@ class UsageService:
388
503
  )
389
504
  return rows
390
505
 
391
- def get_heatmap(self, db: Session, days: int) -> dict:
392
- start = _window_start(days)
506
+ def get_heatmap(
507
+ self,
508
+ db: Session,
509
+ days: int | None = None,
510
+ start: dt_date | None = None,
511
+ end: dt_date | None = None,
512
+ all_data: bool = False,
513
+ ) -> dict:
514
+ w = resolve_window(days=days, start=start, end=end, all_data=all_data)
393
515
  matrix = [[0] * 24 for _ in range(7)]
394
516
 
395
- for e in self._events_in_window(db, start):
517
+ for e in self._events_in_window(db, w):
396
518
  matrix[e.ts.weekday()][e.ts.hour] += 1
397
- for s in self._sessions_in_window(db, start):
519
+ for s in self._sessions_in_window(db, w):
398
520
  matrix[s.created_at.weekday()][s.created_at.hour] += 1
399
521
 
400
522
  return {"matrix": matrix, "max": max(max(row) for row in matrix)}
@@ -1,69 +0,0 @@
1
- """Admin usage-analytics endpoints (ungated, like the rest of /admin)."""
2
-
3
- import logging
4
-
5
- from fastapi import APIRouter, Depends, HTTPException, Query, status
6
- from sqlalchemy.orm import Session
7
-
8
- from src.api.services.usage_service import ALLOWED_WINDOWS, UsageService
9
- from src.core.database import get_db
10
-
11
- logger = logging.getLogger(__name__)
12
- router = APIRouter(prefix="/api/admin/usage", tags=["admin-usage"])
13
-
14
-
15
- def _validated_days(days: int) -> int:
16
- if days not in ALLOWED_WINDOWS:
17
- raise HTTPException(
18
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
19
- detail=f"days must be one of {sorted(ALLOWED_WINDOWS)}",
20
- )
21
- return days
22
-
23
-
24
- def _handle(callable_, *args, **kwargs):
25
- try:
26
- return callable_(*args, **kwargs)
27
- except HTTPException:
28
- raise
29
- except Exception as e:
30
- logger.error(f"Usage analytics error: {e}", exc_info=True)
31
- raise HTTPException(
32
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
33
- detail="Failed to compute usage analytics",
34
- )
35
-
36
-
37
- @router.get("/summary")
38
- def get_summary(days: int = Query(default=7), db: Session = Depends(get_db)):
39
- days = _validated_days(days)
40
- return _handle(UsageService().get_summary, db, days=days)
41
-
42
-
43
- @router.get("/daily")
44
- def get_daily(days: int = Query(default=7), db: Session = Depends(get_db)):
45
- days = _validated_days(days)
46
- return _handle(UsageService().get_daily, db, days=days)
47
-
48
-
49
- @router.get("/top-users")
50
- def get_top_users(days: int = Query(default=7), db: Session = Depends(get_db)):
51
- days = _validated_days(days)
52
- return _handle(UsageService().get_top_users, db, days=days)
53
-
54
-
55
- @router.get("/funnel")
56
- def get_funnel(days: int = Query(default=7), db: Session = Depends(get_db)):
57
- days = _validated_days(days)
58
- return _handle(UsageService().get_funnel, db, days=days)
59
-
60
-
61
- @router.get("/retention")
62
- def get_retention(db: Session = Depends(get_db)):
63
- return _handle(UsageService().get_retention, db)
64
-
65
-
66
- @router.get("/heatmap")
67
- def get_heatmap(days: int = Query(default=7), db: Session = Depends(get_db)):
68
- days = _validated_days(days)
69
- return _handle(UsageService().get_heatmap, db, days=days)