feat: add telegram notification channel with outbox service and per-user preferences

Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
This commit is contained in:
2026-06-22 20:52:02 +00:00
parent a9d0814c47
commit e0e64c8d9e
36 changed files with 720 additions and 78 deletions
+59
View File
@@ -98,3 +98,62 @@ def test_daily_quests_deterministic_count_and_kinds():
assert len(first) == economy.DAILY_QUEST_COUNT
assert all(quest["kind"] in economy.QUEST_DEFS for quest in first)
assert all(quest["goal"] > 0 for quest in first)
def test_farm_score_empty_is_zero():
assert economy.farm_score({}) == 0
def test_farm_score_counts_every_factor():
score = economy.farm_score(
{
"xp": 100,
"prestige": 2,
"total_harvests": 3,
"coins": 200,
"ci_tier": 3,
"plot_count": economy.STARTING_PLOTS + 2,
"perk_yield": 1,
"perk_growth": 1,
"perk_discount": 1,
"perk_xp": 1,
"streak": 4,
}
)
expected = (
100
+ 2 * economy.SCORE_PRESTIGE
+ 3 * economy.SCORE_HARVEST
+ 200 // economy.SCORE_COIN_DIVISOR
+ (3 - 1) * economy.SCORE_CI
+ 2 * economy.SCORE_PLOT
+ 4 * economy.SCORE_PERK
+ 4 * economy.SCORE_STREAK
)
assert score == expected
def test_farm_score_prestige_is_additive_and_substantial():
base = {"xp": 800, "total_harvests": 5}
with_prestige = {**base, "prestige": 1}
gain = economy.farm_score(with_prestige) - economy.farm_score(base)
assert gain == economy.SCORE_PRESTIGE
assert gain >= economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)
def test_farm_score_prestige_lifts_above_lower_level_player():
refactorer = {"xp": 200, "prestige": 1}
higher_level_no_prestige = {"xp": economy.xp_threshold(5)}
assert economy.farm_score(refactorer) > economy.farm_score(higher_level_no_prestige)
def test_farm_score_streak_is_capped():
capped = economy.farm_score({"streak": economy.SCORE_STREAK_CAP})
over = economy.farm_score({"streak": economy.SCORE_STREAK_CAP + 50})
assert capped == over
def test_farm_score_handles_legacy_null_columns():
assert economy.farm_score(
{"xp": 50, "prestige": None, "perk_yield": None, "streak": None}
) == 50
+21 -1
View File
@@ -526,7 +526,7 @@ def test_serialize_handles_legacy_row_without_new_columns(local_db):
# --- leaderboard -------------------------------------------------------------
def test_leaderboard_orders_by_level_then_xp(local_db):
def test_leaderboard_orders_by_score(local_db):
high = _reset("unit_lb_high")
low = _reset("unit_lb_low")
_set(high, level=8, xp=economy.xp_threshold(8))
@@ -535,3 +535,23 @@ def test_leaderboard_orders_by_level_then_xp(local_db):
names = [entry["username"] for entry in board]
assert "unit_lb_high" in names and "unit_lb_low" in names
assert names.index("unit_lb_high") < names.index("unit_lb_low")
def test_leaderboard_prestige_lifts_above_lower_level(local_db):
refactorer = _reset("unit_lb_refactor")
grinder = _reset("unit_lb_grind")
_set(refactorer, level=1, xp=200, prestige=1)
_set(grinder, level=5, xp=economy.xp_threshold(5))
board = store.leaderboard(50)
names = [entry["username"] for entry in board]
assert names.index("unit_lb_refactor") < names.index("unit_lb_grind")
def test_leaderboard_entry_exposes_score_and_prestige(local_db):
user = _reset("unit_lb_fields")
_set(user, prestige=3, total_harvests=12, ci_tier=2)
entry = next(e for e in store.leaderboard(50) if e["username"] == "unit_lb_fields")
farm = store.get_farm(user["uid"])
assert entry["prestige"] == 3
assert entry["score"] == economy.farm_score(farm)
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry)