|
import dataset
|
|
import logging
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta, timezone
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.config import DATABASE_URL, INTERNAL_GATEWAY_URL
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
db = dataset.connect(
|
|
DATABASE_URL,
|
|
engine_kwargs={
|
|
"connect_args": {
|
|
"timeout": 30,
|
|
"check_same_thread": False,
|
|
},
|
|
},
|
|
on_connect_statements=[
|
|
"PRAGMA journal_mode=WAL",
|
|
"PRAGMA synchronous=NORMAL",
|
|
"PRAGMA busy_timeout=30000",
|
|
"PRAGMA cache_size=-8000",
|
|
"PRAGMA temp_store=MEMORY",
|
|
"PRAGMA mmap_size=268435456",
|
|
],
|
|
)
|
|
|
|
|
|
def refresh_snapshot() -> None:
|
|
connection = db.executable
|
|
if connection.in_transaction() and not db.in_transaction:
|
|
connection.commit()
|
|
|
|
|
|
_cache_version_cache = TTLCache(ttl=2)
|
|
_local_cache_versions: dict = {}
|
|
_cache_state_ready = False
|
|
|
|
|
|
def _ensure_cache_state() -> None:
|
|
global _cache_state_ready
|
|
if _cache_state_ready:
|
|
return
|
|
db.query(
|
|
"CREATE TABLE IF NOT EXISTS cache_state "
|
|
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
|
|
)
|
|
_cache_state_ready = True
|
|
|
|
|
|
def get_cache_version(name: str) -> int:
|
|
cached = _cache_version_cache.get(name)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
_ensure_cache_state()
|
|
row = next(iter(db.query("SELECT version FROM cache_state WHERE name = :name", name=name)), None)
|
|
version = int(row["version"]) if row else 0
|
|
except Exception as e:
|
|
logger.warning(f"Could not read cache version {name}: {e}")
|
|
version = 0
|
|
_cache_version_cache.set(name, version)
|
|
return version
|
|
|
|
|
|
def bump_cache_version(name: str) -> None:
|
|
try:
|
|
_ensure_cache_state()
|
|
db.query("INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)", name=name)
|
|
db.query("UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name)
|
|
connection = db.executable
|
|
if connection.in_transaction():
|
|
connection.commit()
|
|
except Exception as e:
|
|
logger.warning(f"Could not bump cache version {name}: {e}")
|
|
_cache_version_cache.pop(name)
|
|
|
|
|
|
def sync_local_cache(name: str, cache) -> None:
|
|
current = get_cache_version(name)
|
|
if name not in _local_cache_versions:
|
|
_local_cache_versions[name] = current
|
|
return
|
|
if _local_cache_versions[name] != current:
|
|
cache.clear()
|
|
_local_cache_versions[name] = current
|
|
|
|
|
|
def _index(db, table, name, columns):
|
|
try:
|
|
if table in db.tables:
|
|
cols = ", ".join(columns)
|
|
db.query(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({cols})")
|
|
except Exception as e:
|
|
logger.warning(f"Could not create index {name} on {table}: {e}")
|
|
|
|
|
|
def init_db():
|
|
tables = db.tables
|
|
_index(db, "users", "idx_users_username", ["username"])
|
|
_index(db, "users", "idx_users_email", ["email"])
|
|
_index(db, "users", "idx_users_api_key", ["api_key"])
|
|
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
|
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
|
_index(db, "posts", "idx_posts_topic", ["topic"])
|
|
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
|
|
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
|
|
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
|
|
_index(db, "comments", "idx_comments_created_at", ["created_at"])
|
|
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
|
|
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
|
|
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
|
|
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
|
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
|
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
|
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
|
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
|
_index(db, "project_files", "idx_project_files_path", ["project_uid", "path"])
|
|
_index(db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"])
|
|
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
|
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
|
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
|
_index(db, "password_resets", "idx_password_resets_token", ["token"])
|
|
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
|
|
_index(db, "gists", "idx_gists_language", ["language"])
|
|
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
|
|
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
|
|
_index(db, "reactions", "idx_reactions_target", ["target_type", "target_uid"])
|
|
_index(db, "reactions", "idx_reactions_user_target", ["user_uid", "target_type", "target_uid"])
|
|
_index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"])
|
|
_index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"])
|
|
_index(db, "polls", "idx_polls_post", ["post_uid"])
|
|
_index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"])
|
|
_index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"])
|
|
_index(db, "poll_votes", "idx_poll_votes_user", ["poll_uid", "user_uid"])
|
|
|
|
if "site_settings" in tables:
|
|
defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."}
|
|
for key, value in defaults.items():
|
|
existing = db["site_settings"].find_one(key=key)
|
|
if not existing:
|
|
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
|
|
|
_index(db, "news", "idx_news_external_id", ["external_id"])
|
|
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
|
_index(db, "news", "idx_news_status", ["status"])
|
|
|
|
news_images = get_table("news_images")
|
|
if not news_images.has_column("news_uid"):
|
|
news_images.create_column_by_example("news_uid", "")
|
|
if not news_images.has_column("url"):
|
|
news_images.create_column_by_example("url", "")
|
|
|
|
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
|
|
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
|
_index(db, "service_state", "idx_service_state_name", ["name"])
|
|
_index(db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"])
|
|
_index(db, "devii_usage_ledger", "idx_devii_ledger_owner_time", ["owner_kind", "owner_id", "created_at"])
|
|
_index(db, "devii_turns", "idx_devii_turns_owner_time", ["owner_kind", "owner_id", "started_at"])
|
|
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
|
|
_index(db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"])
|
|
_index(db, "devii_lessons", "idx_devii_lessons_owner", ["owner_kind", "owner_id"])
|
|
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
|
_index(db, "gateway_usage_ledger", "idx_gw_usage_backend_time", ["backend", "created_at"])
|
|
_index(db, "gateway_usage_ledger", "idx_gw_usage_model_time", ["model", "created_at"])
|
|
_index(db, "gateway_usage_ledger", "idx_gw_usage_owner_time", ["owner_kind", "owner_id", "created_at"])
|
|
_index(db, "gateway_usage_ledger", "idx_gw_usage_endpoint_time", ["endpoint", "created_at"])
|
|
_index(db, "gateway_concurrency_samples", "idx_gw_conc_time", ["created_at"])
|
|
_index(db, "jobs", "idx_jobs_kind_status", ["kind", "status"])
|
|
_index(db, "jobs", "idx_jobs_owner", ["owner_kind", "owner_id"])
|
|
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
|
|
|
|
if "news" in tables:
|
|
for article in db["news"].find(status=None):
|
|
was_featured = article.get("featured", 0)
|
|
db["news"].update({
|
|
"uid": article["uid"],
|
|
"status": "published" if was_featured else "draft",
|
|
}, ["uid"])
|
|
for article in db["news"].find(show_on_landing=None):
|
|
db["news"].update({
|
|
"uid": article["uid"],
|
|
"show_on_landing": 0,
|
|
}, ["uid"])
|
|
for article in db["news"].find(slug=None):
|
|
from devplacepy.utils import make_combined_slug
|
|
slug = make_combined_slug(article.get("title", "") or "news", article["uid"])
|
|
db["news"].update({
|
|
"uid": article["uid"],
|
|
"slug": slug,
|
|
}, ["uid"])
|
|
|
|
if "news_sync" in tables:
|
|
for entry in db["news_sync"].find():
|
|
current = entry.get("status", "")
|
|
if current in ("below_threshold", ""):
|
|
db["news_sync"].update({
|
|
"id": entry["id"],
|
|
"status": "graded",
|
|
}, ["id"])
|
|
|
|
if "site_settings" in tables:
|
|
news_defaults = {
|
|
"news_grade_threshold": "7",
|
|
"news_api_url": "https://news.app.molodetz.nl/api",
|
|
"news_ai_url": INTERNAL_GATEWAY_URL,
|
|
"news_ai_model": "molodetz",
|
|
}
|
|
for key, value in news_defaults.items():
|
|
existing = db["site_settings"].find_one(key=key)
|
|
if not existing:
|
|
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
|
|
|
upload_defaults = {
|
|
"max_upload_size_mb": "10",
|
|
"allowed_file_types": "",
|
|
"max_attachments_per_resource": "10",
|
|
}
|
|
for key, value in upload_defaults.items():
|
|
existing = db["site_settings"].find_one(key=key)
|
|
if not existing:
|
|
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
|
|
|
operational_defaults = {
|
|
"rate_limit_per_minute": "60",
|
|
"rate_limit_window_seconds": "60",
|
|
"news_service_interval": "3600",
|
|
"session_max_age_days": "7",
|
|
"session_remember_days": "30",
|
|
"registration_open": "1",
|
|
"maintenance_mode": "0",
|
|
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
|
|
}
|
|
for key, value in operational_defaults.items():
|
|
existing = db["site_settings"].find_one(key=key)
|
|
if not existing:
|
|
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
|
|
|
_backfill_gamification()
|
|
backfill_api_keys()
|
|
migrate_ai_gateway_settings()
|
|
|
|
logger.info("Database initialized")
|
|
|
|
|
|
def internal_gateway_key() -> str:
|
|
return get_setting("gateway_internal_key", "")
|
|
|
|
|
|
OLD_GATEWAY_URL = "https://openai.app.molodetz.nl/v1/chat/completions"
|
|
|
|
|
|
def migrate_ai_gateway_settings() -> None:
|
|
import os
|
|
import uuid
|
|
if not get_setting("gateway_internal_key", ""):
|
|
set_setting("gateway_internal_key", str(uuid.uuid4()))
|
|
logger.info("Generated internal gateway key")
|
|
deepseek = os.environ.get("DEEPSEEK_API_KEY", "")
|
|
openrouter = os.environ.get("OPENROUTER_API_KEY", "")
|
|
if not get_setting("gateway_api_key", "") and (deepseek or openrouter):
|
|
set_setting("gateway_api_key", deepseek or openrouter)
|
|
logger.info("Migrated gateway upstream key from environment")
|
|
if not get_setting("gateway_vision_key", "") and openrouter:
|
|
set_setting("gateway_vision_key", openrouter)
|
|
logger.info("Migrated gateway vision key from environment")
|
|
for key in ("news_ai_url", "bot_api_url", "devii_ai_url"):
|
|
if get_setting(key, "") == OLD_GATEWAY_URL:
|
|
set_setting(key, INTERNAL_GATEWAY_URL)
|
|
logger.info(f"Migrated {key} to the internal gateway")
|
|
if get_setting("bot_model", "") == "deepseek-chat":
|
|
set_setting("bot_model", "molodetz")
|
|
logger.info("Migrated bot_model to molodetz")
|
|
|
|
|
|
def backfill_api_keys() -> int:
|
|
if "users" not in db.tables:
|
|
return 0
|
|
users = db["users"]
|
|
if not users.has_column("api_key"):
|
|
users.create_column_by_example("api_key", "")
|
|
import uuid_utils
|
|
updated = 0
|
|
for user in users.find():
|
|
if not user.get("api_key"):
|
|
users.update({"uid": user["uid"], "api_key": str(uuid_utils.uuid7())}, ["uid"])
|
|
updated += 1
|
|
return updated
|
|
|
|
|
|
def _backfill_gamification():
|
|
if "users" not in db.tables:
|
|
return
|
|
from devplacepy.utils import (
|
|
level_for_xp, check_milestone_badges,
|
|
XP_POST, XP_COMMENT, XP_PROJECT, XP_GIST, XP_UPVOTE, XP_FOLLOW,
|
|
)
|
|
|
|
pending = list(db["users"].find(xp=0))
|
|
if not pending:
|
|
return
|
|
|
|
xp_by_user = defaultdict(int)
|
|
|
|
def add_counts(table, column, points):
|
|
if table not in db.tables:
|
|
return
|
|
for row in db.query(f"SELECT {column} AS uid, COUNT(*) AS c FROM {table} GROUP BY {column}"):
|
|
if row["uid"]:
|
|
xp_by_user[row["uid"]] += row["c"] * points
|
|
|
|
add_counts("posts", "user_uid", XP_POST)
|
|
add_counts("comments", "user_uid", XP_COMMENT)
|
|
add_counts("projects", "user_uid", XP_PROJECT)
|
|
add_counts("gists", "user_uid", XP_GIST)
|
|
add_counts("follows", "following_uid", XP_FOLLOW)
|
|
|
|
if "votes" in db.tables:
|
|
for content_table, target_type in (("posts", "post"), ("projects", "project"), ("gists", "gist"), ("comments", "comment")):
|
|
if content_table not in db.tables:
|
|
continue
|
|
rows = db.query(
|
|
f"SELECT c.user_uid AS uid, COUNT(*) AS c "
|
|
f"FROM votes v JOIN {content_table} c ON v.target_uid = c.uid "
|
|
f"WHERE v.target_type = :t AND v.value = 1 GROUP BY c.user_uid",
|
|
t=target_type,
|
|
)
|
|
for row in rows:
|
|
if row["uid"]:
|
|
xp_by_user[row["uid"]] += row["c"] * XP_UPVOTE
|
|
|
|
for user in pending:
|
|
xp = xp_by_user.get(user["uid"], 0)
|
|
if xp <= 0:
|
|
continue
|
|
db["users"].update({"uid": user["uid"], "xp": xp, "level": level_for_xp(xp)}, ["uid"])
|
|
|
|
_authors_cache.clear()
|
|
for user in pending:
|
|
check_milestone_badges(user["uid"])
|
|
logger.info(f"Gamification backfill processed {len(pending)} users")
|
|
|
|
|
|
def get_table(name):
|
|
return db[name]
|
|
|
|
|
|
def _in_clause(uids, prefix="p"):
|
|
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
|
|
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
|
|
return placeholders, params
|
|
|
|
|
|
def get_users_by_uids(uids):
|
|
if not uids:
|
|
return {}
|
|
seen = set()
|
|
unique = [u for u in uids if u not in seen and not seen.add(u)]
|
|
return {u["uid"]: u for u in db["users"].find(db["users"].table.columns.uid.in_(unique))}
|
|
|
|
|
|
def get_comment_counts_by_post_uids(post_uids):
|
|
if not post_uids or "comments" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(post_uids)
|
|
rows = db.query(f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) GROUP BY target_uid", **params)
|
|
return {r["target_uid"]: r["c"] for r in rows}
|
|
|
|
|
|
def get_post_counts_by_user_uids(user_uids):
|
|
if not user_uids or "posts" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(user_uids)
|
|
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
|
|
return {r["user_uid"]: r["c"] for r in rows}
|
|
|
|
|
|
def get_vote_counts(target_uids):
|
|
if not target_uids or "votes" not in db.tables:
|
|
return {}, {}
|
|
placeholders, params = _in_clause(target_uids)
|
|
rows = db.query(f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) GROUP BY target_uid, value", **params)
|
|
ups = {}
|
|
downs = {}
|
|
for r in rows:
|
|
if r["value"] == 1:
|
|
ups[r["target_uid"]] = r["c"]
|
|
else:
|
|
downs[r["target_uid"]] = r["c"]
|
|
return ups, downs
|
|
|
|
|
|
def get_user_votes(user_uid, target_uids):
|
|
if not user_uid or not target_uids or "votes" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(target_uids)
|
|
params["uid"] = user_uid
|
|
rows = db.query(f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders})", **params)
|
|
return {r["target_uid"]: r["value"] for r in rows}
|
|
|
|
|
|
def get_reactions_by_targets(target_type, target_uids, user=None):
|
|
empty = {"counts": {}, "mine": []}
|
|
if not target_uids or "reactions" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(target_uids)
|
|
params["tt"] = target_type
|
|
rows = db.query(f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) GROUP BY target_uid, emoji", **params)
|
|
counts = defaultdict(dict)
|
|
for row in rows:
|
|
counts[row["target_uid"]][row["emoji"]] = row["c"]
|
|
mine = defaultdict(list)
|
|
if user:
|
|
my_placeholders, my_params = _in_clause(target_uids, prefix="m")
|
|
my_params["tt"] = target_type
|
|
my_params["u"] = user["uid"]
|
|
for row in db.query(f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({my_placeholders})", **my_params):
|
|
mine[row["target_uid"]].append(row["emoji"])
|
|
result = {}
|
|
for uid in target_uids:
|
|
result[uid] = {"counts": dict(counts.get(uid, {})), "mine": list(mine.get(uid, []))}
|
|
return result
|
|
|
|
|
|
def get_user_bookmarks(user_uid, target_type, target_uids):
|
|
if not user_uid or not target_uids or "bookmarks" not in db.tables:
|
|
return set()
|
|
placeholders, params = _in_clause(target_uids)
|
|
params["u"] = user_uid
|
|
params["tt"] = target_type
|
|
rows = db.query(f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders})", **params)
|
|
return {row["target_uid"] for row in rows}
|
|
|
|
|
|
def get_polls_by_post_uids(post_uids, user=None):
|
|
if not post_uids or "polls" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(post_uids)
|
|
polls = list(db.query(f"SELECT * FROM polls WHERE post_uid IN ({placeholders})", **params))
|
|
if not polls:
|
|
return {}
|
|
poll_uids = [poll["uid"] for poll in polls]
|
|
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
|
|
options = list(db.query(f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) ORDER BY position", **option_params))
|
|
counts = defaultdict(dict)
|
|
totals = defaultdict(int)
|
|
if "poll_votes" in db.tables:
|
|
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
|
|
for row in db.query(f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) GROUP BY poll_uid, option_uid", **vote_params):
|
|
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
|
|
totals[row["poll_uid"]] += row["c"]
|
|
my_choice = {}
|
|
if user and "poll_votes" in db.tables:
|
|
my_placeholders, my_params = _in_clause(poll_uids, prefix="m")
|
|
my_params["u"] = user["uid"]
|
|
for row in db.query(f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({my_placeholders})", **my_params):
|
|
my_choice[row["poll_uid"]] = row["option_uid"]
|
|
options_by_poll = defaultdict(list)
|
|
for option in options:
|
|
options_by_poll[option["poll_uid"]].append(option)
|
|
result = {}
|
|
for poll in polls:
|
|
poll_uid = poll["uid"]
|
|
total = totals.get(poll_uid, 0)
|
|
rendered = []
|
|
for option in options_by_poll.get(poll_uid, []):
|
|
count = counts.get(poll_uid, {}).get(option["uid"], 0)
|
|
rendered.append({
|
|
"uid": option["uid"],
|
|
"label": option["label"],
|
|
"count": count,
|
|
"pct": round(count * 100 / total) if total else 0,
|
|
})
|
|
result[poll["post_uid"]] = {
|
|
"uid": poll_uid,
|
|
"question": poll["question"],
|
|
"options": rendered,
|
|
"total": total,
|
|
"my_choice": my_choice.get(poll_uid),
|
|
}
|
|
return result
|
|
|
|
|
|
def get_poll_for_post(post_uid, user=None):
|
|
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
|
|
|
|
|
def _build_comment_items(raw, user=None):
|
|
uids = [c["user_uid"] for c in raw]
|
|
cids = [c["uid"] for c in raw]
|
|
users = get_users_by_uids(uids)
|
|
ups, downs = get_vote_counts(cids)
|
|
my_votes = get_user_votes(user["uid"], cids) if user else {}
|
|
reactions = get_reactions_by_targets("comment", cids, user)
|
|
from devplacepy.utils import time_ago
|
|
from devplacepy.attachments import get_attachments_batch as _gab
|
|
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
|
|
items = {}
|
|
for c in raw:
|
|
items[c["uid"]] = {
|
|
"comment": c,
|
|
"author": users.get(c["user_uid"]),
|
|
"time_ago": time_ago(c["created_at"]),
|
|
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
|
|
"my_vote": my_votes.get(c["uid"], 0),
|
|
"children": [],
|
|
"attachments": atts_map.get(c["uid"], []),
|
|
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
|
|
}
|
|
return items
|
|
|
|
|
|
def load_comments(target_type, target_uid, user=None):
|
|
if "comments" not in db.tables:
|
|
return []
|
|
comments_table = db["comments"]
|
|
raw = list(comments_table.find(target_type=target_type, target_uid=target_uid, order_by=["created_at"]))
|
|
if not raw and target_type == "post":
|
|
raw = list(comments_table.find(post_uid=target_uid, order_by=["created_at"]))
|
|
if not raw:
|
|
return []
|
|
cmap = _build_comment_items(raw, user)
|
|
top = []
|
|
for item in cmap.values():
|
|
parent = item["comment"].get("parent_uid")
|
|
if parent and parent in cmap:
|
|
cmap[parent]["children"].append(item)
|
|
else:
|
|
top.append(item)
|
|
return top
|
|
|
|
|
|
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
|
if not post_uids or "comments" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(post_uids)
|
|
params["lim"] = limit
|
|
raw = list(db.query(
|
|
f"SELECT * FROM ("
|
|
f" SELECT *, ROW_NUMBER() OVER ("
|
|
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
|
|
f" ) AS rn FROM comments"
|
|
f" WHERE target_type='post' AND target_uid IN ({placeholders})"
|
|
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
|
|
**params,
|
|
))
|
|
if not raw:
|
|
return {}
|
|
items = _build_comment_items(raw, user)
|
|
result = defaultdict(list)
|
|
for c in raw:
|
|
result[c["target_uid"]].append(items[c["uid"]])
|
|
return dict(result)
|
|
|
|
|
|
def get_attachments(resource_type: str, resource_uid: str) -> list:
|
|
if "attachments" not in db.tables:
|
|
return []
|
|
return list(db["attachments"].find(resource_type=resource_type, resource_uid=resource_uid, order_by=["created_at"]))
|
|
|
|
|
|
def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
|
|
if not resource_uids or "attachments" not in db.tables:
|
|
return {}
|
|
rows = list(db["attachments"].find(db["attachments"].table.columns.resource_uid.in_(resource_uids), resource_type=resource_type))
|
|
result = {}
|
|
for a in rows:
|
|
key = a["resource_uid"]
|
|
if key not in result:
|
|
result[key] = []
|
|
result[key].append(a)
|
|
return result
|
|
|
|
|
|
def get_news_images_by_uids(news_uids: list) -> dict:
|
|
if not news_uids or "news_images" not in db.tables:
|
|
return {}
|
|
images_table = db["news_images"]
|
|
if not images_table.has_column("news_uid"):
|
|
return {}
|
|
rows = images_table.find(images_table.table.columns.news_uid.in_(news_uids), order_by=["uid"])
|
|
result = {}
|
|
for r in rows:
|
|
result.setdefault(r["news_uid"], r["url"])
|
|
return result
|
|
|
|
|
|
def delete_attachment_record(uid: str) -> None:
|
|
if "attachments" not in db.tables:
|
|
return
|
|
att = db["attachments"].find_one(uid=uid)
|
|
if att:
|
|
_delete_attachment_file(att.get("storage_path", ""))
|
|
db["attachments"].delete(id=att["id"])
|
|
|
|
|
|
def delete_attachments(resource_type: str, resource_uid: str) -> None:
|
|
if "attachments" not in db.tables:
|
|
return
|
|
for a in db["attachments"].find(resource_type=resource_type, resource_uid=resource_uid):
|
|
_delete_attachment_file(a.get("storage_path", ""))
|
|
db["attachments"].delete(resource_type=resource_type, resource_uid=resource_uid)
|
|
|
|
|
|
def _delete_attachment_file(storage_path: str) -> None:
|
|
if not storage_path:
|
|
return
|
|
from devplacepy.config import STATIC_DIR
|
|
file_path = STATIC_DIR / "uploads" / storage_path
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
parent = file_path.parent
|
|
if parent.exists() and not any(parent.iterdir()):
|
|
parent.rmdir()
|
|
grandparent = parent.parent
|
|
if grandparent.exists() and not any(grandparent.iterdir()):
|
|
grandparent.rmdir()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete attachment file {storage_path}: {e}")
|
|
|
|
|
|
_settings_cache = TTLCache(ttl=60)
|
|
|
|
|
|
def get_setting(key: str, default: str = "") -> str:
|
|
sync_local_cache("settings", _settings_cache)
|
|
cached = _settings_cache.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
if "site_settings" not in db.tables:
|
|
return default
|
|
entry = db["site_settings"].find_one(key=key)
|
|
if entry is None:
|
|
return default
|
|
_settings_cache.set(key, entry["value"])
|
|
return entry["value"]
|
|
|
|
|
|
def get_int_setting(key: str, default: int) -> int:
|
|
raw = get_setting(key, str(default))
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def set_setting(key: str, value: str) -> None:
|
|
settings = get_table("site_settings")
|
|
existing = settings.find_one(key=key)
|
|
if existing:
|
|
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
|
|
else:
|
|
settings.insert({"uid": f"setting_{key}", "key": key, "value": value})
|
|
_settings_cache.set(key, value)
|
|
bump_cache_version("settings")
|
|
|
|
|
|
def clear_settings_cache() -> None:
|
|
_settings_cache.clear()
|
|
bump_cache_version("settings")
|
|
|
|
|
|
_stats_cache = TTLCache(ttl=30)
|
|
|
|
|
|
def get_site_stats() -> dict:
|
|
cached = _stats_cache.get("site")
|
|
if cached is not None:
|
|
return cached
|
|
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
stats = {
|
|
"total_members": db["users"].count() if "users" in db.tables else 0,
|
|
"posts_today": db["posts"].count(created_at={">=": today_start}) if "posts" in db.tables else 0,
|
|
"total_projects": db["projects"].count() if "projects" in db.tables else 0,
|
|
"total_gists": db["gists"].count() if "gists" in db.tables else 0,
|
|
}
|
|
_stats_cache.set("site", stats)
|
|
return stats
|
|
|
|
|
|
_analytics_cache = TTLCache(ttl=60)
|
|
|
|
|
|
def get_platform_analytics(top_n: int = 10) -> dict:
|
|
top_n = max(1, min(int(top_n or 10), 50))
|
|
cache_key = f"analytics:{top_n}"
|
|
cached = _analytics_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
now = datetime.now(timezone.utc)
|
|
d1 = (now - timedelta(days=1)).isoformat()
|
|
d7 = (now - timedelta(days=7)).isoformat()
|
|
d30 = (now - timedelta(days=30)).isoformat()
|
|
|
|
active_24h = active_7d = active_30d = 0
|
|
sources = [table for table in ("posts", "comments", "gists", "projects") if table in db.tables]
|
|
if sources:
|
|
union = " UNION ALL ".join(f"SELECT user_uid, created_at FROM {table}" for table in sources)
|
|
rows = db.query(
|
|
"SELECT "
|
|
"COUNT(DISTINCT CASE WHEN created_at >= :d1 THEN user_uid END) AS a1, "
|
|
"COUNT(DISTINCT CASE WHEN created_at >= :d7 THEN user_uid END) AS a7, "
|
|
"COUNT(DISTINCT CASE WHEN created_at >= :d30 THEN user_uid END) AS a30 "
|
|
f"FROM ({union})",
|
|
d1=d1, d7=d7, d30=d30,
|
|
)
|
|
for row in rows:
|
|
active_24h = row["a1"] or 0
|
|
active_7d = row["a7"] or 0
|
|
active_30d = row["a30"] or 0
|
|
|
|
signed_in_now = 0
|
|
if "sessions" in db.tables:
|
|
for row in db.query(
|
|
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now",
|
|
now=now.isoformat(),
|
|
):
|
|
signed_in_now = row["n"] or 0
|
|
|
|
def _new_since(cutoff: str) -> int:
|
|
return db["users"].count(created_at={">=": cutoff}) if "users" in db.tables else 0
|
|
|
|
site = get_site_stats()
|
|
totals = {
|
|
"posts": db["posts"].count() if "posts" in db.tables else 0,
|
|
"comments": db["comments"].count() if "comments" in db.tables else 0,
|
|
"gists": site["total_gists"],
|
|
"projects": site["total_projects"],
|
|
"news": db["news"].count() if "news" in db.tables else 0,
|
|
}
|
|
top_authors = [
|
|
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
|
|
for author in get_top_authors(top_n)
|
|
]
|
|
|
|
result = {
|
|
"total_members": site["total_members"],
|
|
"active_24h": active_24h,
|
|
"active_7d": active_7d,
|
|
"active_30d": active_30d,
|
|
"signed_in_now": signed_in_now,
|
|
"new_24h": _new_since(d1),
|
|
"new_7d": _new_since(d7),
|
|
"new_30d": _new_since(d30),
|
|
"posts_today": site["posts_today"],
|
|
"totals": totals,
|
|
"top_authors": top_authors,
|
|
"active_definition": (
|
|
"Active = created a post, comment, gist, or project within the window. "
|
|
"Automated/bot accounts are not separately flagged."
|
|
),
|
|
}
|
|
_analytics_cache.set(cache_key, result)
|
|
return result
|
|
|
|
|
|
_activity_cache = TTLCache(ttl=120)
|
|
|
|
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
|
|
|
|
|
|
def get_activity_calendar(user_uid: str) -> dict:
|
|
cached = _activity_cache.get(user_uid)
|
|
if cached is not None:
|
|
return cached
|
|
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
|
calendar: dict[str, int] = {}
|
|
if sources:
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
|
|
union = " UNION ALL ".join(f"SELECT created_at FROM {table} WHERE user_uid = :u" for table in sources)
|
|
rows = db.query(
|
|
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
|
|
u=user_uid, cutoff=cutoff,
|
|
)
|
|
for row in rows:
|
|
if row["day"]:
|
|
calendar[row["day"]] = row["c"]
|
|
_activity_cache.set(user_uid, calendar)
|
|
return calendar
|
|
|
|
|
|
def _activity_level(count: int) -> int:
|
|
if count <= 0:
|
|
return 0
|
|
if count == 1:
|
|
return 1
|
|
if count <= 3:
|
|
return 2
|
|
if count <= 6:
|
|
return 3
|
|
return 4
|
|
|
|
|
|
def get_first_activity_date(user_uid: str):
|
|
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
|
if not sources:
|
|
return None
|
|
union = " UNION ALL ".join(f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u" for table in sources)
|
|
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
|
|
if row["first"]:
|
|
return datetime.fromisoformat(row["first"]).date()
|
|
return None
|
|
|
|
|
|
HEATMAP_WEEKS = 53
|
|
|
|
|
|
def get_activity_heatmap(user_uid: str) -> list:
|
|
calendar = get_activity_calendar(user_uid)
|
|
today = datetime.now(timezone.utc).date()
|
|
week_start = today - timedelta(days=today.weekday())
|
|
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
|
|
first = get_first_activity_date(user_uid)
|
|
if first:
|
|
first_week = first - timedelta(days=first.weekday())
|
|
if first_week > start:
|
|
start = first_week
|
|
weeks = []
|
|
for w in range(HEATMAP_WEEKS):
|
|
week = []
|
|
for d in range(7):
|
|
day = start + timedelta(days=w * 7 + d)
|
|
iso = day.isoformat()
|
|
count = calendar.get(iso, 0)
|
|
week.append({"date": iso, "count": count, "level": _activity_level(count)})
|
|
weeks.append(week)
|
|
return weeks
|
|
|
|
|
|
def get_activity_months(weeks: list) -> list:
|
|
if not weeks:
|
|
return []
|
|
last = len(weeks) - 1
|
|
labels = []
|
|
for i in range(6):
|
|
column = round(i * last / 5)
|
|
iso = weeks[column][0]["date"]
|
|
labels.append(datetime.fromisoformat(iso).strftime("%b"))
|
|
return labels
|
|
|
|
|
|
def get_streaks(user_uid: str) -> dict:
|
|
calendar = get_activity_calendar(user_uid)
|
|
if not calendar:
|
|
return {"current": 0, "longest": 0}
|
|
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
|
|
date_set = set(dates)
|
|
longest = 1
|
|
run = 1
|
|
for index in range(1, len(dates)):
|
|
if (dates[index] - dates[index - 1]).days == 1:
|
|
run += 1
|
|
else:
|
|
run = 1
|
|
longest = max(longest, run)
|
|
today = datetime.now(timezone.utc).date()
|
|
cursor = today
|
|
if today not in date_set and (today - timedelta(days=1)) in date_set:
|
|
cursor = today - timedelta(days=1)
|
|
current = 0
|
|
while cursor in date_set:
|
|
current += 1
|
|
cursor = cursor - timedelta(days=1)
|
|
return {"current": current, "longest": longest}
|
|
|
|
|
|
_gist_languages_cache = TTLCache(ttl=60)
|
|
|
|
|
|
def get_gist_languages() -> set[str]:
|
|
cached = _gist_languages_cache.get("codes")
|
|
if cached is not None:
|
|
return cached
|
|
codes: set[str] = set()
|
|
if "gists" in db.tables:
|
|
for row in db.query("SELECT DISTINCT language FROM gists"):
|
|
language = row.get("language")
|
|
if language:
|
|
codes.add(language)
|
|
_gist_languages_cache.set("codes", codes)
|
|
return codes
|
|
|
|
|
|
VOTABLE_TARGETS: dict[str, str] = {
|
|
"post": "posts",
|
|
"project": "projects",
|
|
"gist": "gists",
|
|
"comment": "comments",
|
|
}
|
|
|
|
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
|
|
|
_authors_cache = TTLCache(ttl=60)
|
|
|
|
|
|
def _ranked_authors() -> list:
|
|
cached = _authors_cache.get("ranked")
|
|
if cached is not None:
|
|
return cached
|
|
sources = [(target_type, table_name) for target_type, table_name in VOTABLE_TARGETS.items() if table_name in db.tables]
|
|
if "votes" not in db.tables or not sources:
|
|
_authors_cache.set("ranked", [])
|
|
return []
|
|
union = " UNION ALL ".join(
|
|
f"SELECT t.user_uid AS user_uid, v.value AS value "
|
|
f"FROM votes v JOIN {table_name} t ON v.target_uid = t.uid "
|
|
f"WHERE v.target_type = '{target_type}'"
|
|
for target_type, table_name in sources
|
|
)
|
|
rows = db.query(
|
|
f"SELECT user_uid, SUM(value) AS total FROM ({union}) "
|
|
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC"
|
|
)
|
|
ranked = [(row["user_uid"], row["total"]) for row in rows]
|
|
users_map = get_users_by_uids([uid for uid, _ in ranked])
|
|
authors = []
|
|
for uid, total in ranked:
|
|
user = users_map.get(uid)
|
|
if user:
|
|
author = dict(user)
|
|
author["stars"] = total
|
|
authors.append(author)
|
|
_authors_cache.set("ranked", authors)
|
|
return authors
|
|
|
|
|
|
def get_top_authors(limit: int = 5) -> list:
|
|
return _ranked_authors()[:limit]
|
|
|
|
|
|
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
|
|
sliced = _ranked_authors()[offset:offset + limit]
|
|
leaderboard = []
|
|
for position, author in enumerate(sliced, start=offset + 1):
|
|
entry = dict(author)
|
|
entry["rank"] = position
|
|
leaderboard.append(entry)
|
|
return leaderboard
|
|
|
|
|
|
def get_user_rank(user_uid: str):
|
|
for position, author in enumerate(_ranked_authors(), start=1):
|
|
if author["uid"] == user_uid:
|
|
return position
|
|
return None
|
|
|
|
|
|
def get_user_stars(user_uid: str) -> int:
|
|
if "votes" not in db.tables:
|
|
return 0
|
|
total = 0
|
|
for target_type, table_name in VOTABLE_TARGETS.items():
|
|
if table_name not in db.tables:
|
|
continue
|
|
for row in db.query(
|
|
f"SELECT COALESCE(SUM(v.value), 0) AS s "
|
|
f"FROM votes v JOIN {table_name} t ON v.target_uid = t.uid "
|
|
f"WHERE v.target_type = :tt AND t.user_uid = :u",
|
|
tt=target_type, u=user_uid,
|
|
):
|
|
total += row["s"] or 0
|
|
return total
|
|
|
|
|
|
def resolve_by_slug(table, slug):
|
|
entry = table.find_one(slug=slug)
|
|
if not entry:
|
|
entry = table.find_one(uid=slug)
|
|
return entry
|
|
|
|
|
|
def resolve_object_url(target_type: str, target_uid: str) -> str:
|
|
if target_type == "post":
|
|
post = resolve_by_slug(get_table("posts"), target_uid)
|
|
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
|
|
if target_type == "project":
|
|
project = resolve_by_slug(get_table("projects"), target_uid)
|
|
return f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
|
|
if target_type == "news":
|
|
article = resolve_by_slug(get_table("news"), target_uid)
|
|
if article:
|
|
return f"/news/{article.get('slug', '') or article['uid']}"
|
|
return "/news"
|
|
if target_type == "bug":
|
|
return f"/bugs?highlight={target_uid}"
|
|
if target_type == "gist":
|
|
gist = resolve_by_slug(get_table("gists"), target_uid)
|
|
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
|
|
if target_type == "comment":
|
|
comment = get_table("comments").find_one(uid=target_uid)
|
|
if not comment:
|
|
return "/feed"
|
|
parent_url = resolve_object_url(comment.get("target_type", "post"), comment.get("target_uid") or comment.get("post_uid", ""))
|
|
return f"{parent_url}#comment-{target_uid}"
|
|
return "/feed"
|
|
|
|
|
|
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
|
table_name = VOTABLE_TARGETS.get(target_type)
|
|
if not table_name:
|
|
return
|
|
if target_type in STAR_TARGETS:
|
|
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
|
_authors_cache.clear()
|
|
|
|
|
|
def delete_engagement(target_type: str, target_uids: list) -> None:
|
|
uids = [uid for uid in (target_uids or []) if uid]
|
|
if not uids:
|
|
return
|
|
if "reactions" in db.tables:
|
|
placeholders, params = _in_clause(uids)
|
|
params["tt"] = target_type
|
|
db.query(f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
|
|
if "bookmarks" in db.tables:
|
|
placeholders, params = _in_clause(uids)
|
|
params["tt"] = target_type
|
|
db.query(f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
|
|
if target_type == "post" and "polls" in db.tables:
|
|
for uid in uids:
|
|
for poll in db["polls"].find(post_uid=uid):
|
|
if "poll_votes" in db.tables:
|
|
db["poll_votes"].delete(poll_uid=poll["uid"])
|
|
if "poll_options" in db.tables:
|
|
db["poll_options"].delete(poll_uid=poll["uid"])
|
|
db["polls"].delete(post_uid=uid)
|
|
|
|
|
|
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
|
table_name = VOTABLE_TARGETS.get(target_type)
|
|
if not table_name:
|
|
return None
|
|
row = get_table(table_name).find_one(uid=target_uid)
|
|
return row["user_uid"] if row else None
|
|
|
|
|
|
PAGE_SIZE = 25
|
|
|
|
|
|
def paginate(table, *clauses, before=None, order=None, cursor_field="created_at", **filters):
|
|
order = order or ["-" + cursor_field]
|
|
clauses = list(clauses)
|
|
if before:
|
|
clauses.append(table.table.columns[cursor_field] < before)
|
|
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
|
|
has_more = len(rows) > PAGE_SIZE
|
|
rows = rows[:PAGE_SIZE]
|
|
next_cursor = rows[-1][cursor_field] if has_more and rows else None
|
|
return rows, next_cursor
|
|
|
|
|
|
def build_pagination(page, total, per_page=25):
|
|
total_pages = max(1, __import__("math").ceil(total / per_page))
|
|
page = max(1, min(page, total_pages))
|
|
return {
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total": total,
|
|
"total_pages": total_pages,
|
|
"has_prev": page > 1,
|
|
"has_next": page < total_pages,
|
|
"prev_page": page - 1,
|
|
"next_page": page + 1,
|
|
}
|
|
|
|
|
|
def get_follow_counts(user_uid: str) -> dict:
|
|
if "follows" not in db.tables:
|
|
return {"followers": 0, "following": 0}
|
|
follows = get_table("follows")
|
|
return {
|
|
"followers": follows.count(following_uid=user_uid),
|
|
"following": follows.count(follower_uid=user_uid),
|
|
}
|
|
|
|
|
|
def get_follow_list(user_uid: str, mode: str, page: int = 1, per_page: int = 25) -> tuple:
|
|
if "follows" not in db.tables:
|
|
return [], build_pagination(page, 0, per_page)
|
|
follows = get_table("follows")
|
|
key = "following_uid" if mode == "followers" else "follower_uid"
|
|
other = "follower_uid" if mode == "followers" else "following_uid"
|
|
total = follows.count(**{key: user_uid})
|
|
pagination = build_pagination(page, total, per_page)
|
|
offset = (pagination["page"] - 1) * pagination["per_page"]
|
|
rows = list(follows.find(order_by=["-created_at"], _limit=pagination["per_page"], _offset=offset, **{key: user_uid}))
|
|
users_map = get_users_by_uids([row[other] for row in rows])
|
|
people = []
|
|
for row in rows:
|
|
person = users_map.get(row[other])
|
|
if person:
|
|
people.append({
|
|
"uid": person["uid"],
|
|
"username": person["username"],
|
|
"bio": (person.get("bio") or "")[:140],
|
|
"followed_at": row.get("created_at"),
|
|
})
|
|
return people, pagination
|
|
|
|
|
|
def get_following_among(follower_uid: str, target_uids: list) -> set:
|
|
if not follower_uid or not target_uids or "follows" not in db.tables:
|
|
return set()
|
|
placeholders, params = _in_clause(target_uids)
|
|
params["f"] = follower_uid
|
|
rows = db.query(
|
|
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders})",
|
|
**params,
|
|
)
|
|
return {row["following_uid"] for row in rows}
|
|
|
|
|
|
def get_daily_topic():
|
|
if "news" in db.tables:
|
|
article = db["news"].find_one(status="published", order_by=["-synced_at"])
|
|
if article:
|
|
desc = (article.get("description") or "")[:200] or (article.get("content") or "")[:200]
|
|
return {
|
|
"title": article.get("title", ""),
|
|
"summary": desc,
|
|
"slug": article.get("slug", ""),
|
|
"url": article.get("url", ""),
|
|
}
|
|
return {"title": "Welcome to DevPlace", "summary": "Stay tuned for the latest dev news."}
|
|
|
|
|
|
def get_featured_news(limit=5):
|
|
if "news" not in db.tables:
|
|
return []
|
|
from devplacepy.utils import time_ago
|
|
rows = list(db["news"].find(show_on_landing=1, order_by=["-synced_at"], _limit=limit))
|
|
articles = []
|
|
for article in rows:
|
|
summary = (article.get("description") or "")[:120] or (article.get("content") or "")[:120]
|
|
articles.append({
|
|
"title": article.get("title", ""),
|
|
"summary": summary,
|
|
"slug": article.get("slug", ""),
|
|
"url": article.get("url", ""),
|
|
"source_name": article.get("source_name", ""),
|
|
"time_ago": time_ago(article["synced_at"]) if article.get("synced_at") else "",
|
|
})
|
|
return articles
|