cipher-attacks commited on
Commit
d925e38
·
1 Parent(s): 34c3133

track join-step blocker

Browse files
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, "lib_courses"), kb.courses(user.lang, items, page, total, lib.PAGE_SIZE)
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(lang: str, items: Iterable[Course], page: int, total: int, size: int) -> InlineKeyboardMarkup:
 
 
34
  rows = [[_btn(f"{course_icon(c)} {course_label(c)}", f"lib:k:{c.id}")] for c in items]
35
- nav = _nav(lang, "lib:c", page, total, size)
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_"):