Spaces:
Running on Zero
Running on Zero
Commit ·
d925e38
1
Parent(s): 34c3133
track join-step blocker
Browse files- app/bot/delivery.py +2 -1
- app/bot/handlers/__init__.py +3 -1
- app/bot/handlers/analytics.py +79 -0
- app/bot/handlers/channel_posts.py +44 -0
- app/bot/handlers/groups.py +3 -0
- app/bot/handlers/inline.py +8 -1
- app/bot/handlers/library.py +12 -6
- app/bot/handlers/search.py +2 -1
- app/bot/keyboards/library.py +8 -2
- app/bot/manual.py +9 -0
- app/db/models/__init__.py +2 -1
- app/db/models/analytics.py +33 -0
- app/db/repos/analytics.py +97 -0
- app/db/repos/library.py +14 -0
- app/services/analytics.py +450 -0
- app/services/library.py +10 -2
- locales/en.json +5 -2
- tests/test_logic.py +124 -0
app/bot/delivery.py
CHANGED
|
@@ -9,7 +9,7 @@ from app.bot.keyboards import library as lib_kb
|
|
| 9 |
from app.bot.keyboards.common import share_kb
|
| 10 |
from app.core import season
|
| 11 |
from app.core.i18n import t
|
| 12 |
-
from app.db.models import Material, User
|
| 13 |
from app.db.repos import library as repo
|
| 14 |
from app.services import growth
|
| 15 |
from app.services import library as lib
|
|
@@ -41,6 +41,7 @@ async def deliver_material(bot: Bot, session: AsyncSession, user: User, material
|
|
| 41 |
except TelegramBadRequest:
|
| 42 |
return False
|
| 43 |
material.downloads = (material.downloads or 0) + 1
|
|
|
|
| 44 |
res = await xp_service.award(session, user, "download", 1, ref=f"{material.id}:{season.day_key()}")
|
| 45 |
if res.leveled_up:
|
| 46 |
await xp_service.announce_level(bot, user, res.new_level)
|
|
|
|
| 9 |
from app.bot.keyboards.common import share_kb
|
| 10 |
from app.core import season
|
| 11 |
from app.core.i18n import t
|
| 12 |
+
from app.db.models import ActivityEvent, Material, User
|
| 13 |
from app.db.repos import library as repo
|
| 14 |
from app.services import growth
|
| 15 |
from app.services import library as lib
|
|
|
|
| 41 |
except TelegramBadRequest:
|
| 42 |
return False
|
| 43 |
material.downloads = (material.downloads or 0) + 1
|
| 44 |
+
session.add(ActivityEvent(user_id=user.id, kind="download", ref=str(material.id), n=1))
|
| 45 |
res = await xp_service.award(session, user, "download", 1, ref=f"{material.id}:{season.day_key()}")
|
| 46 |
if res.leveled_up:
|
| 47 |
await xp_service.announce_level(bot, user, res.new_level)
|
app/bot/handlers/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
-
from app.bot.handlers import admin, admin_queue, contribute, gate, groups, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
@@ -9,6 +9,8 @@ def build_router() -> Router:
|
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
| 11 |
router.include_router(admin_queue.router)
|
|
|
|
|
|
|
| 12 |
router.include_router(groups.router)
|
| 13 |
router.include_router(start.router)
|
| 14 |
router.include_router(menu.router)
|
|
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
+
from app.bot.handlers import admin, admin_queue, analytics, channel_posts, contribute, gate, groups, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
|
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
| 11 |
router.include_router(admin_queue.router)
|
| 12 |
+
router.include_router(analytics.router)
|
| 13 |
+
router.include_router(channel_posts.router)
|
| 14 |
router.include_router(groups.router)
|
| 15 |
router.include_router(start.router)
|
| 16 |
router.include_router(menu.router)
|
app/bot/handlers/analytics.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/analyze: an admin-only analytics screen with section buttons. Each section is computed on its own,
|
| 2 |
+
so one failing query never hides the others."""
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from aiogram import F, Router
|
| 6 |
+
from aiogram.filters import Command
|
| 7 |
+
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.bot.handlers.admin import IsAdmin
|
| 11 |
+
from app.bot.handlers.channel_posts import is_main_channel
|
| 12 |
+
from app.db.repos import analytics as analytics_repo
|
| 13 |
+
from app.services import analytics as A
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger(__name__)
|
| 16 |
+
router = Router()
|
| 17 |
+
router.message.filter(IsAdmin())
|
| 18 |
+
router.callback_query.filter(IsAdmin())
|
| 19 |
+
|
| 20 |
+
SECTIONS = {
|
| 21 |
+
"growth": ("👥 Growth", A.section_growth),
|
| 22 |
+
"retention": ("🔁 Retention", A.section_retention),
|
| 23 |
+
"library": ("📚 Library", A.section_library),
|
| 24 |
+
"contrib": ("📤 Student files", A.section_contrib),
|
| 25 |
+
"channel": ("📢 Channel posts", A.section_channel),
|
| 26 |
+
"insights": ("💡 Insights", A.section_insights),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def keyboard(current: str) -> InlineKeyboardMarkup:
|
| 31 |
+
buttons = [
|
| 32 |
+
InlineKeyboardButton(text=("• " if key == current else "") + label, callback_data=f"az:{key}")
|
| 33 |
+
for key, (label, _) in SECTIONS.items()
|
| 34 |
+
]
|
| 35 |
+
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
| 36 |
+
rows.append([InlineKeyboardButton(text=("• " if current == "home" else "") + "🏠 Overview", callback_data="az:home")])
|
| 37 |
+
return InlineKeyboardMarkup(inline_keyboard=rows)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def render(fn, snap: A.Snapshot) -> str:
|
| 41 |
+
try:
|
| 42 |
+
text = fn(snap)
|
| 43 |
+
except Exception as exc: # one broken section must not hide the rest
|
| 44 |
+
log.exception("Analytics section failed")
|
| 45 |
+
text = f"⚠️ This section failed: {type(exc).__name__}: {exc}"
|
| 46 |
+
if len(text) > 4000:
|
| 47 |
+
text = text[: text.rfind("\n", 0, 3900)] + "\n…(cut: too long for one message)"
|
| 48 |
+
return text
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.message(Command("analyze"))
|
| 52 |
+
async def analyze(message: Message, session: AsyncSession) -> None:
|
| 53 |
+
snap = await analytics_repo.load(session)
|
| 54 |
+
await message.answer(render(A.overview, snap), reply_markup=keyboard("home"))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.callback_query(F.data.startswith("az:"))
|
| 58 |
+
async def section(call: CallbackQuery, session: AsyncSession) -> None:
|
| 59 |
+
key = call.data.split(":")[1]
|
| 60 |
+
fn = A.overview if key == "home" else SECTIONS.get(key, (None, A.overview))[1]
|
| 61 |
+
snap = await analytics_repo.load(session)
|
| 62 |
+
await call.answer()
|
| 63 |
+
try:
|
| 64 |
+
await call.message.edit_text(render(fn, snap), reply_markup=keyboard(key))
|
| 65 |
+
except Exception:
|
| 66 |
+
pass # "message is not modified": the same numbers
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.message(F.chat.type == "private", F.text, F.forward_origin.type == "channel")
|
| 70 |
+
async def add_forwarded_post(message: Message, session: AsyncSession) -> None:
|
| 71 |
+
"""Forward an older channel post (text) here to add it to the channel analysis."""
|
| 72 |
+
origin = message.forward_origin
|
| 73 |
+
if not is_main_channel(origin.chat):
|
| 74 |
+
await message.answer("That post is from another channel; only your main channel is analysed.")
|
| 75 |
+
return
|
| 76 |
+
await analytics_repo.save_post(
|
| 77 |
+
session, chat_id=origin.chat.id, message_id=origin.message_id, posted_at=origin.date, text=message.text
|
| 78 |
+
)
|
| 79 |
+
await message.answer("✅ Post added. See it in /analyze → Channel posts (reactions of old posts are unknown).")
|
app/bot/handlers/channel_posts.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Records the main channel's posts and their reactions, so /analyze can link posts to new students.
|
| 2 |
+
Telegram's Bot API does NOT give bots a post's views or shares (only the Telegram app shows those);
|
| 3 |
+
reactions are delivered as counts when the bot is an admin of the channel."""
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from aiogram import Router
|
| 7 |
+
from aiogram.types import Chat, Message, MessageReactionCountUpdated
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
from app.db.repos import analytics as analytics_repo
|
| 11 |
+
from app.db.session import SessionFactory
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger(__name__)
|
| 14 |
+
router = Router()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_main_channel(chat: Chat) -> bool:
|
| 18 |
+
return (chat.username or "").lower() == get_settings().gate_list[0].lstrip("@").lower()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.channel_post()
|
| 22 |
+
async def on_channel_post(post: Message) -> None:
|
| 23 |
+
if not is_main_channel(post.chat):
|
| 24 |
+
return
|
| 25 |
+
try:
|
| 26 |
+
async with SessionFactory() as session:
|
| 27 |
+
await analytics_repo.save_post(
|
| 28 |
+
session, chat_id=post.chat.id, message_id=post.message_id, posted_at=post.date, text=post.text or post.caption or ""
|
| 29 |
+
)
|
| 30 |
+
await session.commit()
|
| 31 |
+
except Exception:
|
| 32 |
+
log.exception("Could not record a channel post")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.message_reaction_count()
|
| 36 |
+
async def on_reactions(event: MessageReactionCountUpdated) -> None:
|
| 37 |
+
if not is_main_channel(event.chat):
|
| 38 |
+
return
|
| 39 |
+
try:
|
| 40 |
+
async with SessionFactory() as session:
|
| 41 |
+
await analytics_repo.set_reactions(session, event.chat.id, event.message_id, sum(r.total_count for r in event.reactions))
|
| 42 |
+
await session.commit()
|
| 43 |
+
except Exception:
|
| 44 |
+
log.exception("Could not record reactions")
|
app/bot/handlers/groups.py
CHANGED
|
@@ -12,6 +12,7 @@ from aiogram.types import ChatMemberUpdated, InlineKeyboardButton, InlineKeyboar
|
|
| 12 |
from app.bot.keyboards.common import open_bot_kb
|
| 13 |
from app.bot.keyboards.library import clip, course_icon, course_label
|
| 14 |
from app.core.i18n import t
|
|
|
|
| 15 |
from app.db.repos import groups as group_repo
|
| 16 |
from app.db.session import SessionFactory
|
| 17 |
from app.services import growth
|
|
@@ -60,6 +61,8 @@ async def find_in_group(message: Message, command: CommandObject) -> None:
|
|
| 60 |
return
|
| 61 |
async with SessionFactory() as session:
|
| 62 |
courses, materials = await lib.search(session, query, course_limit=4, material_limit=4)
|
|
|
|
|
|
|
| 63 |
if not courses and not materials:
|
| 64 |
await message.reply(t("en", "group_find_none"), reply_markup=open_bot_kb(open_link))
|
| 65 |
return
|
|
|
|
| 12 |
from app.bot.keyboards.common import open_bot_kb
|
| 13 |
from app.bot.keyboards.library import clip, course_icon, course_label
|
| 14 |
from app.core.i18n import t
|
| 15 |
+
from app.db.models import ActivityEvent
|
| 16 |
from app.db.repos import groups as group_repo
|
| 17 |
from app.db.session import SessionFactory
|
| 18 |
from app.services import growth
|
|
|
|
| 61 |
return
|
| 62 |
async with SessionFactory() as session:
|
| 63 |
courses, materials = await lib.search(session, query, course_limit=4, material_limit=4)
|
| 64 |
+
session.add(ActivityEvent(user_id=message.from_user.id, kind="find", ref=query[:100].lower(), n=len(courses) + len(materials)))
|
| 65 |
+
await session.commit()
|
| 66 |
if not courses and not materials:
|
| 67 |
await message.reply(t("en", "group_find_none"), reply_markup=open_bot_kb(open_link))
|
| 68 |
return
|
app/bot/handlers/inline.py
CHANGED
|
@@ -18,7 +18,7 @@ from aiogram.types import (
|
|
| 18 |
|
| 19 |
from app.config import get_settings
|
| 20 |
from app.core.i18n import t
|
| 21 |
-
from app.db.models import Course, Material
|
| 22 |
from app.db.repos import library as repo
|
| 23 |
from app.db.session import SessionFactory
|
| 24 |
from app.services import library as lib
|
|
@@ -80,3 +80,10 @@ async def inline_search(query: InlineQuery) -> None:
|
|
| 80 |
is_personal=False,
|
| 81 |
button=InlineQueryResultsButton(text=t("en", "inline_open_bot"), start_parameter="inline"),
|
| 82 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from app.config import get_settings
|
| 20 |
from app.core.i18n import t
|
| 21 |
+
from app.db.models import ActivityEvent, Course, Material
|
| 22 |
from app.db.repos import library as repo
|
| 23 |
from app.db.session import SessionFactory
|
| 24 |
from app.services import library as lib
|
|
|
|
| 80 |
is_personal=False,
|
| 81 |
button=InlineQueryResultsButton(text=t("en", "inline_open_bot"), start_parameter="inline"),
|
| 82 |
)
|
| 83 |
+
if len(text) >= 2: # remember what was searched (after answering, so the student never waits for it)
|
| 84 |
+
try:
|
| 85 |
+
async with SessionFactory() as session:
|
| 86 |
+
session.add(ActivityEvent(user_id=query.from_user.id, kind="inline", ref=text[:100].lower(), n=len(results)))
|
| 87 |
+
await session.commit()
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
app/bot/handlers/library.py
CHANGED
|
@@ -40,15 +40,16 @@ async def _show(target: Union[Message, CallbackQuery], text: str, markup) -> Non
|
|
| 40 |
await target.answer(text, reply_markup=markup)
|
| 41 |
|
| 42 |
|
| 43 |
-
async def _course_payload(user: User, session: AsyncSession, page: int):
|
| 44 |
-
items, total = await lib.courses_page(session, user, page)
|
|
|
|
| 45 |
if not items:
|
| 46 |
-
return t(user.lang, "lib_no_courses"), None
|
| 47 |
-
return t(user.lang, "
|
| 48 |
|
| 49 |
|
| 50 |
-
async def _courses(target, user: User, session: AsyncSession, page: int) -> None:
|
| 51 |
-
text, markup = await _course_payload(user, session, page)
|
| 52 |
await _show(target, text, markup)
|
| 53 |
|
| 54 |
|
|
@@ -62,6 +63,11 @@ async def on_courses(call: CallbackQuery, user: User, session: AsyncSession) ->
|
|
| 62 |
await _courses(call, user, session, int(call.data.split(":")[2]))
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
@router.callback_query(F.data.startswith("lib:k:"))
|
| 66 |
async def on_kinds(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 67 |
course_id = int(call.data.split(":")[2])
|
|
|
|
| 40 |
await target.answer(text, reply_markup=markup)
|
| 41 |
|
| 42 |
|
| 43 |
+
async def _course_payload(user: User, session: AsyncSession, page: int, scope: str = "c"):
|
| 44 |
+
items, total, more_elsewhere = await lib.courses_page(session, user, page, scope)
|
| 45 |
+
markup = kb.courses(user.lang, items, page, total, lib.PAGE_SIZE, scope, more_elsewhere)
|
| 46 |
if not items:
|
| 47 |
+
return t(user.lang, "lib_no_courses"), (markup if markup.inline_keyboard else None)
|
| 48 |
+
return t(user.lang, "lib_courses_all" if scope == "a" else "lib_courses"), markup
|
| 49 |
|
| 50 |
|
| 51 |
+
async def _courses(target, user: User, session: AsyncSession, page: int, scope: str = "c") -> None:
|
| 52 |
+
text, markup = await _course_payload(user, session, page, scope)
|
| 53 |
await _show(target, text, markup)
|
| 54 |
|
| 55 |
|
|
|
|
| 63 |
await _courses(call, user, session, int(call.data.split(":")[2]))
|
| 64 |
|
| 65 |
|
| 66 |
+
@router.callback_query(F.data.startswith("lib:a:"))
|
| 67 |
+
async def on_all_courses(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 68 |
+
await _courses(call, user, session, int(call.data.split(":")[2]), scope="a")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
@router.callback_query(F.data.startswith("lib:k:"))
|
| 72 |
async def on_kinds(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 73 |
course_id = int(call.data.split(":")[2])
|
app/bot/handlers/search.py
CHANGED
|
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 4 |
|
| 5 |
from app.bot.keyboards import library as kb
|
| 6 |
from app.core.i18n import t
|
| 7 |
-
from app.db.models import User
|
| 8 |
from app.services import library as lib
|
| 9 |
|
| 10 |
router = Router()
|
|
@@ -19,6 +19,7 @@ async def search_text(message: Message, user: User, session: AsyncSession) -> No
|
|
| 19 |
await message.answer(t(user.lang, "search_none"))
|
| 20 |
return
|
| 21 |
courses, materials = await lib.search(session, query)
|
|
|
|
| 22 |
if not courses and not materials:
|
| 23 |
await message.answer(t(user.lang, "search_none"))
|
| 24 |
return
|
|
|
|
| 4 |
|
| 5 |
from app.bot.keyboards import library as kb
|
| 6 |
from app.core.i18n import t
|
| 7 |
+
from app.db.models import ActivityEvent, User
|
| 8 |
from app.services import library as lib
|
| 9 |
|
| 10 |
router = Router()
|
|
|
|
| 19 |
await message.answer(t(user.lang, "search_none"))
|
| 20 |
return
|
| 21 |
courses, materials = await lib.search(session, query)
|
| 22 |
+
session.add(ActivityEvent(user_id=user.id, kind="search", ref=query[:100].lower(), n=len(courses) + len(materials)))
|
| 23 |
if not courses and not materials:
|
| 24 |
await message.answer(t(user.lang, "search_none"))
|
| 25 |
return
|
app/bot/keyboards/library.py
CHANGED
|
@@ -30,11 +30,17 @@ def _nav(lang: str, prefix: str, page: int, total: int, size: int) -> list[Inlin
|
|
| 30 |
return row
|
| 31 |
|
| 32 |
|
| 33 |
-
def courses(
|
|
|
|
|
|
|
| 34 |
rows = [[_btn(f"{course_icon(c)} {course_label(c)}", f"lib:k:{c.id}")] for c in items]
|
| 35 |
-
nav = _nav(lang, "lib:
|
| 36 |
if nav:
|
| 37 |
rows.append(nav)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return InlineKeyboardMarkup(inline_keyboard=rows)
|
| 39 |
|
| 40 |
|
|
|
|
| 30 |
return row
|
| 31 |
|
| 32 |
|
| 33 |
+
def courses(
|
| 34 |
+
lang: str, items: Iterable[Course], page: int, total: int, size: int, scope: str = "c", more_elsewhere: bool = False
|
| 35 |
+
) -> InlineKeyboardMarkup:
|
| 36 |
rows = [[_btn(f"{course_icon(c)} {course_label(c)}", f"lib:k:{c.id}")] for c in items]
|
| 37 |
+
nav = _nav(lang, f"lib:{scope}", page, total, size)
|
| 38 |
if nav:
|
| 39 |
rows.append(nav)
|
| 40 |
+
if scope == "a":
|
| 41 |
+
rows.append([_btn(t(lang, "btn_my_courses"), "lib:c:0")])
|
| 42 |
+
elif more_elsewhere:
|
| 43 |
+
rows.append([_btn(t(lang, "btn_more_courses"), "lib:a:0")]) # courses of the other stream
|
| 44 |
return InlineKeyboardMarkup(inline_keyboard=rows)
|
| 45 |
|
| 46 |
|
app/bot/manual.py
CHANGED
|
@@ -169,6 +169,15 @@ def sections(f: dict) -> list[tuple[str, str, list[Cmd]]]:
|
|
| 169 |
"Offline marketing you can measure: each place has its own tag in /growth. The PNGs let you use "
|
| 170 |
"your own design (for example in Canva).",
|
| 171 |
"Test-scan the QR with a phone before printing and keep it at least 3 cm wide.", "Poster + QR for printing"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
Cmd("announce", "/announce",
|
| 173 |
"Writes the channel announcement for the XP, levels and weekly prizes from your current settings, "
|
| 174 |
"shows a preview and offers a button to post it to the channel.",
|
|
|
|
| 169 |
"Offline marketing you can measure: each place has its own tag in /growth. The PNGs let you use "
|
| 170 |
"your own design (for example in Canva).",
|
| 171 |
"Test-scan the QR with a phone before printing and keep it at least 3 cm wide.", "Poster + QR for printing"),
|
| 172 |
+
Cmd("analyze", "/analyze",
|
| 173 |
+
"Your analytics screen with buttons: Overview, Growth (per day, funnel, sources, who is stuck), "
|
| 174 |
+
"Retention (daily actives, who comes back), Library (files, downloads, empty courses and the searches "
|
| 175 |
+
"that found NOTHING), Student files, Channel posts and Insights (what to do next, with the numbers).",
|
| 176 |
+
"Turns the bot's own data into decisions: what material to add, where students are lost, which posts "
|
| 177 |
+
"bring students. Every insight shows the numbers behind it.",
|
| 178 |
+
"Channel views and shares are only visible in the Telegram app (the Bot API does not give them to bots). "
|
| 179 |
+
"The bot records new channel posts and their reactions by itself; FORWARD an older text post to the bot to "
|
| 180 |
+
"add it. Small samples (under ~30 students) are only hints.", "Analytics: growth, retention, library, insights"),
|
| 181 |
Cmd("announce", "/announce",
|
| 182 |
"Writes the channel announcement for the XP, levels and weekly prizes from your current settings, "
|
| 183 |
"shows a preview and offers a button to post it to the channel.",
|
app/db/models/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from app.db.models.course import Course
|
| 2 |
from app.db.models.group import BotGroup
|
| 3 |
from app.db.models.material import Material
|
|
@@ -7,4 +8,4 @@ from app.db.models.submission import Submission
|
|
| 7 |
from app.db.models.user import User
|
| 8 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 9 |
|
| 10 |
-
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult", "BotGroup", "Submission"]
|
|
|
|
| 1 |
+
from app.db.models.analytics import ActivityEvent, ChannelPost
|
| 2 |
from app.db.models.course import Course
|
| 3 |
from app.db.models.group import BotGroup
|
| 4 |
from app.db.models.material import Material
|
|
|
|
| 8 |
from app.db.models.user import User
|
| 9 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 10 |
|
| 11 |
+
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult", "BotGroup", "Submission", "ActivityEvent", "ChannelPost"]
|
app/db/models/analytics.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, DateTime, Integer, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ActivityEvent(Base):
|
| 11 |
+
"""Small usage log that feeds /analyze: downloads and searches (so we learn what students look for)."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "activity_events"
|
| 14 |
+
|
| 15 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 16 |
+
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 17 |
+
kind: Mapped[str] = mapped_column(String(12), index=True) # download | search | inline | find
|
| 18 |
+
ref: Mapped[str] = mapped_column(String(120)) # material id, or the search words
|
| 19 |
+
n: Mapped[int] = mapped_column(Integer, default=0) # for searches: how many results were found
|
| 20 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ChannelPost(Base):
|
| 24 |
+
"""A post in the main channel (seen live, or added by forwarding it to the bot)."""
|
| 25 |
+
|
| 26 |
+
__tablename__ = "channel_posts"
|
| 27 |
+
|
| 28 |
+
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
| 29 |
+
message_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
| 30 |
+
posted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
| 31 |
+
preview: Mapped[Optional[str]] = mapped_column(String(300))
|
| 32 |
+
tags: Mapped[Optional[str]] = mapped_column(String(120)) # ?start= tags found in the post, comma separated
|
| 33 |
+
reactions: Mapped[Optional[int]] = mapped_column(Integer) # None = unknown
|
app/db/repos/analytics.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loads the plain data the analyzer needs (simple selects; all the maths is in services/analytics.py)."""
|
| 2 |
+
from datetime import date, datetime, timedelta, timezone
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.db.models import ActivityEvent, ChannelPost, Course, Material, Submission, User, XpEvent
|
| 9 |
+
from app.services import analytics as A
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _utc(dt: Optional[datetime]) -> Optional[datetime]:
|
| 13 |
+
if dt is None:
|
| 14 |
+
return None
|
| 15 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _day(dt: datetime) -> date:
|
| 19 |
+
return _utc(dt).astimezone(A.ET).date()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def load(session: AsyncSession, now: Optional[datetime] = None) -> A.Snapshot:
|
| 23 |
+
now = now or datetime.now(timezone.utc)
|
| 24 |
+
|
| 25 |
+
users = [
|
| 26 |
+
A.UserRow(
|
| 27 |
+
id=uid, created=_utc(created), joined=_day(created), source=source or "before tracking",
|
| 28 |
+
verified=bool(passed), onboarded=year is not None, active_days=active or 0, stream=stream, missing=missing,
|
| 29 |
+
)
|
| 30 |
+
for uid, created, source, passed, year, active, stream, missing in (
|
| 31 |
+
await session.execute(
|
| 32 |
+
select(User.id, User.created_at, User.source, User.gate_passed, User.year, User.active_days, User.stream, User.gate_missing)
|
| 33 |
+
)
|
| 34 |
+
).all()
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
checkins: dict[int, set[date]] = {}
|
| 38 |
+
for uid, day in (await session.execute(select(XpEvent.user_id, XpEvent.day).where(XpEvent.kind == "checkin"))).all():
|
| 39 |
+
checkins.setdefault(uid, set()).add(date.fromisoformat(day))
|
| 40 |
+
|
| 41 |
+
since = now - timedelta(days=30)
|
| 42 |
+
events = [
|
| 43 |
+
A.EventRow(kind=kind, ref=ref or "", n=n or 0, day=_day(created), user_id=uid)
|
| 44 |
+
for kind, ref, n, created, uid in (
|
| 45 |
+
await session.execute(
|
| 46 |
+
select(ActivityEvent.kind, ActivityEvent.ref, ActivityEvent.n, ActivityEvent.created_at, ActivityEvent.user_id)
|
| 47 |
+
.where(ActivityEvent.created_at >= since)
|
| 48 |
+
)
|
| 49 |
+
).all()
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
materials = [
|
| 53 |
+
A.MaterialRow(id=i, course_id=cid, title=title or "", downloads=dl or 0, status=status, kind=kind)
|
| 54 |
+
for i, cid, title, dl, status, kind in (
|
| 55 |
+
await session.execute(select(Material.id, Material.course_id, Material.title, Material.downloads, Material.status, Material.kind))
|
| 56 |
+
).all()
|
| 57 |
+
]
|
| 58 |
+
courses = {cid: name for cid, name in (await session.execute(select(Course.id, Course.name))).all()}
|
| 59 |
+
subs = [
|
| 60 |
+
A.SubRow(status=status, user_id=uid, created=_utc(created), reviewed=_utc(reviewed))
|
| 61 |
+
for status, uid, created, reviewed in (
|
| 62 |
+
await session.execute(select(Submission.status, Submission.user_id, Submission.created_at, Submission.reviewed_at))
|
| 63 |
+
).all()
|
| 64 |
+
]
|
| 65 |
+
posts = [
|
| 66 |
+
A.PostRow(
|
| 67 |
+
message_id=mid, posted=_utc(posted), preview=preview or "",
|
| 68 |
+
tags=[t for t in (tags or "").split(",") if t], reactions=reactions,
|
| 69 |
+
)
|
| 70 |
+
for mid, posted, preview, tags, reactions in (
|
| 71 |
+
await session.execute(
|
| 72 |
+
select(ChannelPost.message_id, ChannelPost.posted_at, ChannelPost.preview, ChannelPost.tags, ChannelPost.reactions)
|
| 73 |
+
.order_by(ChannelPost.posted_at.desc()).limit(30)
|
| 74 |
+
)
|
| 75 |
+
).all()
|
| 76 |
+
]
|
| 77 |
+
return A.Snapshot(now=now, users=users, checkins=checkins, events=events, materials=materials, courses=courses, subs=subs, posts=posts)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def save_post(
|
| 81 |
+
session: AsyncSession, *, chat_id: int, message_id: int, posted_at: datetime, text: str, reactions: Optional[int] = None
|
| 82 |
+
) -> None:
|
| 83 |
+
post = await session.get(ChannelPost, (chat_id, message_id))
|
| 84 |
+
preview = " ".join((text or "").split())[:300] or "(media post)"
|
| 85 |
+
tags = ",".join(A.extract_tags(text or ""))[:120]
|
| 86 |
+
if post:
|
| 87 |
+
post.preview, post.tags = preview, tags
|
| 88 |
+
else:
|
| 89 |
+
session.add(ChannelPost(chat_id=chat_id, message_id=message_id, posted_at=posted_at, preview=preview, tags=tags, reactions=reactions))
|
| 90 |
+
await session.flush()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def set_reactions(session: AsyncSession, chat_id: int, message_id: int, total: int) -> None:
|
| 94 |
+
post = await session.get(ChannelPost, (chat_id, message_id))
|
| 95 |
+
if post:
|
| 96 |
+
post.reactions = total
|
| 97 |
+
await session.flush()
|
app/db/repos/library.py
CHANGED
|
@@ -46,6 +46,20 @@ async def list_courses(
|
|
| 46 |
return list(res.scalars())
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
async def get_course(session: AsyncSession, course_id: int) -> Optional[Course]:
|
| 50 |
return await session.get(Course, course_id)
|
| 51 |
|
|
|
|
| 46 |
return list(res.scalars())
|
| 47 |
|
| 48 |
|
| 49 |
+
async def count_all_courses(session: AsyncSession, only_with_files: bool = False) -> int:
|
| 50 |
+
stmt = select(func.count()).select_from(Course)
|
| 51 |
+
if only_with_files:
|
| 52 |
+
stmt = stmt.where(_has_files())
|
| 53 |
+
return int((await session.execute(stmt)).scalar_one())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def list_all_courses(session: AsyncSession, offset: int, limit: int, only_with_files: bool = False) -> list[Course]:
|
| 57 |
+
stmt = select(Course).order_by(Course.semester, Course.code, Course.name).offset(offset).limit(limit)
|
| 58 |
+
if only_with_files:
|
| 59 |
+
stmt = stmt.where(_has_files())
|
| 60 |
+
return list((await session.execute(stmt)).scalars())
|
| 61 |
+
|
| 62 |
+
|
| 63 |
async def get_course(session: AsyncSession, course_id: int) -> Optional[Course]:
|
| 64 |
return await session.get(Course, course_id)
|
| 65 |
|
app/services/analytics.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The analyzer's brain. Pure functions over plain data (no database, no Telegram), so every number
|
| 2 |
+
is unit-tested. The loading of the data lives in app/db/repos/analytics.py.
|
| 3 |
+
|
| 4 |
+
Honesty rules built in: small samples are labelled, a correlation is never called a cause, and every
|
| 5 |
+
insight shows the numbers it is based on."""
|
| 6 |
+
import html
|
| 7 |
+
import re
|
| 8 |
+
from collections import Counter
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import date, datetime, timedelta, timezone
|
| 11 |
+
from statistics import median
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
ET = timezone(timedelta(hours=3))
|
| 15 |
+
SPARSE = 3 # a course with fewer files than this is "short of material"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ------------------------------------------------------------------ plain data
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class UserRow:
|
| 21 |
+
id: int
|
| 22 |
+
created: datetime # UTC
|
| 23 |
+
joined: date # Ethiopian date of joining
|
| 24 |
+
source: str
|
| 25 |
+
verified: bool
|
| 26 |
+
onboarded: bool
|
| 27 |
+
active_days: int
|
| 28 |
+
stream: Optional[str] = None
|
| 29 |
+
missing: Optional[str] = None # join-gate places last missing, e.g. "dbucentralforum"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class EventRow:
|
| 34 |
+
kind: str # download | search | inline | find
|
| 35 |
+
ref: str
|
| 36 |
+
n: int
|
| 37 |
+
day: date
|
| 38 |
+
user_id: int
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class MaterialRow:
|
| 43 |
+
id: int
|
| 44 |
+
course_id: Optional[int]
|
| 45 |
+
title: str
|
| 46 |
+
downloads: int
|
| 47 |
+
status: str
|
| 48 |
+
kind: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class SubRow:
|
| 53 |
+
status: str
|
| 54 |
+
user_id: int
|
| 55 |
+
created: datetime
|
| 56 |
+
reviewed: Optional[datetime]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass(frozen=True)
|
| 60 |
+
class PostRow:
|
| 61 |
+
message_id: int
|
| 62 |
+
posted: datetime # UTC
|
| 63 |
+
preview: str
|
| 64 |
+
tags: list[str]
|
| 65 |
+
reactions: Optional[int]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class Snapshot:
|
| 70 |
+
now: datetime
|
| 71 |
+
users: list[UserRow]
|
| 72 |
+
checkins: dict[int, set[date]]
|
| 73 |
+
events: list[EventRow]
|
| 74 |
+
materials: list[MaterialRow]
|
| 75 |
+
courses: dict[int, str]
|
| 76 |
+
subs: list[SubRow]
|
| 77 |
+
posts: list[PostRow] = field(default_factory=list)
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def today(self) -> date:
|
| 81 |
+
return self.now.astimezone(ET).date()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ------------------------------------------------------------------ small helpers
|
| 85 |
+
def pct(part: int, whole: int) -> int:
|
| 86 |
+
return round(100 * part / whole) if whole else 0
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def bar(percent: float, width: int = 10) -> str:
|
| 90 |
+
filled = max(0, min(width, round(percent / 100 * width)))
|
| 91 |
+
return "▓" * filled + "░" * (width - filled)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def esc(text: str) -> str:
|
| 95 |
+
return html.escape(text or "", quote=False)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def extract_tags(text: str) -> list[str]:
|
| 99 |
+
"""?start= tags inside a post: 'ad1', 'dorm5' (numbers are personal invite links, ignored)."""
|
| 100 |
+
tags = re.findall(r"[?&]start=([A-Za-z0-9_]{1,20})", text or "")
|
| 101 |
+
return sorted({t.lower() for t in tags if not t.isdigit()})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ------------------------------------------------------------------ growth + funnel
|
| 105 |
+
def per_day(users: list[UserRow], today: date, days: int = 14) -> list[tuple[date, int]]:
|
| 106 |
+
counts = Counter(u.joined for u in users)
|
| 107 |
+
return [(today - timedelta(days=i), counts.get(today - timedelta(days=i), 0)) for i in range(days - 1, -1, -1)]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def funnel(users: list[UserRow]) -> dict:
|
| 111 |
+
n = len(users)
|
| 112 |
+
verified = sum(u.verified for u in users)
|
| 113 |
+
onboarded = sum(u.onboarded for u in users)
|
| 114 |
+
active3 = sum(u.active_days >= 3 for u in users)
|
| 115 |
+
return dict(joined=n, verified=verified, onboarded=onboarded, active3=active3)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def biggest_leak(f: dict) -> Optional[tuple[str, str, int, int]]:
|
| 119 |
+
"""The stage with the largest loss between consecutive steps: (from, to, people lost, % lost)."""
|
| 120 |
+
steps = [("joined", "in channel + forum", f["joined"], f["verified"]),
|
| 121 |
+
("in channel + forum", "finished setup", f["verified"], f["onboarded"])]
|
| 122 |
+
steps = [(a, b, x - y, pct(x - y, x)) for a, b, x, y in steps if x > 0]
|
| 123 |
+
return max(steps, key=lambda s: s[2]) if steps and max(s[2] for s in steps) > 0 else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def source_table(users: list[UserRow]) -> list[dict]:
|
| 127 |
+
groups: dict[str, list[UserRow]] = {}
|
| 128 |
+
for u in users:
|
| 129 |
+
groups.setdefault(u.source, []).append(u)
|
| 130 |
+
rows = []
|
| 131 |
+
for source, members in groups.items():
|
| 132 |
+
f = funnel(members)
|
| 133 |
+
rows.append(dict(source=source, **f, setup_pct=pct(f["onboarded"], f["joined"])))
|
| 134 |
+
return sorted(rows, key=lambda r: -r["joined"])
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def stuck_by_missing(users: list[UserRow]) -> list[tuple[str, int]]:
|
| 138 |
+
c = Counter((u.missing or "not recorded yet") for u in users if not u.verified)
|
| 139 |
+
return c.most_common()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------------ activity + retention
|
| 143 |
+
def dau_wau(checkins: dict[int, set[date]], today: date) -> dict:
|
| 144 |
+
week = [today - timedelta(days=i) for i in range(6, -1, -1)]
|
| 145 |
+
dau = [(d, sum(1 for days in checkins.values() if d in days)) for d in week]
|
| 146 |
+
wau = sum(1 for days in checkins.values() if any(d in days for d in week))
|
| 147 |
+
last_full = dau[-2][1] if len(dau) >= 2 else 0 # yesterday is complete, today is not
|
| 148 |
+
return dict(dau=dau, wau=wau, stickiness=pct(last_full, wau))
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def retention(users: list[UserRow], checkins: dict[int, set[date]], today: date, cohorts: int = 7) -> dict:
|
| 152 |
+
"""Share of each day's new students who were active exactly N days later. Check-ins only exist from the day
|
| 153 |
+
activity tracking started, so earlier cohorts are 'n/a' instead of a misleading 0. A day that is not over
|
| 154 |
+
yet (today) is never counted."""
|
| 155 |
+
all_days = [d for days in checkins.values() for d in days]
|
| 156 |
+
start = min(all_days) if all_days else None
|
| 157 |
+
rows = []
|
| 158 |
+
for offset in range(cohorts, 0, -1):
|
| 159 |
+
d = today - timedelta(days=offset)
|
| 160 |
+
members = [u for u in users if u.joined == d]
|
| 161 |
+
if not members:
|
| 162 |
+
continue
|
| 163 |
+
cells = {}
|
| 164 |
+
for n in (1, 3, 7):
|
| 165 |
+
target = d + timedelta(days=n)
|
| 166 |
+
if target >= today or start is None or target < start:
|
| 167 |
+
cells[n] = None # not reached yet, or before we tracked activity
|
| 168 |
+
else:
|
| 169 |
+
cells[n] = pct(sum(1 for u in members if target in checkins.get(u.id, set())), len(members))
|
| 170 |
+
rows.append(dict(day=d, size=len(members), **{f"d{n}": v for n, v in cells.items()}))
|
| 171 |
+
eligible = [u for u in users if u.joined < today]
|
| 172 |
+
came_back = sum(1 for u in eligible if any(day > u.joined for day in checkins.get(u.id, set())))
|
| 173 |
+
return dict(rows=rows, tracking_start=start, came_back=came_back, eligible=len(eligible))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ------------------------------------------------------------------ library + searches
|
| 177 |
+
def library(snap: Snapshot) -> dict:
|
| 178 |
+
ok = [m for m in snap.materials if m.status == "ok"]
|
| 179 |
+
per_course = Counter(m.course_id for m in ok if m.course_id)
|
| 180 |
+
empty = [name for cid, name in snap.courses.items() if per_course.get(cid, 0) == 0]
|
| 181 |
+
sparse = [name for cid, name in snap.courses.items() if 0 < per_course.get(cid, 0) < SPARSE]
|
| 182 |
+
week_start = snap.today - timedelta(days=6)
|
| 183 |
+
downloads_week = [e for e in snap.events if e.kind == "download" and e.day >= week_start]
|
| 184 |
+
since = snap.today - timedelta(days=13)
|
| 185 |
+
searches = [e for e in snap.events if e.kind in ("search", "inline", "find") and e.day >= since and len(e.ref) >= 2]
|
| 186 |
+
missed = Counter(e.ref for e in searches if e.n == 0)
|
| 187 |
+
found = Counter(e.ref for e in searches if e.n > 0)
|
| 188 |
+
top_files = sorted(ok, key=lambda m: -m.downloads)[:5]
|
| 189 |
+
return dict(
|
| 190 |
+
files=len(ok), kinds=Counter(m.kind for m in ok), courses=len(snap.courses), empty=empty, sparse=sparse,
|
| 191 |
+
downloads_total=sum(m.downloads for m in ok), downloads_week=len(downloads_week),
|
| 192 |
+
downloaders_week=len({e.user_id for e in downloads_week}), top_files=top_files,
|
| 193 |
+
by_course=[(snap.courses.get(cid, "?"), n) for cid, n in per_course.most_common(5)],
|
| 194 |
+
searches=len(searches), missed_total=sum(missed.values()), missed=missed.most_common(8), found=found.most_common(5),
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ------------------------------------------------------------------ contributions + channel posts
|
| 199 |
+
def contributions(snap: Snapshot) -> dict:
|
| 200 |
+
by = Counter(s.status for s in snap.subs)
|
| 201 |
+
done = [s for s in snap.subs if s.reviewed]
|
| 202 |
+
hours = [(s.reviewed - s.created).total_seconds() / 3600 for s in done]
|
| 203 |
+
pending = [s for s in snap.subs if s.status == "pending"]
|
| 204 |
+
oldest = max(((snap.now - s.created).total_seconds() / 3600 for s in pending), default=0.0)
|
| 205 |
+
top = Counter(s.user_id for s in snap.subs if s.status == "approved").most_common(3)
|
| 206 |
+
return dict(
|
| 207 |
+
pending=by["pending"], approved=by["approved"], rejected=by["rejected"],
|
| 208 |
+
approval=pct(by["approved"], by["approved"] + by["rejected"]),
|
| 209 |
+
avg_hours=round(sum(hours) / len(hours), 1) if hours else None, oldest_pending_hours=round(oldest, 1), top=top,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def channel_posts(snap: Snapshot, window_hours: int = 6) -> list[dict]:
|
| 214 |
+
"""For each post: students who joined the bot in the hours after it, and students who came through a tag
|
| 215 |
+
written in the post. NOTE: a correlation, not proof; close posts share the same joiners."""
|
| 216 |
+
rows = []
|
| 217 |
+
for p in snap.posts:
|
| 218 |
+
end = p.posted + timedelta(hours=window_hours)
|
| 219 |
+
soon = sum(1 for u in snap.users if p.posted <= u.created < end)
|
| 220 |
+
tagged = sum(1 for u in snap.users if u.source in p.tags)
|
| 221 |
+
rows.append(dict(message_id=p.message_id, posted=p.posted, preview=p.preview, tags=p.tags,
|
| 222 |
+
reactions=p.reactions, soon=soon, tagged=tagged))
|
| 223 |
+
return sorted(rows, key=lambda r: (-r["tagged"], -r["soon"]))
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ------------------------------------------------------------------ insights (rule based, numbers shown)
|
| 227 |
+
@dataclass
|
| 228 |
+
class Insight:
|
| 229 |
+
priority: int # 1 = most important
|
| 230 |
+
title: str
|
| 231 |
+
evidence: str
|
| 232 |
+
action: str
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def insights(snap: Snapshot) -> list[Insight]:
|
| 236 |
+
out: list[Insight] = []
|
| 237 |
+
users, today = snap.users, snap.today
|
| 238 |
+
if not users:
|
| 239 |
+
return out
|
| 240 |
+
f = funnel(users)
|
| 241 |
+
leak = biggest_leak(f)
|
| 242 |
+
|
| 243 |
+
stuck = [u for u in users if not u.verified]
|
| 244 |
+
if f["joined"] and pct(len(stuck), f["joined"]) >= 15:
|
| 245 |
+
top = [(m, n) for m, n in stuck_by_missing(users)][:3]
|
| 246 |
+
detail = ", ".join(f"{n} missing {m}" if m != "not recorded yet" else f"{n} not recorded yet" for m, n in top)
|
| 247 |
+
out.append(Insight(1, "Students stop at the join step",
|
| 248 |
+
f"{len(stuck)} of {f['joined']} ({pct(len(stuck), f['joined'])}%) never got through: {detail}.",
|
| 249 |
+
"Check /gatecheck. If most miss the forum, consider making it optional or send one reminder."))
|
| 250 |
+
elif leak and leak[3] >= 10:
|
| 251 |
+
out.append(Insight(2, f"Biggest drop: {leak[0]} → {leak[1]}", f"{leak[2]} people ({leak[3]}%) are lost here.",
|
| 252 |
+
"Make this step shorter or explain why it matters."))
|
| 253 |
+
|
| 254 |
+
verified_not_setup = f["verified"] - f["onboarded"]
|
| 255 |
+
if f["verified"] and pct(verified_not_setup, f["verified"]) >= 10:
|
| 256 |
+
out.append(Insight(2, "Some verified students did not finish setup",
|
| 257 |
+
f"{verified_not_setup} of {f['verified']} ({pct(verified_not_setup, f['verified'])}%) stopped at stream/year.",
|
| 258 |
+
"Check that the two setup questions are clear."))
|
| 259 |
+
|
| 260 |
+
ret = retention(users, snap.checkins, today)
|
| 261 |
+
d1 = [(r["d1"], r["size"]) for r in ret["rows"] if r["d1"] is not None]
|
| 262 |
+
if d1 and sum(s for _, s in d1) >= 20:
|
| 263 |
+
total = sum(s for _, s in d1)
|
| 264 |
+
avg = round(sum(v * s for v, s in d1) / total)
|
| 265 |
+
if avg < 20:
|
| 266 |
+
out.append(Insight(1, "Few students come back the next day", f"Day-1 return is {avg}% (over {total} students).",
|
| 267 |
+
"Give a reason to return: new files weekly, a daily XP nudge, exam-week reminders."))
|
| 268 |
+
elif ret["tracking_start"] is None or snap.checkins and not d1:
|
| 269 |
+
since = ret["tracking_start"].isoformat() if ret["tracking_start"] else "now"
|
| 270 |
+
out.append(Insight(4, "Not enough days of data for retention yet",
|
| 271 |
+
f"Activity has been tracked since {since}.", "Check again in a few days."))
|
| 272 |
+
|
| 273 |
+
lib = library(snap)
|
| 274 |
+
if lib["missed"] and lib["missed"][0][1] >= 2:
|
| 275 |
+
q, n = lib["missed"][0]
|
| 276 |
+
out.append(Insight(1, "Students search for things you do not have",
|
| 277 |
+
f"{lib['missed_total']} searches in 14 days found nothing; top: “{q}” ({n} times).",
|
| 278 |
+
"Add material for these words (see Library & searches), then they find it next time."))
|
| 279 |
+
if snap.courses and (len(lib["empty"]) / len(snap.courses)) >= 0.4:
|
| 280 |
+
out.append(Insight(2, "Many courses have no file", f"{len(lib['empty'])} of {len(snap.courses)} courses are empty.",
|
| 281 |
+
"Use the 🎯 wanted-courses prompt in announcements; ask seniors for past exams."))
|
| 282 |
+
|
| 283 |
+
con = contributions(snap)
|
| 284 |
+
if con["pending"] and con["oldest_pending_hours"] >= 24:
|
| 285 |
+
out.append(Insight(2, "Student files are waiting", f"{con['pending']} waiting; the oldest {con['oldest_pending_hours']:.0f} h.",
|
| 286 |
+
"Run /queue. Slow reviews discourage contributors."))
|
| 287 |
+
|
| 288 |
+
days = [n for _, n in per_day(users, today, 14)]
|
| 289 |
+
nonzero = [n for n in days if n]
|
| 290 |
+
if len(nonzero) >= 3 and max(days) >= 3 * max(1, median(days)):
|
| 291 |
+
out.append(Insight(3, "Growth comes from single pushes",
|
| 292 |
+
f"Best day {max(days)} new students vs a typical day of {median(days):g}.",
|
| 293 |
+
"Keep a steady posting rhythm; one-off pushes fade within 2-3 days."))
|
| 294 |
+
|
| 295 |
+
sources = [r for r in source_table(users) if r["joined"] >= 10 and r["source"] != "before tracking"]
|
| 296 |
+
if len(sources) >= 2:
|
| 297 |
+
best, worst = max(sources, key=lambda r: r["setup_pct"]), min(sources, key=lambda r: r["setup_pct"])
|
| 298 |
+
if best["setup_pct"] - worst["setup_pct"] >= 15:
|
| 299 |
+
out.append(Insight(3, "Sources differ in quality",
|
| 300 |
+
f"“{best['source']}”: {best['setup_pct']}% finish setup vs “{worst['source']}”: {worst['setup_pct']}% "
|
| 301 |
+
f"({best['joined']} and {worst['joined']} students, still small samples).",
|
| 302 |
+
"Repeat what works in the better source."))
|
| 303 |
+
referral = sum(1 for u in users if u.source == "referral")
|
| 304 |
+
if len(users) >= 50 and pct(referral, len(users)) < 5:
|
| 305 |
+
out.append(Insight(3, "Few students join by invite", f"{referral} of {len(users)} ({pct(referral, len(users))}%) came from an invite.",
|
| 306 |
+
"Mention “Invite friends” in channel posts; the share button is one tap."))
|
| 307 |
+
|
| 308 |
+
posts = channel_posts(snap)
|
| 309 |
+
if posts and (posts[0]["tagged"] or posts[0]["soon"]):
|
| 310 |
+
p = posts[0]
|
| 311 |
+
out.append(Insight(4, "Your best channel post so far",
|
| 312 |
+
f"“{esc(p['preview'][:50])}” → {p['tagged']} via its tag, {p['soon']} new students within 6 h "
|
| 313 |
+
"(a correlation, not proof).", "Write more posts like this one."))
|
| 314 |
+
return sorted(out, key=lambda i: i.priority)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# ------------------------------------------------------------------ text for each screen
|
| 318 |
+
def _fmt_day(d: date) -> str:
|
| 319 |
+
return d.strftime("%m-%d")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def overview(snap: Snapshot) -> str:
|
| 323 |
+
users, today = snap.users, snap.today
|
| 324 |
+
f = funnel(users)
|
| 325 |
+
new_today = sum(1 for u in users if u.joined == today)
|
| 326 |
+
wau = dau_wau(snap.checkins, today)
|
| 327 |
+
ret = retention(users, snap.checkins, today)
|
| 328 |
+
lib = library(snap)
|
| 329 |
+
con = contributions(snap)
|
| 330 |
+
lines = [
|
| 331 |
+
f"📊 <b>Analytics</b> · {snap.now.astimezone(ET).strftime('%d %b %H:%M')} (Ethiopian time)",
|
| 332 |
+
f"👥 <b>{f['joined']}</b> students (+{new_today} today) · in channel+forum <b>{f['verified']}</b> ({pct(f['verified'], f['joined'])}%) "
|
| 333 |
+
f"· finished setup <b>{f['onboarded']}</b> ({pct(f['onboarded'], f['joined'])}%)",
|
| 334 |
+
f"🔁 Active this week <b>{wau['wau']}</b> · came back at least once <b>{ret['came_back']}</b> of {ret['eligible']} "
|
| 335 |
+
f"({pct(ret['came_back'], ret['eligible'])}%)",
|
| 336 |
+
f"📚 <b>{lib['files']}</b> files · {lib['downloads_total']} downloads ({lib['downloads_week']} this week) · "
|
| 337 |
+
f"{len(lib['empty'])} courses empty",
|
| 338 |
+
f"🔍 {lib['searches']} searches in 14 days · found nothing: {lib['missed_total']} ({pct(lib['missed_total'], lib['searches'])}%)",
|
| 339 |
+
f"📤 Student files: {con['pending']} waiting · {con['approved']} approved · {con['rejected']} rejected",
|
| 340 |
+
]
|
| 341 |
+
ins = insights(snap)[:3]
|
| 342 |
+
if ins:
|
| 343 |
+
lines.append("\n💡 <b>What matters most now</b>")
|
| 344 |
+
lines += [f"{i}. <b>{esc(x.title)}</b>: {x.evidence}" for i, x in enumerate(ins, 1)]
|
| 345 |
+
lines.append("\nTap a button for details. Small samples (under ~30 people) are only hints.")
|
| 346 |
+
return "\n".join(lines)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def section_growth(snap: Snapshot) -> str:
|
| 350 |
+
days = per_day(snap.users, snap.today, 14)
|
| 351 |
+
top = max((n for _, n in days), default=0) or 1
|
| 352 |
+
lines = ["👥 <b>Growth and sources</b>", "<b>New students per day</b>"]
|
| 353 |
+
lines += [f"{_fmt_day(d)} {bar(100 * n / top, 12)} {n}" for d, n in days]
|
| 354 |
+
f = funnel(snap.users)
|
| 355 |
+
lines.append(f"\n<b>Funnel</b> (everyone ever)\njoined <b>{f['joined']}</b> → in channel + forum <b>{f['verified']}</b> → "
|
| 356 |
+
f"finished setup <b>{f['onboarded']}</b> → used the bot on 3+ days <b>{f['active3']}</b>")
|
| 357 |
+
lines.append("\n<b>By source</b> (joined → in channel+forum → finished setup → 3+ days)")
|
| 358 |
+
for r in source_table(snap.users)[:10]:
|
| 359 |
+
small = " ·small sample" if r["joined"] < 10 else ""
|
| 360 |
+
lines.append(f"• <b>{esc(r['source'])}</b>: {r['joined']} → {r['verified']} → {r['onboarded']} → {r['active3']} "
|
| 361 |
+
f"({r['setup_pct']}% setup){small}")
|
| 362 |
+
stuck = stuck_by_missing(snap.users)
|
| 363 |
+
if stuck:
|
| 364 |
+
lines.append("\n<b>Stuck at the join step</b>")
|
| 365 |
+
lines += [f"• {n}: {('missing ' + m) if m != 'not recorded yet' else m}" for m, n in stuck[:5]]
|
| 366 |
+
return "\n".join(lines)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def section_retention(snap: Snapshot) -> str:
|
| 370 |
+
ret = retention(snap.users, snap.checkins, snap.today)
|
| 371 |
+
w = dau_wau(snap.checkins, snap.today)
|
| 372 |
+
lines = ["🔁 <b>Activity and retention</b>", "<b>Active students per day</b> (opened the bot)"]
|
| 373 |
+
peak = max((n for _, n in w["dau"]), default=0) or 1
|
| 374 |
+
lines += [f"{_fmt_day(d)} {bar(100 * n / peak, 12)} {n}" for d, n in w["dau"]]
|
| 375 |
+
lines.append(f"\nWeekly active: <b>{w['wau']}</b> · yesterday's active ÷ weekly active: <b>{w['stickiness']}%</b>")
|
| 376 |
+
lines.append(f"Came back at least once: <b>{ret['came_back']}</b> of {ret['eligible']} ({pct(ret['came_back'], ret['eligible'])}%)")
|
| 377 |
+
lines.append("\n<b>Return rate by signup day</b> (share active again on day 1 / 3 / 7)")
|
| 378 |
+
cell = lambda v: "n/a" if v is None else f"{v}%" # noqa: E731
|
| 379 |
+
if ret["rows"]:
|
| 380 |
+
lines += [f"{_fmt_day(r['day'])} (n={r['size']}): {cell(r['d1'])} / {cell(r['d3'])} / {cell(r['d7'])}" for r in ret["rows"]]
|
| 381 |
+
else:
|
| 382 |
+
lines.append("no signups in the last 7 days")
|
| 383 |
+
since = ret["tracking_start"].isoformat() if ret["tracking_start"] else "not yet"
|
| 384 |
+
lines.append(f"\n“n/a” = that day has not come yet, or it is before activity tracking began ({since}).")
|
| 385 |
+
return "\n".join(lines)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def section_library(snap: Snapshot) -> str:
|
| 389 |
+
lib = library(snap)
|
| 390 |
+
lines = ["📚 <b>Library and searches</b>",
|
| 391 |
+
f"{lib['files']} files in {lib['courses']} courses · kinds: " + ", ".join(f"{k} {n}" for k, n in lib["kinds"].most_common()),
|
| 392 |
+
f"Downloads: {lib['downloads_total']} all time · {lib['downloads_week']} this week by {lib['downloaders_week']} students"]
|
| 393 |
+
if lib["top_files"]:
|
| 394 |
+
lines.append("\n<b>Most downloaded</b>")
|
| 395 |
+
lines += [f"• {esc(m.title[:45])} ({m.downloads})" for m in lib["top_files"] if m.downloads]
|
| 396 |
+
if lib["by_course"]:
|
| 397 |
+
lines.append("\n<b>Courses with the most files</b>: " + ", ".join(f"{esc(n[:25])} {c}" for n, c in lib["by_course"]))
|
| 398 |
+
lines.append(f"\n<b>No file at all</b> ({len(lib['empty'])}): " + (", ".join(esc(n[:28]) for n in lib["empty"][:8]) or "none")
|
| 399 |
+
+ ("…" if len(lib["empty"]) > 8 else ""))
|
| 400 |
+
if lib["sparse"]:
|
| 401 |
+
lines.append(f"<b>Short of files</b> (under {SPARSE}): " + ", ".join(esc(n[:28]) for n in lib["sparse"][:8]))
|
| 402 |
+
lines.append(f"\n🔍 <b>Searches (14 days)</b>: {lib['searches']} · found nothing {lib['missed_total']} ({pct(lib['missed_total'], lib['searches'])}%)")
|
| 403 |
+
if lib["missed"]:
|
| 404 |
+
lines.append("<b>Searched but NOT found</b> (add these!)")
|
| 405 |
+
lines += [f"• “{esc(q)}” × {n}" for q, n in lib["missed"]]
|
| 406 |
+
if lib["found"]:
|
| 407 |
+
lines.append("<b>Most searched and found</b>: " + ", ".join(f"“{esc(q)}” {n}" for q, n in lib["found"]))
|
| 408 |
+
return "\n".join(lines)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def section_contrib(snap: Snapshot) -> str:
|
| 412 |
+
c = contributions(snap)
|
| 413 |
+
lines = ["📤 <b>Student contributions</b>",
|
| 414 |
+
f"Waiting <b>{c['pending']}</b> · approved <b>{c['approved']}</b> · rejected <b>{c['rejected']}</b> · approval rate <b>{c['approval']}%</b>"]
|
| 415 |
+
if c["avg_hours"] is not None:
|
| 416 |
+
lines.append(f"Average time to review: {c['avg_hours']} h")
|
| 417 |
+
if c["pending"]:
|
| 418 |
+
lines.append(f"Oldest waiting: {c['oldest_pending_hours']:.0f} h")
|
| 419 |
+
if c["top"]:
|
| 420 |
+
lines.append("Top contributors (user id · approved): " + ", ".join(f"{u} · {n}" for u, n in c["top"]))
|
| 421 |
+
if not snap.subs:
|
| 422 |
+
lines.append("No student files yet.")
|
| 423 |
+
return "\n".join(lines)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def section_channel(snap: Snapshot) -> str:
|
| 427 |
+
rows = channel_posts(snap)
|
| 428 |
+
lines = ["📢 <b>Channel posts</b>"]
|
| 429 |
+
if not rows:
|
| 430 |
+
return "\n".join(lines + [
|
| 431 |
+
"No posts recorded yet. The bot records new posts of the channel by itself. To add an older post, "
|
| 432 |
+
"FORWARD it (text posts) to this bot as admin."])
|
| 433 |
+
lines.append("Sorted by students who came through the post's own link tag, then by new students within 6 hours.")
|
| 434 |
+
for r in rows[:8]:
|
| 435 |
+
react = "?" if r["reactions"] is None else r["reactions"]
|
| 436 |
+
tag = f" · tag {', '.join(r['tags'])}: <b>{r['tagged']}</b>" if r["tags"] else ""
|
| 437 |
+
lines.append(f"• {r['posted'].astimezone(ET).strftime('%m-%d %H:%M')} “{esc(r['preview'][:40])}”\n"
|
| 438 |
+
f" 6h: <b>{r['soon']}</b> new{tag} · reactions: {react}")
|
| 439 |
+
lines.append("\nA post and a sign-up close in time is a hint, not proof. Reactions are only known for posts seen live.")
|
| 440 |
+
return "\n".join(lines)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def section_insights(snap: Snapshot) -> str:
|
| 444 |
+
ins = insights(snap)
|
| 445 |
+
if not ins:
|
| 446 |
+
return "💡 <b>Insights</b>\nNothing stands out yet; there is not enough data."
|
| 447 |
+
lines = ["💡 <b>Insights and what to do</b> (from your numbers, most important first)"]
|
| 448 |
+
for i, x in enumerate(ins, 1):
|
| 449 |
+
lines.append(f"\n<b>{i}. {esc(x.title)}</b>\n{x.evidence}\n➡️ {esc(x.action)}")
|
| 450 |
+
return "\n".join(lines)
|
app/services/library.py
CHANGED
|
@@ -19,11 +19,19 @@ def tokenize(query: str, limit: int = 5) -> list[str]:
|
|
| 19 |
return [t for t in _STRIP.sub(" ", query.lower()).split() if t][:limit]
|
| 20 |
|
| 21 |
|
| 22 |
-
async def courses_page(session: AsyncSession, user: User, page: int) -> tuple[list[Course], int]:
|
|
|
|
|
|
|
| 23 |
only = get_settings().hide_empty_courses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
total = await repo.count_courses(session, user.stream, user.year, only)
|
| 25 |
courses = await repo.list_courses(session, user.stream, user.year, page * PAGE_SIZE, PAGE_SIZE, only)
|
| 26 |
-
return courses, total
|
| 27 |
|
| 28 |
|
| 29 |
async def kinds_of(session: AsyncSession, course_id: int) -> tuple[Optional[Course], dict[str, int]]:
|
|
|
|
| 19 |
return [t for t in _STRIP.sub(" ", query.lower()).split() if t][:limit]
|
| 20 |
|
| 21 |
|
| 22 |
+
async def courses_page(session: AsyncSession, user: User, page: int, scope: str = "c") -> tuple[list[Course], int, bool]:
|
| 23 |
+
"""(courses on this page, total, are there more courses in other streams?).
|
| 24 |
+
scope "c" = the student's own stream and year, "a" = every course that has files."""
|
| 25 |
only = get_settings().hide_empty_courses
|
| 26 |
+
if scope == "a":
|
| 27 |
+
return (
|
| 28 |
+
await repo.list_all_courses(session, page * PAGE_SIZE, PAGE_SIZE, only),
|
| 29 |
+
await repo.count_all_courses(session, only),
|
| 30 |
+
False,
|
| 31 |
+
)
|
| 32 |
total = await repo.count_courses(session, user.stream, user.year, only)
|
| 33 |
courses = await repo.list_courses(session, user.stream, user.year, page * PAGE_SIZE, PAGE_SIZE, only)
|
| 34 |
+
return courses, total, await repo.count_all_courses(session, only) > total
|
| 35 |
|
| 36 |
|
| 37 |
async def kinds_of(session: AsyncSession, course_id: int) -> tuple[Optional[Course], dict[str, int]]:
|
locales/en.json
CHANGED
|
@@ -47,7 +47,7 @@
|
|
| 47 |
"season_win": "🏅 You finished #{rank} last week ({xp} XP)! Congratulations.",
|
| 48 |
"season_win_prize": "\n💰 Prize: {birr} birr airtime card. The admin will contact you on Telegram.",
|
| 49 |
"season_raffle": "🎟 You won the weekly raffle!",
|
| 50 |
-
"rules_text": "📜 <b>Rules</b>\n\n<b>How to earn XP</b>\n• Daily check-in: 3-7 XP (grows with your streak)\n• Downloading a file: +1 (up to 3 a day)\n• Inviting a friend: +5 when they join, +30 once they are an active student (3+ days), max 5 a week\n• The admin may grant extra XP for useful contributions\n• Automatic sources are capped at {cap} XP per day\n\n<b>Weekly contest</b>\nStarts Monday, ends Sunday (Ethiopian time).\nWhen prizes are on, to compete you need {days}+ active days that week, {xp}+ XP, to stay in the channel, and a healthy trust score.\n\n<b>Fairness</b>\n• Every XP is recorded in an unchangeable ledger; the same action is never paid twice.\n• Suspicious activity (fake accounts, unusual mass inviting) is reviewed by the admin; points may be removed or accounts banned.\n• Prizes may change; the admin's decision is final.\n• No payment is needed to take part.",
|
| 51 |
"level_1": "Rookie",
|
| 52 |
"level_2": "Starter",
|
| 53 |
"level_3": "Sharp",
|
|
@@ -105,5 +105,8 @@
|
|
| 105 |
"badge_bronze": "🥉 Contributor",
|
| 106 |
"badge_silver": "🥈 Silver contributor",
|
| 107 |
"badge_gold": "🥇 Gold contributor",
|
| 108 |
-
"help_student": "📖 <b>How to use this bot</b>\n\n📚 <b>Library</b>: pick a course and get slides, modules and past exams. Or just type a course name to search.\n📤 <b>Contribute a file</b>: share your material; you earn XP when it is approved.\n👤 <b>Profile</b>: your level, XP, streak and badge.\n🏆 <b>Ranking</b>: the weekly leaderboard and prizes.\n👥 <b>Invite friends</b>: a personal link, one tap to share. You earn XP when they join and when they become active.\n🧮 <b>GPA calculator</b>: coming soon.\n\nAnywhere in Telegram you can also type @{bot} followed by a course name to share a file instantly.\nRules of XP and prizes: /rules"
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
|
|
|
| 47 |
"season_win": "🏅 You finished #{rank} last week ({xp} XP)! Congratulations.",
|
| 48 |
"season_win_prize": "\n💰 Prize: {birr} birr airtime card. The admin will contact you on Telegram.",
|
| 49 |
"season_raffle": "🎟 You won the weekly raffle!",
|
| 50 |
+
"rules_text": "📜 <b>Rules</b>\n\n<b>How to earn XP</b>\n• Daily check-in: 3-7 XP (grows with your streak)\n• Downloading a file: +1 (up to 3 a day)\n• Inviting a friend: +5 when they join, +30 once they are an active student (3+ days), max 5 a week\n• The admin may grant extra XP for useful contributions\n• Automatic sources are capped at {cap} XP per day\n\n<b>Weekly contest</b>\nStarts Monday, ends Sunday (Ethiopian time).\nWhen prizes are on, to compete you need {days}+ active days that week, {xp}+ XP, to stay in the channel, and a healthy trust score.\n\n<b>Fairness</b>\n• Every XP is recorded in an unchangeable ledger; the same action is never paid twice.\n• Suspicious activity (fake accounts, unusual mass inviting) is reviewed by the admin; points may be removed or accounts banned.\n• Prizes may change; the admin's decision is final.\n• We keep simple usage data (what is searched and downloaded) to decide which material to add. Only admins see it, and your messages are never shared.\n• No payment is needed to take part.",
|
| 51 |
"level_1": "Rookie",
|
| 52 |
"level_2": "Starter",
|
| 53 |
"level_3": "Sharp",
|
|
|
|
| 105 |
"badge_bronze": "🥉 Contributor",
|
| 106 |
"badge_silver": "🥈 Silver contributor",
|
| 107 |
"badge_gold": "🥇 Gold contributor",
|
| 108 |
+
"help_student": "📖 <b>How to use this bot</b>\n\n📚 <b>Library</b>: pick a course and get slides, modules and past exams. Or just type a course name to search.\n📤 <b>Contribute a file</b>: share your material; you earn XP when it is approved.\n👤 <b>Profile</b>: your level, XP, streak and badge.\n🏆 <b>Ranking</b>: the weekly leaderboard and prizes.\n👥 <b>Invite friends</b>: a personal link, one tap to share. You earn XP when they join and when they become active.\n🧮 <b>GPA calculator</b>: coming soon.\n\nAnywhere in Telegram you can also type @{bot} followed by a course name to share a file instantly.\nRules of XP and prizes: /rules",
|
| 109 |
+
"btn_more_courses": "🔀 Courses of other streams",
|
| 110 |
+
"btn_my_courses": "↩️ My courses",
|
| 111 |
+
"lib_courses_all": "📚 <b>All courses</b>\nEvery course that has files, from all streams 🔍"
|
| 112 |
}
|
tests/test_logic.py
CHANGED
|
@@ -510,6 +510,130 @@ def test_members_who_never_saw_the_gate_still_count():
|
|
| 510 |
assert "SET gate_passed = TRUE" in (root / "app/db/migrate.py").read_text(encoding="utf-8")
|
| 511 |
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
if __name__ == "__main__":
|
| 514 |
for name, fn in list(globals().items()):
|
| 515 |
if name.startswith("test_"):
|
|
|
|
| 510 |
assert "SET gate_passed = TRUE" in (root / "app/db/migrate.py").read_text(encoding="utf-8")
|
| 511 |
|
| 512 |
|
| 513 |
+
def _snapshot():
|
| 514 |
+
from datetime import datetime, timedelta, timezone
|
| 515 |
+
|
| 516 |
+
from app.services import analytics as A
|
| 517 |
+
|
| 518 |
+
ET = A.ET
|
| 519 |
+
now = datetime(2026, 10, 22, 12, 0, tzinfo=timezone.utc) # 15:00 Ethiopian time, 22 Oct
|
| 520 |
+
today = now.astimezone(ET).date()
|
| 521 |
+
|
| 522 |
+
def user(i, days_ago, source, verified, onboarded, active=0, missing=None):
|
| 523 |
+
created = now - timedelta(days=days_ago, hours=1)
|
| 524 |
+
return A.UserRow(id=i, created=created, joined=created.astimezone(ET).date(), source=source, verified=verified,
|
| 525 |
+
onboarded=onboarded, active_days=active, missing=missing)
|
| 526 |
+
|
| 527 |
+
users = (
|
| 528 |
+
[user(i, 5, "organic", True, True, 3) for i in range(1, 7)] # 6 joined 5 days ago, all fine
|
| 529 |
+
+ [user(i, 5, "organic", False, False, 0, "dbucentralforum") for i in range(7, 13)] # 6 stuck: forum missing
|
| 530 |
+
+ [user(i, 1, "ad1", True, True, 1) for i in range(13, 19)] # 6 joined yesterday
|
| 531 |
+
+ [user(i, 0, "ad1", True, False) for i in range(19, 21)] # 2 verified today, did not finish setup
|
| 532 |
+
)
|
| 533 |
+
d = lambda n: today - timedelta(days=n) # noqa: E731
|
| 534 |
+
checkins = {i: {d(5), d(4)} for i in range(1, 4)} # 3 of the first 12 came back the next day
|
| 535 |
+
checkins.update({i: {d(5)} for i in range(4, 7)})
|
| 536 |
+
checkins.update({i: {d(1), d(0)} for i in range(13, 17)})
|
| 537 |
+
events = (
|
| 538 |
+
[A.EventRow("search", "physics", 3, d(1), 1)] * 3
|
| 539 |
+
+ [A.EventRow("search", "chemistry module", 0, d(1), 2)] * 4
|
| 540 |
+
+ [A.EventRow("inline", "chemistry module", 0, d(0), 3)]
|
| 541 |
+
+ [A.EventRow("search", "x", 0, d(0), 4)] # too short to count
|
| 542 |
+
+ [A.EventRow("download", "1", 1, d(0), 1), A.EventRow("download", "1", 1, d(2), 2)]
|
| 543 |
+
)
|
| 544 |
+
materials = [A.MaterialRow(1, 10, "Physics module", 7, "ok", "note"), A.MaterialRow(2, 10, "Physics exam", 2, "ok", "exam"),
|
| 545 |
+
A.MaterialRow(3, 11, "Psych", 1, "ok", "note"), A.MaterialRow(4, 12, "Hidden", 9, "pending", "note")]
|
| 546 |
+
courses = {10: "General Physics", 11: "General Psychology", 12: "General Chemistry", 13: "Entrepreneurship"}
|
| 547 |
+
subs = [A.SubRow("approved", 5, now - timedelta(hours=30), now - timedelta(hours=28)),
|
| 548 |
+
A.SubRow("rejected", 6, now - timedelta(hours=10), now - timedelta(hours=6)),
|
| 549 |
+
A.SubRow("pending", 7, now - timedelta(hours=40), None)]
|
| 550 |
+
posts = [A.PostRow(1, now - timedelta(days=1, hours=3), "Big news t.me/Bot?start=ad1", ["ad1"], 12),
|
| 551 |
+
A.PostRow(2, now - timedelta(days=3), "meme", [], None)]
|
| 552 |
+
return A.Snapshot(now=now, users=users, checkins=checkins, events=events, materials=materials, courses=courses, subs=subs, posts=posts)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def test_analytics_numbers():
|
| 556 |
+
from datetime import timedelta
|
| 557 |
+
|
| 558 |
+
from app.services import analytics as A
|
| 559 |
+
|
| 560 |
+
s = _snapshot()
|
| 561 |
+
f = A.funnel(s.users)
|
| 562 |
+
assert f == dict(joined=20, verified=14, onboarded=12, active3=6)
|
| 563 |
+
assert A.biggest_leak(f) == ("joined", "in channel + forum", 6, 30)
|
| 564 |
+
table = {r["source"]: r for r in A.source_table(s.users)}
|
| 565 |
+
assert table["organic"]["joined"] == 12 and table["organic"]["setup_pct"] == 50
|
| 566 |
+
assert table["ad1"]["joined"] == 8 and table["ad1"]["onboarded"] == 6 and table["ad1"]["setup_pct"] == 75
|
| 567 |
+
assert A.stuck_by_missing(s.users) == [("dbucentralforum", 6)]
|
| 568 |
+
days = A.per_day(s.users, s.today, 7)
|
| 569 |
+
assert len(days) == 7 and days[-1] == (s.today, 2) and days[-2] == (s.today - timedelta(days=1), 6)
|
| 570 |
+
assert sum(n for _, n in days) == 20
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def test_analytics_retention_and_activity():
|
| 574 |
+
from datetime import timedelta
|
| 575 |
+
|
| 576 |
+
from app.services import analytics as A
|
| 577 |
+
|
| 578 |
+
s = _snapshot()
|
| 579 |
+
ret = A.retention(s.users, s.checkins, s.today)
|
| 580 |
+
old = next(r for r in ret["rows"] if r["day"] == s.today - timedelta(days=5))
|
| 581 |
+
assert old["size"] == 12 and old["d1"] == 25 # 3 of the 12 were active the next day
|
| 582 |
+
assert old["d7"] is None # day 7 has not come yet
|
| 583 |
+
yesterday = next(r for r in ret["rows"] if r["day"] == s.today - timedelta(days=1))
|
| 584 |
+
assert yesterday["d1"] is None # "tomorrow" is today, which is not over: never counted, never guessed
|
| 585 |
+
assert ret["came_back"] == 7 and ret["eligible"] == 18
|
| 586 |
+
w = A.dau_wau(s.checkins, s.today)
|
| 587 |
+
assert w["wau"] == 10 and dict(w["dau"])[s.today] == 4
|
| 588 |
+
# before activity tracking began a cohort must be n/a, not 0%
|
| 589 |
+
ret2 = A.retention(s.users, {1: {s.today}}, s.today)
|
| 590 |
+
assert all(r["d1"] is None for r in ret2["rows"] if r["day"] < s.today - timedelta(days=2))
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def test_analytics_library_searches_contribs_channel():
|
| 594 |
+
from app.services import analytics as A
|
| 595 |
+
|
| 596 |
+
s = _snapshot()
|
| 597 |
+
lib = A.library(s)
|
| 598 |
+
assert lib["files"] == 3 and lib["empty"] == ["General Chemistry", "Entrepreneurship"] # pending files do not count
|
| 599 |
+
assert lib["sparse"] == ["General Physics", "General Psychology"] # 2 and 1 files: both under 3
|
| 600 |
+
assert lib["missed"][0] == ("chemistry module", 5) # 4 text + 1 inline; the 1-letter search is ignored
|
| 601 |
+
assert lib["searches"] == 8 and lib["missed_total"] == 5
|
| 602 |
+
assert lib["downloads_week"] == 2 and lib["downloaders_week"] == 2 and lib["top_files"][0].title == "Physics module"
|
| 603 |
+
c = A.contributions(s)
|
| 604 |
+
assert (c["pending"], c["approved"], c["rejected"], c["approval"]) == (1, 1, 1, 50)
|
| 605 |
+
assert c["oldest_pending_hours"] == 40.0 and c["avg_hours"] == 3.0
|
| 606 |
+
ch = A.channel_posts(s)
|
| 607 |
+
assert ch[0]["message_id"] == 1 and ch[0]["tagged"] == 8 # the 8 students whose source is the post's tag "ad1"
|
| 608 |
+
assert A.extract_tags("go t.me/B?start=ad2 or t.me/B?start=123456 and ?start=Dorm5") == ["ad2", "dorm5"]
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def test_analytics_insights_and_screens():
|
| 612 |
+
import re
|
| 613 |
+
|
| 614 |
+
from app.services import analytics as A
|
| 615 |
+
|
| 616 |
+
s = _snapshot()
|
| 617 |
+
ins = A.insights(s)
|
| 618 |
+
titles = [i.title for i in ins]
|
| 619 |
+
assert "Students stop at the join step" in titles # 6 of 20 = 30% (rule: 15% or more)
|
| 620 |
+
assert "Students search for things you do not have" in titles
|
| 621 |
+
assert "Many courses have no file" in titles # 2 of 4 courses are empty
|
| 622 |
+
assert "Student files are waiting" in titles # the oldest pending file is 40 h old
|
| 623 |
+
first = ins[0]
|
| 624 |
+
assert first.priority == 1 and "30%" in first.evidence and "dbucentralforum" in first.evidence
|
| 625 |
+
screens = (A.overview, A.section_growth, A.section_retention, A.section_library, A.section_contrib,
|
| 626 |
+
A.section_channel, A.section_insights)
|
| 627 |
+
for render in screens:
|
| 628 |
+
text = render(s)
|
| 629 |
+
assert 20 < len(text) < 4000, render.__name__
|
| 630 |
+
for tag in ("b", "i"):
|
| 631 |
+
assert len(re.findall(rf"<{tag}>", text)) == len(re.findall(rf"</{tag}>", text)), render.__name__
|
| 632 |
+
empty = A.Snapshot(now=s.now, users=[], checkins={}, events=[], materials=[], courses={}, subs=[], posts=[])
|
| 633 |
+
for render in screens: # an empty project must not crash anything
|
| 634 |
+
assert render(empty)
|
| 635 |
+
|
| 636 |
+
|
| 637 |
if __name__ == "__main__":
|
| 638 |
for name, fn in list(globals().items()):
|
| 639 |
if name.startswith("test_"):
|