chore: document project-wide soft delete pattern and add deleted_by column to all tables

This commit is contained in:
2026-06-11 20:36:47 +00:00
parent d0e2075e6d
commit 9a93debfc9
37 changed files with 1690 additions and 266 deletions
+33 -3
View File
@@ -222,6 +222,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
"thumbnail_name": thumbnail,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return {
@@ -386,12 +387,17 @@ def delete_attachment(uid):
_delete_attachment_row(row)
def soft_delete_attachment(uid):
def soft_delete_attachment(uid, deleted_by="system"):
row = get_table("attachments").find_one(uid=uid)
if not row or row.get("deleted_at"):
return None
get_table("attachments").update(
{"uid": uid, "deleted_at": datetime.now(timezone.utc).isoformat()}, ["uid"]
{
"uid": uid,
"deleted_at": datetime.now(timezone.utc).isoformat(),
"deleted_by": deleted_by,
},
["uid"],
)
return row
@@ -400,10 +406,34 @@ def restore_attachment(uid):
row = get_table("attachments").find_one(uid=uid)
if not row or not row.get("deleted_at"):
return False
get_table("attachments").update({"uid": uid, "deleted_at": None}, ["uid"])
get_table("attachments").update(
{"uid": uid, "deleted_at": None, "deleted_by": None}, ["uid"]
)
return True
def soft_delete_target_attachments(target_type, target_uid, deleted_by):
stamp = datetime.now(timezone.utc).isoformat()
for row in get_table("attachments").find(
target_type=target_type, target_uid=target_uid, deleted_at=None
):
get_table("attachments").update(
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by}, ["uid"]
)
def soft_delete_attachments_for(target_type, target_uids, deleted_by):
from devplacepy.database import soft_delete_in
soft_delete_in(
"attachments",
"target_uid",
target_uids,
deleted_by,
target_type=target_type,
)
def delete_target_attachments(target_type, target_uid):
for row in get_table("attachments").find(
target_type=target_type, target_uid=target_uid
+46 -45
View File
@@ -4,8 +4,7 @@ from typing import Any
from datetime import datetime, timezone
from fastapi.responses import RedirectResponse
from devplacepy.attachments import (
delete_attachments_for,
delete_inline_image,
soft_delete_attachments_for,
get_attachments,
link_attachments,
)
@@ -18,8 +17,12 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_poll_for_post,
delete_engagement,
soft_delete,
soft_delete_in,
soft_delete_engagement,
soft_delete_fork_relations,
load_comments,
_now_iso,
db,
)
from devplacepy.utils import (
@@ -93,6 +96,8 @@ def create_content_item(
"slug": slug,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
**fields,
}
)
@@ -232,49 +237,45 @@ def delete_content_item(
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=redirect_url, status_code=302)
if is_owner(item, user):
item_label = item.get("title") or item["uid"]
delete_attachments_for(target_type, [item["uid"]])
comment_uids = []
if "comments" in db.tables:
comments = get_table("comments")
comment_uids = [
comment["uid"] for comment in comments.find(target_uid=item["uid"])
]
delete_attachments_for("comment", comment_uids)
comments.delete(target_uid=item["uid"])
if "votes" in db.tables:
votes = get_table("votes")
votes.delete(target_uid=item["uid"])
if comment_uids:
votes.delete(
votes.table.columns.target_uid.in_(comment_uids),
target_type="comment",
)
delete_engagement(target_type, [item["uid"]])
if comment_uids:
delete_engagement("comment", comment_uids)
if target_type == "project":
from devplacepy.project_files import delete_all_project_files
from devplacepy.database import delete_fork_relations
delete_all_project_files(item["uid"])
delete_fork_relations(item["uid"])
if inline_image_field:
delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"])
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
audit.record(
request,
f"{target_type}.delete",
user=user,
target_type=target_type,
target_uid=item["uid"],
target_label=item_label,
summary=f"{user['username']} deleted {target_type} {item_label}",
metadata={"comment_count": len(comment_uids)},
links=[audit.target(target_type, item["uid"], item_label)],
item_label = item.get("title") or item["uid"]
actor = user["uid"]
stamp = _now_iso()
soft_delete_attachments_for(target_type, [item["uid"]], actor)
comment_uids = []
if "comments" in db.tables:
comments = get_table("comments")
comment_uids = [
comment["uid"]
for comment in comments.find(target_uid=item["uid"], deleted_at=None)
]
soft_delete_attachments_for("comment", comment_uids, actor)
soft_delete("comments", actor, stamp=stamp, target_uid=item["uid"])
if "votes" in db.tables:
soft_delete("votes", actor, stamp=stamp, target_uid=item["uid"])
soft_delete_in(
"votes", "target_uid", comment_uids, actor, stamp=stamp, target_type="comment"
)
soft_delete_engagement(target_type, [item["uid"]], actor)
if comment_uids:
soft_delete_engagement("comment", comment_uids, actor)
if target_type == "project":
from devplacepy.project_files import soft_delete_all_project_files
soft_delete_all_project_files(item["uid"], actor)
soft_delete_fork_relations(item["uid"], actor)
soft_delete(table_name, actor, stamp=stamp, uid=item["uid"])
logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}")
audit.record(
request,
f"{target_type}.delete",
user=user,
target_type=target_type,
target_uid=item["uid"],
target_label=item_label,
summary=f"{user['username']} deleted {target_type} {item_label}",
metadata={"comment_count": len(comment_uids)},
links=[audit.target(target_type, item["uid"], item_label)],
)
return action_result(request, redirect_url)
+301 -47
View File
@@ -102,6 +102,46 @@ def _index(db, table, name, columns):
logger.warning(f"Could not create index {name} on {table}: {e}")
SOFT_DELETE_TABLES = [
"posts",
"comments",
"gists",
"projects",
"news",
"news_images",
"project_files",
"attachments",
"votes",
"reactions",
"bookmarks",
"follows",
"poll_votes",
"polls",
"poll_options",
"sessions",
"instances",
"instance_schedules",
"devii_conversations",
"devii_tasks",
"devii_lessons",
"devii_virtual_tools",
"user_customizations",
"project_forks",
]
def ensure_soft_delete_columns(table, *, db_handle=None):
handle = db_handle or db
if table not in handle.tables:
return
target = handle[table]
if not target.has_column("deleted_at"):
target.create_column_by_example("deleted_at", "")
if not target.has_column("deleted_by"):
target.create_column_by_example("deleted_by", "")
_index(handle, table, f"idx_{table}_deleted", ["deleted_at"])
def init_db():
tables = db.tables
_index(db, "users", "idx_users_username", ["username"])
@@ -326,6 +366,9 @@ def init_db():
_index(db, "audit_log_links", "idx_audit_links_object", ["object_type", "object_uid"])
_index(db, "audit_log_links", "idx_audit_links_relation", ["relation"])
for table in SOFT_DELETE_TABLES:
ensure_soft_delete_columns(table)
if "news" in tables:
for article in db["news"].find(status=None):
was_featured = article.get("featured", 0)
@@ -554,6 +597,134 @@ def _in_clause(uids, prefix="p"):
return placeholders, params
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def soft_delete(table_name, deleted_by, *, stamp=None, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = list(table.find(deleted_at=None, **criteria))
if not rows:
return 0
stamp = stamp or _now_iso()
for row in rows:
table.update(
{"id": row["id"], "deleted_at": stamp, "deleted_by": deleted_by}, ["id"]
)
return len(rows)
def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra):
uids = [uid for uid in (uids or []) if uid]
if not uids or table_name not in db.tables:
return 0
if not db[table_name].has_column("deleted_at"):
return 0
placeholders, params = _in_clause(uids)
params["dat"] = stamp or _now_iso()
params["dby"] = deleted_by
extra_sql = ""
for index, (key, value) in enumerate(extra.items()):
params[f"x{index}"] = value
extra_sql += f" AND {key} = :x{index}"
db.query(
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
**params,
)
return len(uids)
def restore(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = [row for row in table.find(**criteria) if row.get("deleted_at")]
for row in rows:
table.update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
return len(rows)
def purge(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
count = table.count(**criteria)
table.delete(**criteria)
return int(count)
def list_deleted(table_name, page=1, per_page=25):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return [], build_pagination(page, 0, per_page)
table = db[table_name]
column = table.table.columns.deleted_at
total = table.count(column.isnot(None))
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
column.isnot(None),
order_by=["-deleted_at"],
_limit=pagination["per_page"],
_offset=offset,
)
)
return rows, pagination
def count_deleted(table_name):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return 0
table = db[table_name]
return int(table.count(table.table.columns.deleted_at.isnot(None)))
def restore_event(stamp):
if not stamp:
return 0
restored = 0
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
restored += int(
db.query(
f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at = :s",
s=stamp,
).__next__()["n"]
)
db.query(
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
f"WHERE deleted_at = :s",
s=stamp,
)
return restored
def purge_event(stamp):
if not stamp:
return []
purged = []
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
rows = list(
db.query(
f"SELECT * FROM {table_name} WHERE deleted_at = :s", s=stamp
)
)
if rows:
purged.append((table_name, rows))
db.query(
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
)
return purged
def get_users_by_uids(uids):
if not uids or "users" not in db.tables:
return {}
@@ -570,7 +741,7 @@ def get_comment_counts_by_post_uids(post_uids):
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",
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
return {r["target_uid"]: r["c"] for r in rows}
@@ -581,7 +752,7 @@ def get_post_counts_by_user_uids(user_uids):
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",
f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY user_uid",
**params,
)
return {r["user_uid"]: r["c"] for r in rows}
@@ -592,7 +763,7 @@ def get_vote_counts(target_uids):
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",
f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, value",
**params,
)
ups = {}
@@ -611,7 +782,7 @@ def get_user_votes(user_uid, target_uids):
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})",
f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {r["target_uid"]: r["value"] for r in rows}
@@ -623,7 +794,7 @@ def get_reactions_by_targets(target_type, target_uids, user=None):
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",
f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, emoji",
**params,
)
counts = defaultdict(dict)
@@ -635,7 +806,7 @@ def get_reactions_by_targets(target_type, target_uids, user=None):
params["tt"] = target_type
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 ({placeholders})",
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
mine[row["target_uid"]].append(row["emoji"])
@@ -655,7 +826,7 @@ def get_user_bookmarks(user_uid, target_type, 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})",
f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["target_uid"] for row in rows}
@@ -666,7 +837,10 @@ def get_polls_by_post_uids(post_uids, user=None):
return {}
placeholders, params = _in_clause(post_uids)
polls = list(
db.query(f"SELECT * FROM polls WHERE post_uid IN ({placeholders})", **params)
db.query(
f"SELECT * FROM polls WHERE post_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
)
if not polls:
return {}
@@ -674,7 +848,7 @@ def get_polls_by_post_uids(post_uids, user=None):
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",
f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) AND deleted_at IS NULL ORDER BY position",
**option_params,
)
)
@@ -683,7 +857,7 @@ def get_polls_by_post_uids(post_uids, user=None):
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",
f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) AND deleted_at IS NULL GROUP BY poll_uid, option_uid",
**vote_params,
):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
@@ -693,7 +867,7 @@ def get_polls_by_post_uids(post_uids, user=None):
placeholders, params = _in_clause(poll_uids, prefix="m")
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 ({placeholders})",
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
user_choice[row["poll_uid"]] = row["option_uid"]
@@ -761,11 +935,18 @@ def load_comments(target_type, target_uid, user=None):
comments_table = db["comments"]
raw = list(
comments_table.find(
target_type=target_type, target_uid=target_uid, order_by=["created_at"]
target_type=target_type,
target_uid=target_uid,
deleted_at=None,
order_by=["created_at"],
)
)
if not raw and target_type == "post":
raw = list(comments_table.find(post_uid=target_uid, order_by=["created_at"]))
raw = list(
comments_table.find(
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
)
)
if not raw:
return []
cmap = _build_comment_items(raw, user)
@@ -789,7 +970,7 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
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 target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
)
@@ -845,6 +1026,7 @@ def get_attachments(resource_type: str, resource_uid: str) -> list:
db["attachments"].find(
resource_type=resource_type,
resource_uid=resource_uid,
deleted_at=None,
order_by=["created_at"],
)
)
@@ -856,6 +1038,7 @@ def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
rows = list(
db["attachments"].find(
db["attachments"].table.columns.resource_uid.in_(resource_uids),
db["attachments"].table.columns.deleted_at.is_(None),
resource_type=resource_type,
)
)
@@ -875,7 +1058,9 @@ def get_news_images_by_uids(news_uids: list) -> dict:
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"]
images_table.table.columns.news_uid.in_(news_uids),
images_table.table.columns.deleted_at.is_(None),
order_by=["uid"],
)
result = {}
for r in rows:
@@ -1016,6 +1201,7 @@ def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict
owner_kind=owner_kind,
owner_id=owner_id,
enabled=1,
deleted_at=None,
)
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
for row in rows:
@@ -1045,6 +1231,7 @@ def get_custom_override(
owner_id=owner_id,
scope=scope,
lang=lang,
deleted_at=None,
)
@@ -1052,7 +1239,9 @@ def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
if "user_customizations" not in db.tables:
return []
return list(
db["user_customizations"].find(owner_kind=owner_kind, owner_id=owner_id)
db["user_customizations"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
@@ -1072,6 +1261,8 @@ def set_custom_override(
"code": code,
"enabled": 1,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
@@ -1086,6 +1277,8 @@ def set_custom_override(
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("customizations")
@@ -1093,7 +1286,11 @@ def set_custom_override(
def delete_custom_override(
owner_kind: str, owner_id: str, scope: str | None = None, lang: str | None = None
owner_kind: str,
owner_id: str,
scope: str | None = None,
lang: str | None = None,
deleted_by: str | None = None,
) -> int:
if "user_customizations" not in db.tables:
return 0
@@ -1102,8 +1299,9 @@ def delete_custom_override(
criteria["scope"] = scope
if lang is not None:
criteria["lang"] = lang
count = db["user_customizations"].count(**criteria)
db["user_customizations"].delete(**criteria)
count = soft_delete(
"user_customizations", deleted_by or f"{owner_kind}:{owner_id}", **criteria
)
bump_cache_version("customizations")
return int(count)
@@ -1122,11 +1320,17 @@ def get_site_stats() -> dict:
)
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(created_at={">=": today_start})
"posts_today": db["posts"].count(
created_at={">=": today_start}, deleted_at=None
)
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,
"total_projects": db["projects"].count(deleted_at=None)
if "projects" in db.tables
else 0,
"total_gists": db["gists"].count(deleted_at=None)
if "gists" in db.tables
else 0,
}
_stats_cache.set("site", stats)
return stats
@@ -1155,7 +1359,8 @@ def get_platform_analytics(top_n: int = 10) -> dict:
]
if sources:
union = " UNION ALL ".join(
f"SELECT user_uid, created_at FROM {table}" for table in sources
f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL"
for table in sources
)
rows = db.query(
"SELECT "
@@ -1175,7 +1380,7 @@ def get_platform_analytics(top_n: int = 10) -> dict:
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",
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now AND deleted_at IS NULL",
now=now.isoformat(),
):
signed_in_now = row["n"] or 0
@@ -1187,11 +1392,13 @@ def get_platform_analytics(top_n: int = 10) -> dict:
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,
"posts": db["posts"].count(deleted_at=None) if "posts" in db.tables else 0,
"comments": db["comments"].count(deleted_at=None)
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,
"news": db["news"].count(deleted_at=None) if "news" in db.tables else 0,
}
top_authors = [
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
@@ -1241,7 +1448,8 @@ def get_activity_calendar(user_uid: str) -> dict:
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
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
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",
@@ -1272,7 +1480,7 @@ def get_first_activity_date(user_uid: str):
if not sources:
return None
union = " UNION ALL ".join(
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u"
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
@@ -1352,7 +1560,9 @@ def get_gist_languages() -> set[str]:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query("SELECT DISTINCT language FROM gists"):
for row in db.query(
"SELECT DISTINCT language FROM gists WHERE deleted_at IS NULL"
):
language = row.get("language")
if language:
codes.add(language)
@@ -1385,12 +1595,13 @@ def _ranked_authors() -> list:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name}"
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
for target_type, table_name in sources
)
rows = db.query(
f"SELECT t.user_uid, SUM(v.value) AS total "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL "
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
@@ -1431,7 +1642,7 @@ def get_user_stars(user_uid: str) -> int:
if "votes" not in db.tables:
return 0
target_union = " UNION ALL ".join(
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u"
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in db.tables
)
@@ -1439,17 +1650,20 @@ def get_user_stars(user_uid: str) -> int:
return 0
for row in db.query(
f"SELECT COALESCE(SUM(v.value), 0) AS s "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type",
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL",
u=user_uid,
):
return row["s"] or 0
return 0
def resolve_by_slug(table, slug):
entry = table.find_one(slug=slug)
def resolve_by_slug(table, slug, include_deleted=False):
has_soft_delete = table.has_column("deleted_at")
flt = {} if include_deleted or not has_soft_delete else {"deleted_at": None}
entry = table.find_one(slug=slug, **flt)
if not entry:
entry = table.find_one(uid=slug)
entry = table.find_one(uid=slug, **flt)
return entry
@@ -1465,6 +1679,8 @@ def record_fork(
"forked_project_uid": forked_project_uid,
"forked_by_uid": forked_by_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
@@ -1473,17 +1689,29 @@ def get_fork_parent(forked_project_uid: str) -> dict | None:
if "project_forks" not in db.tables:
return None
relation = get_table("project_forks").find_one(
forked_project_uid=forked_project_uid
forked_project_uid=forked_project_uid, deleted_at=None
)
if not relation:
return None
return get_table("projects").find_one(uid=relation["source_project_uid"])
return get_table("projects").find_one(
uid=relation["source_project_uid"], deleted_at=None
)
def count_forks(source_project_uid: str) -> int:
if "project_forks" not in db.tables:
return 0
return get_table("project_forks").count(source_project_uid=source_project_uid)
return get_table("project_forks").count(
source_project_uid=source_project_uid, deleted_at=None
)
def soft_delete_fork_relations(project_uid: str, deleted_by: str) -> None:
if "project_forks" not in db.tables:
return
stamp = _now_iso()
soft_delete("project_forks", deleted_by, stamp=stamp, forked_project_uid=project_uid)
soft_delete("project_forks", deleted_by, stamp=stamp, source_project_uid=project_uid)
def delete_fork_relations(project_uid: str) -> None:
@@ -1514,7 +1742,7 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
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)
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
return "/feed"
parent_url = resolve_object_url(
@@ -1534,6 +1762,25 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
_authors_cache.clear()
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
stamp = _now_iso()
soft_delete_in(
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
soft_delete_in(
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
@@ -1566,7 +1813,7 @@ 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)
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
return row["user_uid"] if row else None
@@ -1578,6 +1825,8 @@ def paginate(
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
@@ -1664,8 +1913,8 @@ def get_follow_counts(user_uid: str) -> dict:
return {"followers": 0, "following": 0}
follows = get_table("follows")
return {
"followers": follows.count(following_uid=user_uid),
"following": follows.count(follower_uid=user_uid),
"followers": follows.count(following_uid=user_uid, deleted_at=None),
"following": follows.count(follower_uid=user_uid, deleted_at=None),
}
@@ -1677,7 +1926,7 @@ def get_follow_list(
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})
total = follows.count(deleted_at=None, **{key: user_uid})
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
@@ -1685,6 +1934,7 @@ def get_follow_list(
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
deleted_at=None,
**{key: user_uid},
)
)
@@ -1710,7 +1960,7 @@ def get_following_among(follower_uid: str, target_uids: list) -> 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})",
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["following_uid"] for row in rows}
@@ -1718,7 +1968,9 @@ def get_following_among(follower_uid: str, target_uids: list) -> set:
def get_daily_topic():
if "news" in db.tables:
article = db["news"].find_one(status="published", order_by=["-synced_at"])
article = db["news"].find_one(
status="published", deleted_at=None, order_by=["-synced_at"]
)
if article:
desc = (article.get("description") or "")[:200] or (
article.get("content") or ""
@@ -1741,7 +1993,9 @@ def get_featured_news(limit=5):
from devplacepy.utils import time_ago
rows = list(
db["news"].find(show_on_landing=1, order_by=["-synced_at"], _limit=limit)
db["news"].find(
show_on_landing=1, deleted_at=None, order_by=["-synced_at"], _limit=limit
)
)
articles = []
for article in rows:
+31 -9
View File
@@ -194,13 +194,13 @@ def _file_url(directory: str, stored_name: str) -> str:
def get_node(project_uid: str, path: str):
return _table().find_one(project_uid=project_uid, path=path)
return _table().find_one(project_uid=project_uid, path=path, deleted_at=None)
def _count(project_uid: str) -> int:
if "project_files" not in db.tables:
return 0
return _table().count(project_uid=project_uid)
return _table().count(project_uid=project_uid, deleted_at=None)
def _guard_capacity(project_uid: str) -> None:
@@ -215,6 +215,8 @@ def _insert_dir(project_uid: str, user: dict, path: str) -> None:
{
"uid": generate_uid(),
"project_uid": project_uid,
"deleted_at": None,
"deleted_by": None,
"user_uid": user["uid"],
"path": path,
"name": _name_of(path),
@@ -292,6 +294,8 @@ def write_text_file(project_uid: str, user: dict, raw_path: str, content: str) -
{
"uid": generate_uid(),
"project_uid": project_uid,
"deleted_at": None,
"deleted_by": None,
"user_uid": user["uid"],
"path": path,
"name": _name_of(path),
@@ -381,6 +385,8 @@ def store_upload(
{
"uid": generate_uid(),
"project_uid": project_uid,
"deleted_at": None,
"deleted_by": None,
"user_uid": user["uid"],
"path": path,
"name": base,
@@ -403,7 +409,7 @@ def _descendants(project_uid: str, path: str) -> list:
prefix = f"{path}/"
return [
row
for row in _table().find(project_uid=project_uid)
for row in _table().find(project_uid=project_uid, deleted_at=None)
if row["path"] == path or row["path"].startswith(prefix)
]
@@ -451,16 +457,29 @@ def move_node(project_uid: str, user: dict, raw_from: str, raw_to: str) -> dict:
return get_node(project_uid, dst_path)
def delete_node(project_uid: str, raw_path: str) -> None:
def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") -> None:
_guard_writable(project_uid)
path = normalize_path(raw_path)
node = get_node(project_uid, path)
if node is None:
raise ProjectFileError(f"'{path}' does not exist")
stamp = _now()
for row in _descendants(project_uid, path):
if row.get("is_binary"):
_unlink_blob(row)
_table().delete(uid=row["uid"])
_table().update(
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by},
["uid"],
)
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
if "project_files" not in db.tables:
return
stamp = _now()
for row in _table().find(project_uid=project_uid, deleted_at=None):
_table().update(
{"uid": row["uid"], "deleted_at": stamp, "deleted_by": deleted_by},
["uid"],
)
def node_to_dict(row: dict) -> dict:
@@ -484,7 +503,10 @@ def node_to_dict(row: dict) -> dict:
def list_files(project_uid: str) -> list:
if "project_files" not in db.tables:
return []
rows = sorted(_table().find(project_uid=project_uid), key=lambda r: r["path"])
rows = sorted(
_table().find(project_uid=project_uid, deleted_at=None),
key=lambda r: r["path"],
)
return [node_to_dict(row) for row in rows]
@@ -557,7 +579,7 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
rows = _descendants(project_uid, base)
strip = _parent_of(base)
else:
rows = list(_table().find(project_uid=project_uid))
rows = list(_table().find(project_uid=project_uid, deleted_at=None))
strip = ""
written = 0
for row in sorted(rows, key=lambda r: r["path"]):
+141 -11
View File
@@ -14,8 +14,20 @@ from devplacepy.database import (
clear_settings_cache,
get_platform_analytics,
get_deleted_media,
soft_delete,
restore,
purge,
list_deleted,
count_deleted,
restore_event,
purge_event,
get_users_by_uids,
resolve_object_url,
SOFT_DELETE_TABLES,
_now_iso,
)
from devplacepy.attachments import delete_attachment
from devplacepy.attachments import delete_attachment, _unlink_attachment_files
from devplacepy.project_files import _unlink_blob
from devplacepy.utils import (
require_admin,
hash_password,
@@ -24,6 +36,7 @@ from devplacepy.utils import (
clear_user_cache,
get_current_user,
is_admin,
not_found,
)
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.responses import respond, action_result
@@ -32,6 +45,7 @@ from devplacepy.schemas import (
AdminNewsOut,
AdminSettingsOut,
AdminMediaOut,
AdminTrashOut,
GatewayUsageOut,
AuditLogOut,
AuditEventOut,
@@ -363,6 +377,122 @@ async def admin_media_purge(request: Request, uid: str):
return action_result(request, "/admin/media")
TRASH_TABLES = [
{"key": "posts", "label": "Posts", "type": "post"},
{"key": "comments", "label": "Comments", "type": "comment"},
{"key": "gists", "label": "Gists", "type": "gist"},
{"key": "projects", "label": "Projects", "type": "project"},
{"key": "news", "label": "News", "type": "news"},
{"key": "project_files", "label": "Project files", "type": None},
{"key": "attachments", "label": "Attachments", "type": None},
]
_TRASH_KEYS = {entry["key"] for entry in TRASH_TABLES}
_TRASH_TYPE = {entry["key"]: entry["type"] for entry in TRASH_TABLES}
def _trash_label(table: str, row: dict) -> str:
for field in ("title", "name", "path", "original_filename", "question", "content"):
value = row.get(field)
if value:
return str(value)[:80]
return row.get("slug") or row.get("uid", "")
def _trash_target_url(table: str, row: dict) -> str | None:
target_type = _TRASH_TYPE.get(table)
if target_type:
return resolve_object_url(target_type, row["uid"])
return None
@router.get("/trash", response_class=HTMLResponse)
async def admin_trash(request: Request, table: str = "posts", page: int = 1):
admin = require_admin(request)
if table not in _TRASH_KEYS:
table = "posts"
rows, pagination = list_deleted(table, page)
actor_ids = [r.get("deleted_by") for r in rows if r.get("deleted_by")]
actors = get_users_by_uids([a for a in actor_ids if a]) if actor_ids else {}
items = []
for row in rows:
actor = actors.get(row.get("deleted_by"))
items.append(
{
"uid": row["uid"],
"table": table,
"label": _trash_label(table, row),
"deleted_at": row.get("deleted_at"),
"deleted_by": actor["username"] if actor else row.get("deleted_by"),
"target_url": _trash_target_url(table, row),
}
)
tabs = [
{**entry, "count": count_deleted(entry["key"]), "active": entry["key"] == table}
for entry in TRASH_TABLES
]
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Trash - Admin",
description="Restore or permanently remove soft-deleted content.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Trash", "url": "/admin/trash"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_trash.html",
{
**seo_ctx,
"request": request,
"user": admin,
"items": items,
"table": table,
"tables": tabs,
"pagination": pagination,
"admin_section": "trash",
},
model=AdminTrashOut,
)
@router.post("/trash/{table}/{uid}/restore")
async def admin_trash_restore(request: Request, table: str, uid: str):
admin = require_admin(request)
if table not in _TRASH_KEYS:
raise not_found("Unknown trash table")
row = get_table(table).find_one(uid=uid)
if row and row.get("deleted_at"):
restored = restore_event(row["deleted_at"])
logger.info(
f"Admin {admin['username']} restored {table} {uid} ({restored} rows)"
)
return action_result(request, f"/admin/trash?table={table}")
@router.post("/trash/{table}/{uid}/purge")
async def admin_trash_purge(request: Request, table: str, uid: str):
admin = require_admin(request)
if table not in _TRASH_KEYS:
raise not_found("Unknown trash table")
row = get_table(table).find_one(uid=uid)
if row and row.get("deleted_at"):
purged = purge_event(row["deleted_at"])
for purged_table, purged_rows in purged:
if purged_table == "attachments":
for attachment in purged_rows:
_unlink_attachment_files(attachment)
elif purged_table == "project_files":
for node in purged_rows:
if node.get("is_binary"):
_unlink_blob(node)
logger.info(f"Admin {admin['username']} purged {table} {uid}")
return action_result(request, f"/admin/trash?table={table}")
@router.post("/ai-quota/reset-guests")
async def admin_reset_guest_ai_quota(request: Request):
admin = require_admin(request)
@@ -556,12 +686,15 @@ async def admin_audit_event(request: Request, uid: str):
async def admin_news(request: Request, page: int = 1):
admin = require_admin(request)
news_table = get_table("news")
total = news_table.count()
total = news_table.count(deleted_at=None)
pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"]
page_articles = list(
news_table.find(
order_by=["-synced_at"], _limit=pagination["per_page"], _offset=offset
deleted_at=None,
order_by=["-synced_at"],
_limit=pagination["per_page"],
_offset=offset,
)
)
images_by_news = get_news_images_by_uids([a["uid"] for a in page_articles])
@@ -689,15 +822,12 @@ async def admin_news_landing(request: Request, uid: str):
async def admin_news_delete(request: Request, uid: str):
admin = require_admin(request)
news_table = get_table("news")
article = news_table.find_one(uid=uid)
article = news_table.find_one(uid=uid, deleted_at=None)
if article:
images_table = get_table("news_images")
image_count = 0
for img in images_table.find(news_uid=uid):
images_table.delete(id=img["id"])
image_count += 1
news_table.delete(id=article["id"])
logger.info(f"Admin {admin['username']} deleted news {uid}")
stamp = _now_iso()
image_count = soft_delete("news_images", admin["uid"], stamp=stamp, news_uid=uid)
soft_delete("news", admin["uid"], stamp=stamp, uid=uid)
logger.info(f"Admin {admin['username']} soft-deleted news {uid}")
audit.record(
request,
"news.delete",
+10 -3
View File
@@ -7,7 +7,7 @@ from typing import Annotated
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.database import get_table, get_setting, get_int_setting, _now_iso
from devplacepy.templating import templates
from devplacepy.utils import (
hash_password,
@@ -407,9 +407,16 @@ async def logout(request: Request):
token = request.cookies.get("session")
if token:
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
session = sessions.find_one(session_token=token, deleted_at=None)
if session:
sessions.delete(id=session["id"])
sessions.update(
{
"id": session["id"],
"deleted_at": _now_iso(),
"deleted_by": session["user_uid"],
},
["id"],
)
clear_session_cache(token)
if user:
audit.record(
+17 -4
View File
@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from devplacepy.database import get_table, db, paginate, resolve_object_url
from devplacepy.database import get_table, db, paginate, resolve_object_url, _now_iso
from devplacepy.utils import generate_uid, require_user, time_ago
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond
@@ -46,7 +46,10 @@ async def saved_page(request: Request, before: str = None):
if not table_name or table_name not in db.tables:
continue
table = get_table(table_name)
for obj in table.find(table.table.columns.uid.in_(uids)):
clauses = [table.table.columns.uid.in_(uids)]
if table.has_column("deleted_at"):
clauses.append(table.table.columns.deleted_at.is_(None))
for obj in table.find(*clauses):
resolved[(target_type, obj["uid"])] = obj
items = []
@@ -97,9 +100,17 @@ async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
existing = bookmarks.find_one(
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
)
if existing:
bookmarks.delete(id=existing["id"])
if existing and not existing.get("deleted_at"):
bookmarks.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
saved = False
elif existing:
bookmarks.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
saved = True
else:
bookmarks.insert(
{
@@ -108,6 +119,8 @@ async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
"target_uid": target_uid,
"target_type": target_type,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
saved = True
+3 -1
View File
@@ -43,6 +43,8 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
"content": content,
"parent_uid": parent_uid or None,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
if target_type == "post":
insert["post_uid"] = target_uid
@@ -57,7 +59,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if target_type == "post":
if parent_uid:
parent = get_table("comments").find_one(uid=parent_uid)
parent = get_table("comments").find_one(uid=parent_uid, deleted_at=None)
if parent and parent["user_uid"] != user["uid"]:
create_notification(
parent["user_uid"],
+7
View File
@@ -183,6 +183,13 @@ DOCS_PAGES = [
"admin": True,
"section": SECTION_ADMIN,
},
{
"slug": "soft-delete",
"title": "Soft delete and data retention",
"kind": "prose",
"admin": True,
"section": SECTION_ADMIN,
},
# Devii internals - technical reference for the Devii assistant (admins only)
{
"slug": "audit-log",
+4 -1
View File
@@ -35,7 +35,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
if not user:
return [], None
follows = get_table("follows")
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
following = [
f["following_uid"]
for f in follows.find(follower_uid=user["uid"], deleted_at=None)
]
if not following:
return [], None
posts, next_cursor = paginate(
+24 -13
View File
@@ -3,7 +3,7 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from devplacepy.database import get_table
from devplacepy.database import get_table, _now_iso
from devplacepy.utils import (
generate_uid,
require_user,
@@ -28,17 +28,23 @@ async def follow_user(request: Request, username: str):
follows = get_table("follows")
existing = follows.find_one(follower_uid=user["uid"], following_uid=target["uid"])
if existing:
if existing and not existing.get("deleted_at"):
return action_result(request, f"/profile/{username}")
follows.insert(
{
"uid": generate_uid(),
"follower_uid": user["uid"],
"following_uid": target["uid"],
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
if existing:
follows.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
else:
follows.insert(
{
"uid": generate_uid(),
"follower_uid": user["uid"],
"following_uid": target["uid"],
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
create_notification(
target["uid"],
@@ -72,9 +78,14 @@ async def unfollow_user(request: Request, username: str):
return action_result(request, f"/profile/{username}")
follows = get_table("follows")
existing = follows.find_one(follower_uid=user["uid"], following_uid=target["uid"])
existing = follows.find_one(
follower_uid=user["uid"], following_uid=target["uid"], deleted_at=None
)
if existing:
follows.delete(id=existing["id"])
follows.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
logger.info(f"{user['username']} unfollowed {username}")
audit.record(
request,
+1 -1
View File
@@ -75,7 +75,7 @@ def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
if language:
filters["language"] = language
total = gists_table.count(**filters)
total = gists_table.count(deleted_at=None, **filters)
gists, next_cursor = paginate(gists_table, before=before, **filters)
if not gists:
+1 -1
View File
@@ -43,7 +43,7 @@ async def delete_media(request: Request, uid: str):
if wants_json(request):
return json_error(403, "Not allowed")
return action_result(request, _media_redirect(request, attachment))
soft_delete_attachment(uid)
soft_delete_attachment(uid, deleted_by=user["uid"])
logger.info(f"Media {uid} soft-deleted by {user['username']}")
audit.record(
request,
+3 -1
View File
@@ -101,7 +101,9 @@ async def news_detail_page(request: Request, news_slug: str):
image_url = ""
if "news_images" in db.tables:
img = get_table("news_images").find_one(news_uid=article["uid"])
img = get_table("news_images").find_one(
news_uid=article["uid"], deleted_at=None
)
if img:
image_url = img["url"]
+23 -5
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_poll_for_post
from devplacepy.database import get_table, get_poll_for_post, _now_iso
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import PollVoteForm
from devplacepy.services.audit import record as audit
@@ -18,18 +18,34 @@ async def vote_poll(
request: Request, poll_uid: str, data: Annotated[PollVoteForm, Form()]
):
user = require_user(request)
poll = get_table("polls").find_one(uid=poll_uid)
poll = get_table("polls").find_one(uid=poll_uid, deleted_at=None)
if not poll:
return JSONResponse({"error": "Poll not found"}, status_code=404)
option = get_table("poll_options").find_one(uid=data.option_uid, poll_uid=poll_uid)
option = get_table("poll_options").find_one(
uid=data.option_uid, poll_uid=poll_uid, deleted_at=None
)
if not option:
return JSONResponse({"error": "Invalid option"}, status_code=400)
poll_votes = get_table("poll_votes")
existing = poll_votes.find_one(poll_uid=poll_uid, user_uid=user["uid"])
if existing:
if existing["option_uid"] == data.option_uid:
poll_votes.delete(id=existing["id"])
if existing.get("deleted_at"):
poll_votes.update(
{
"id": existing["id"],
"option_uid": data.option_uid,
"deleted_at": None,
"deleted_by": None,
},
["id"],
)
poll_event = "poll.vote.cast"
elif existing["option_uid"] == data.option_uid:
poll_votes.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
poll_event = "poll.vote.clear"
else:
poll_votes.update(
@@ -44,6 +60,8 @@ async def vote_poll(
"option_uid": data.option_uid,
"user_uid": user["uid"],
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
poll_event = "poll.vote.cast"
+6 -2
View File
@@ -101,6 +101,8 @@ def create_poll(
"user_uid": user["uid"],
"question": question,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
get_table("poll_options").insert_many(
@@ -110,6 +112,8 @@ def create_poll(
"poll_uid": poll_uid,
"label": label,
"position": index,
"deleted_at": None,
"deleted_by": None,
}
for index, label in enumerate(labels)
]
@@ -174,7 +178,7 @@ async def view_post(request: Request, post_slug: str):
if "posts" in db.tables:
rows = list(
db.query(
"SELECT p.*, u.username FROM posts p JOIN users u ON p.user_uid = u.uid WHERE p.topic = :t AND p.uid != :uid ORDER BY p.created_at DESC LIMIT 5",
"SELECT p.*, u.username FROM posts p JOIN users u ON p.user_uid = u.uid WHERE p.topic = :t AND p.uid != :uid AND p.deleted_at IS NULL ORDER BY p.created_at DESC LIMIT 5",
t=post.get("topic", ""),
uid=post["uid"],
)
@@ -230,7 +234,7 @@ async def edit_post(
if (
post
and post["user_uid"] == user["uid"]
and not get_table("polls").find_one(post_uid=post["uid"])
and not get_table("polls").find_one(post_uid=post["uid"], deleted_at=None)
):
create_poll(
post["uid"], user, data.poll_question, data.poll_options, request
+23 -7
View File
@@ -164,7 +164,9 @@ async def profile_page(
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(
posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"])
posts_table.find(
user_uid=profile_user["uid"], deleted_at=None, order_by=["-created_at"]
)
)
post_uids = [p["uid"] for p in raw_posts]
counts = get_comment_counts_by_post_uids(post_uids) if raw_posts else {}
@@ -186,19 +188,28 @@ async def profile_page(
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
projects = list(
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
)
projects = [p for p in projects if can_view_project(p, current_user)]
gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
gists_raw = list(
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
)
gists = []
for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = len(posts) or get_table("posts").count(user_uid=profile_user["uid"])
posts_count = len(posts) or get_table("posts").count(
user_uid=profile_user["uid"], deleted_at=None
)
activities = []
if tab == "activity":
posts_table = get_table("posts")
for p in posts_table.find(
user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10
user_uid=profile_user["uid"],
deleted_at=None,
order_by=["-created_at"],
_limit=10,
):
activities.append(
{
@@ -211,7 +222,10 @@ async def profile_page(
)
comments_table = get_table("comments")
for c in comments_table.find(
user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10
user_uid=profile_user["uid"],
deleted_at=None,
order_by=["-created_at"],
_limit=10,
):
target_uid = c.get("target_uid", c.get("post_uid", ""))
target_type = c.get("target_type", "post")
@@ -236,7 +250,9 @@ async def profile_page(
follows = get_table("follows")
is_following = bool(
follows.find_one(
follower_uid=current_user["uid"], following_uid=profile_user["uid"]
follower_uid=current_user["uid"],
following_uid=profile_user["uid"],
deleted_at=None,
)
)
+1 -1
View File
@@ -385,7 +385,7 @@ async def project_file_delete(
if not is_owner(project, user):
return _deny(request, project, user, "file.delete", data.path)
try:
project_files.delete_node(project["uid"], data.path)
project_files.delete_node(project["uid"], data.path, deleted_by=user["uid"])
except ProjectFileError as exc:
return _fail(request, project, exc, user, "file.delete", data.path)
_audit_file(request, project, user, "file.delete", data.path)
+1
View File
@@ -73,6 +73,7 @@ def get_projects_list(
clauses = []
if projects.exists:
columns = projects.table.columns
clauses.append(columns.deleted_at.is_(None))
if search:
like = f"%{search}%"
clauses.append(
+23 -8
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.database import get_table, db, _now_iso
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import ReactionForm
from devplacepy.services.audit import record as audit
@@ -34,9 +34,17 @@ async def react(
target_type=target_type,
emoji=emoji,
)
if existing:
reactions.delete(id=existing["id"])
reaction_event = "reaction.remove"
if existing and existing.get("deleted_at"):
reactions.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
added = True
elif existing:
reactions.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
added = False
else:
reactions.insert(
{
@@ -46,9 +54,13 @@ async def react(
"target_type": target_type,
"emoji": emoji,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
reaction_event = "reaction.add"
added = True
reaction_event = "reaction.add" if added else "reaction.remove"
audit.record(
request,
@@ -57,14 +69,14 @@ async def react(
target_type=target_type,
target_uid=target_uid,
metadata={"emoji": emoji},
summary=f"{user['username']} {'removed' if existing else 'added'} {emoji} on {target_type} {target_uid}",
summary=f"{user['username']} {'added' if added else 'removed'} {emoji} on {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
counts = {
row["emoji"]: row["c"]
for row in db.query(
"SELECT emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid=:tu GROUP BY emoji",
"SELECT emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid=:tu AND deleted_at IS NULL GROUP BY emoji",
tt=target_type,
tu=target_uid,
)
@@ -72,7 +84,10 @@ async def react(
mine = [
row["emoji"]
for row in reactions.find(
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
user_uid=user["uid"],
target_uid=target_uid,
target_type=target_type,
deleted_at=None,
)
]
+29 -5
View File
@@ -10,6 +10,7 @@ from devplacepy.database import (
update_target_stars,
get_target_owner_uid,
resolve_object_url,
_now_iso,
)
from devplacepy.utils import (
generate_uid,
@@ -46,8 +47,22 @@ async def vote(
did_upvote = False
new_value = value
if existing:
if int(existing["value"]) == value:
votes.delete(id=existing["id"])
if existing.get("deleted_at"):
votes.update(
{
"id": existing["id"],
"value": value,
"deleted_at": None,
"deleted_by": None,
},
["id"],
)
did_upvote = value == 1
elif int(existing["value"]) == value:
votes.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
new_value = 0
else:
votes.update({"id": existing["id"], "value": value}, ["id"])
@@ -61,12 +76,18 @@ async def vote(
"target_type": target_type,
"value": value,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
did_upvote = value == 1
up_count = votes.count(target_uid=target_uid, target_type=target_type, value=1)
down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1)
up_count = votes.count(
target_uid=target_uid, target_type=target_type, value=1, deleted_at=None
)
down_count = votes.count(
target_uid=target_uid, target_type=target_type, value=-1, deleted_at=None
)
net = up_count - down_count
update_target_stars(target_type, target_uid, net)
@@ -103,7 +124,10 @@ async def vote(
if request.headers.get("x-requested-with") == "fetch":
current = votes.find_one(
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
user_uid=user["uid"],
target_uid=target_uid,
target_type=target_type,
deleted_at=None,
)
current_value = int(current["value"]) if current else 0
logger.debug(
+17
View File
@@ -616,6 +616,23 @@ class AdminContainerInstanceOut(_Out):
user: Optional[Any] = None
class TrashItemOut(_Out):
uid: str
table: str
label: Optional[str] = None
deleted_at: Optional[str] = None
deleted_by: Optional[str] = None
target_url: Optional[str] = None
class AdminTrashOut(_Out):
items: list[TrashItemOut] = []
table: str = "posts"
tables: list[dict] = []
pagination: Optional[Any] = None
admin_section: Optional[str] = None
class GatewayUsageOut(_Out):
window_hours: int = 48
generated_at: Optional[str] = None
+3 -1
View File
@@ -289,6 +289,8 @@ def make_sitemap(base_url):
def _collect(table, limit, label, **query):
if table.has_column("deleted_at") and "deleted_at" not in query:
query["deleted_at"] = None
rows = list(table.find(_limit=limit, **query))
if len(rows) >= limit:
logger.warning("sitemap: %s truncated at %d entries", label, limit)
@@ -401,7 +403,7 @@ def _build_sitemap(base_url):
post_counts = {}
if "posts" in db.tables:
for row in db.query(
"SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"
"SELECT user_uid, COUNT(*) AS c FROM posts WHERE deleted_at IS NULL GROUP BY user_uid"
):
post_counts[row["user_uid"]] = row["c"]
users = _collect(
+24 -10
View File
@@ -52,6 +52,8 @@ def create_instance(row: dict) -> dict:
"stopped_at": "",
"created_at": now(),
"updated_at": now(),
"deleted_at": None,
"deleted_by": None,
}
base.update(row)
base["uid"] = uid
@@ -63,13 +65,15 @@ def get_instance(uid: str):
table = get_table("instances")
if not _exists("instances"):
return None
return table.find_one(uid=uid) or table.find_one(slug=uid)
return table.find_one(uid=uid, deleted_at=None) or table.find_one(
slug=uid, deleted_at=None
)
def list_instances(project_uid: str = None) -> list:
if not _exists("instances"):
return []
filters = {}
filters = {"deleted_at": None}
if project_uid:
filters["project_uid"] = project_uid
return sorted(
@@ -82,13 +86,13 @@ def list_instances(project_uid: str = None) -> list:
def all_instances() -> list:
if not _exists("instances"):
return []
return list(get_table("instances").find())
return list(get_table("instances").find(deleted_at=None))
def find_instance_by_ingress(slug: str):
if not slug or not _exists("instances"):
return None
matches = list(get_table("instances").find(ingress_slug=slug))
matches = list(get_table("instances").find(ingress_slug=slug, deleted_at=None))
for instance in matches:
if instance.get("status") == ST_RUNNING:
return instance
@@ -99,8 +103,10 @@ def update_instance(uid: str, changes: dict) -> None:
get_table("instances").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
def delete_instance(uid: str) -> None:
get_table("instances").delete(uid=uid)
def delete_instance(uid: str, deleted_by: str = "system") -> None:
get_table("instances").update(
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]
)
# ---------------- events / metrics / schedules ----------------
@@ -183,6 +189,8 @@ def create_schedule(
"run_count": 0,
"created_at": now(),
"updated_at": now(),
"deleted_at": None,
"deleted_by": None,
}
)
return get_table("instance_schedules").find_one(uid=uid)
@@ -191,7 +199,11 @@ def create_schedule(
def list_schedules(instance_uid: str) -> list:
if not _exists("instance_schedules"):
return []
return list(get_table("instance_schedules").find(instance_uid=instance_uid))
return list(
get_table("instance_schedules").find(
instance_uid=instance_uid, deleted_at=None
)
)
def due_schedules(now_iso: str) -> list:
@@ -199,7 +211,7 @@ def due_schedules(now_iso: str) -> list:
return []
return [
r
for r in get_table("instance_schedules").find(enabled=1)
for r in get_table("instance_schedules").find(enabled=1, deleted_at=None)
if r.get("next_run_at") and r["next_run_at"] <= now_iso
]
@@ -210,5 +222,7 @@ def update_schedule(uid: str, changes: dict) -> None:
)
def delete_schedule(uid: str) -> None:
get_table("instance_schedules").delete(uid=uid)
def delete_schedule(uid: str, deleted_by: str = "system") -> None:
get_table("instance_schedules").update(
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]
)
+22 -5
View File
@@ -44,7 +44,12 @@ class LessonStore:
def _ensure_indexes(self) -> None:
if TABLE not in self._db.tables:
return
self._db[TABLE].create_index(["owner_kind", "owner_id"])
table = self._db[TABLE]
if not table.has_column("deleted_at"):
table.create_column_by_example("deleted_at", "")
if not table.has_column("deleted_by"):
table.create_column_by_example("deleted_by", "")
table.create_index(["owner_kind", "owner_id"])
@property
def _table(self) -> Any:
@@ -57,7 +62,7 @@ class LessonStore:
def count(self) -> int:
if TABLE not in self._db.tables:
return 0
return self._table.count(**self._scope)
return self._table.count(deleted_at=None, **self._scope)
def add(
self, observation: str, conclusion: str, next_action: str, tags: str = ""
@@ -70,6 +75,8 @@ class LessonStore:
"tags": tags,
"created_at": to_iso(now_utc()),
"hits": 0,
"deleted_at": None,
"deleted_by": None,
**self._scope,
}
self._table.insert(record)
@@ -82,14 +89,24 @@ class LessonStore:
def all(self) -> list[dict[str, Any]]:
if TABLE not in self._db.tables:
return []
return list(self._table.find(**self._scope))
return list(self._table.find(deleted_at=None, **self._scope))
def delete(self, uid: str) -> bool:
if TABLE not in self._db.tables:
return False
deleted = self._table.delete(uid=uid, **self._scope)
row = self._table.find_one(uid=uid, deleted_at=None, **self._scope)
if not row:
return False
self._table.update(
{
"id": row["id"],
"deleted_at": to_iso(now_utc()),
"deleted_by": f"{self._owner_kind}:{self._owner_id}",
},
["id"],
)
self._dirty = True
return bool(deleted)
return True
def clear(self) -> int:
n = self.count()
+28 -7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import dataset
@@ -34,6 +35,10 @@ class TaskStore:
if TABLE not in self._db.tables:
return
table = self._db[TABLE]
if not table.has_column("deleted_at"):
table.create_column_by_example("deleted_at", "")
if not table.has_column("deleted_by"):
table.create_column_by_example("deleted_by", "")
for columns in INDEXED_COLUMNS:
table.create_index(columns)
@@ -46,7 +51,9 @@ class TaskStore:
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
def create(self, record: dict[str, Any]) -> None:
self._table.insert({**record, **self._scope})
self._table.insert(
{"deleted_at": None, "deleted_by": None, **record, **self._scope}
)
logger.info(
"Task created uid=%s owner=%s/%s",
record.get("uid"),
@@ -55,12 +62,13 @@ class TaskStore:
)
def get(self, uid: str) -> dict[str, Any] | None:
return self._table.find_one(uid=uid, **self._scope)
return self._table.find_one(uid=uid, deleted_at=None, **self._scope)
def list(
self, enabled_only: bool = False, status: str | None = None
) -> list[dict[str, Any]]:
criteria: dict[str, Any] = dict(self._scope)
criteria["deleted_at"] = None
if enabled_only:
criteria["enabled"] = True
if status:
@@ -73,14 +81,25 @@ class TaskStore:
logger.debug("Task updated uid=%s changes=%s", uid, list(changes))
def delete(self, uid: str) -> bool:
deleted = self._table.delete(uid=uid, **self._scope)
logger.info("Task deleted uid=%s ok=%s", uid, deleted)
return bool(deleted)
row = self._table.find_one(uid=uid, deleted_at=None, **self._scope)
if not row:
return False
self._table.update(
{
"uid": uid,
"deleted_at": datetime.now(timezone.utc).isoformat(),
"deleted_by": f"{self._owner_kind}:{self._owner_id}",
**self._scope,
},
["uid", "owner_kind", "owner_id"],
)
logger.info("Task soft-deleted uid=%s", uid)
return True
def recover_running(self) -> int:
if TABLE not in self._db.tables:
return 0
stuck = list(self._table.find(status="running", **self._scope))
stuck = list(self._table.find(status="running", deleted_at=None, **self._scope))
for row in stuck:
self._table.update(
{"uid": row["uid"], "status": "pending", **self._scope},
@@ -91,7 +110,9 @@ class TaskStore:
return len(stuck)
def due(self, now_iso: str) -> list[dict[str, Any]]:
rows = self._table.find(enabled=True, status="pending", **self._scope)
rows = self._table.find(
enabled=True, status="pending", deleted_at=None, **self._scope
)
return [
row
for row in rows
@@ -46,6 +46,10 @@ class VirtualToolStore:
if TABLE not in self._db.tables:
return
table = self._db[TABLE]
if not table.has_column("deleted_at"):
table.create_column_by_example("deleted_at", "")
if not table.has_column("deleted_by"):
table.create_column_by_example("deleted_by", "")
for columns in INDEXED_COLUMNS:
table.create_index(columns)
@@ -60,16 +64,23 @@ class VirtualToolStore:
def find(self, name: str) -> dict[str, Any] | None:
if TABLE not in self._db.tables:
return None
return self._table.find_one(name=name, **self._scope)
return self._table.find_one(name=name, deleted_at=None, **self._scope)
def list(self) -> list[dict[str, Any]]:
if TABLE not in self._db.tables:
return []
return list(self._table.find(**self._scope))
return list(self._table.find(deleted_at=None, **self._scope))
def create(self, record: dict[str, Any]) -> dict[str, Any]:
now = _now_iso()
record = {**record, **self._scope, "created_at": now, "updated_at": now}
record = {
"deleted_at": None,
"deleted_by": None,
**record,
**self._scope,
"created_at": now,
"updated_at": now,
}
self._table.insert(record)
logger.info(
"Virtual tool created name=%s owner=%s/%s",
@@ -85,9 +96,20 @@ class VirtualToolStore:
logger.debug("Virtual tool updated name=%s changes=%s", name, list(changes))
def delete(self, name: str) -> bool:
deleted = self._table.delete(name=name, **self._scope)
logger.info("Virtual tool deleted name=%s ok=%s", name, deleted)
return bool(deleted)
row = self._table.find_one(name=name, deleted_at=None, **self._scope)
if not row:
return False
self._table.update(
{
"name": name,
"deleted_at": _now_iso(),
"deleted_by": f"{self._owner_kind}:{self._owner_id}",
**self._scope,
},
["name", "owner_kind", "owner_id"],
)
logger.info("Virtual tool soft-deleted name=%s", name)
return True
def tool_schemas(self) -> list[dict[str, Any]]:
return [_schema_for(row) for row in self.list() if row.get("enabled", 1)]
+4
View File
@@ -239,6 +239,8 @@ class NewsService(BaseService):
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
new_count += 1
@@ -296,6 +298,8 @@ class NewsService(BaseService):
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
"deleted_at": None,
"deleted_by": None,
}
)
+45
View File
@@ -28,6 +28,51 @@
color: var(--text-muted);
}
.admin-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.admin-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 600;
}
.admin-tab:hover {
color: var(--text-color);
border-color: var(--accent-color);
}
.admin-tab.active {
color: var(--accent-color);
border-color: var(--accent-color);
background: var(--accent-soft, rgba(127, 127, 127, 0.08));
}
.admin-tab-count {
font-size: 0.6875rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--bg-muted, rgba(127, 127, 127, 0.15));
color: var(--text-muted);
}
.admin-table-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
white-space: nowrap;
}
.admin-table-wrap {
overflow-x: auto;
}
+3
View File
@@ -17,6 +17,9 @@
<a href="/admin/media" class="sidebar-link {% if admin_section == 'media' %}active{% endif %}">
<span class="sidebar-icon">&#x1F5BC;&#xFE0F;</span> Media
</a>
<a href="/admin/trash" class="sidebar-link {% if admin_section == 'trash' %}active{% endif %}">
<span class="sidebar-icon">&#x1F5D1;&#xFE0F;</span> Trash
</a>
<a href="/admin/services" class="sidebar-link {% if admin_section == 'services' %}active{% endif %}">
<span class="sidebar-icon">&#x2699;&#xFE0F;</span> Services
</a>
+55
View File
@@ -0,0 +1,55 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Trash</h2>
<span class="admin-count">{{ pagination.total }} soft-deleted</span>
</div>
<nav class="admin-tabs">
{% for tab in tables %}
<a href="/admin/trash?table={{ tab['key'] }}" class="admin-tab {% if tab['active'] %}active{% endif %}">
{{ tab['label'] }} <span class="admin-tab-count">{{ tab['count'] }}</span>
</a>
{% endfor %}
</nav>
<table class="admin-table">
<thead>
<tr>
<th>Item</th>
<th>Removed</th>
<th>Removed by</th>
<th class="admin-table-actions">Actions</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{% if item['target_url'] %}
<a href="{{ item['target_url'] }}">{{ item['label'] }}</a>
{% else %}
{{ item['label'] }}
{% endif %}
</td>
<td>{{ format_date(item['deleted_at']) }}</td>
<td>{{ item['deleted_by'] or 'system' }}</td>
<td class="admin-table-actions">
<form method="POST" action="/admin/trash/{{ item['table'] }}/{{ item['uid'] }}/restore" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x267B;&#xFE0F;</span> Restore</button>
</form>
<form method="POST" action="/admin/trash/{{ item['table'] }}/{{ item['uid'] }}/purge" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this item and everything removed with it? This cannot be undone."><span class="icon">&#x1F5D1;&#xFE0F;</span> Purge</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="empty-state">Trash is empty.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "_pagination.html" %}
{% endblock %}
@@ -0,0 +1,99 @@
<div class="docs-content" data-render>
# Soft delete and data retention
> Audience: administrators. This page is hidden from members and guests in the sidebar, the search
> index, and the documentation export. It describes the platform-wide soft-delete model, the data it
> retains, and the moderation surface that lets you review, restore, and permanently purge it.
DevPlace removes records with a **soft delete** rather than a physical delete. Removing something
stamps two columns on its row instead of erasing it:
- `deleted_at` - an ISO-8601 timestamp marking when the row was removed,
- `deleted_by` - who removed it: a user uid, or `system` for automated actions.
A live row has `deleted_at = NULL`. Every read path that lists or counts data filters on
`deleted_at IS NULL`, so a soft-deleted record disappears from the entire product immediately, yet
stays in the database for audit and recovery. Both columns are indexed (`idx_<table>_deleted`) so the
filter stays fast at scale.
## The two stages of deletion
Soft delete only makes sense alongside a second stage that actually frees storage. The platform
distinguishes them deliberately:
1. **Removal (soft).** A user or admin removing something they care about - a post, comment, gist,
project, news article, project file, attachment, a vote/reaction/bookmark/follow toggle, a
session (logout), a container instance or schedule, a Devii task, lesson, or custom tool, a
per-owner customization. These set `deleted_at` + `deleted_by` and are reversible.
2. **Purge / garbage collection (hard).** Operations whose entire purpose is to bound storage stay
physical deletes, because soft-deleting them would mean nothing is ever freed: async job rows and
their staging directories, the container metrics ring buffer, AI usage-ledger retention sweeps and
quota resets, expired-session cleanup, fork-rollback of a half-created project, and the news-sync
image refresh. The admin **Purge** action below is the manual counterpart - it empties the trash.
This is why "everything is soft-deleted" is true for removals while retention and ring-buffer
mechanics remain hard: the second stage is what makes the first stage safe.
## Toggles revive instead of duplicating
Vote, reaction, bookmark, follow, and poll-vote actions are toggles. Toggling off soft-deletes the
row; toggling the same target back on **revives the same physical row** (clears `deleted_at`) rather
than inserting a second one. Counts and "did I react?" reads filter `deleted_at IS NULL`, so a
revived toggle is counted exactly once and history is never duplicated.
## Cascades share one timestamp
When a parent is removed, its children are soft-deleted in the same operation with the **same
`deleted_at` timestamp and `deleted_by`**. Deleting a post, for example, stamps the post plus its
comments, votes, reactions, bookmarks, poll, and attachments with one shared timestamp. That single
timestamp identifies the whole deletion event, which is what makes a one-click restore possible.
## Who can do what
| Action | Who | Effect |
|--------|-----|--------|
| Delete content | The owner, or any admin | Soft delete (reversible), cascades to children |
| Restore | Admin only | Reverses the entire deletion event |
| Purge | Admin only | Permanent hard delete of the event (rows + files) |
Members and guests are never shown the soft-delete, restore, or purge concepts; from their point of
view a delete simply removes the item. Recovery is an administrator surface.
## The Trash
The **Trash** entry in the admin sidebar opens `/admin/trash`. It lists soft-deleted rows per table
(posts, comments, gists, projects, news, project files, attachments), each with its label, the time
it was removed, and the resolved `deleted_by` username. Two actions per row:
- **Restore** (`POST /admin/trash/{table}/{uid}/restore`) - reads the row's `deleted_at` and reverses
every row across all tables that share that timestamp, bringing the item and everything removed with
it back to the live product in one step.
- **Purge** (`POST /admin/trash/{table}/{uid}/purge`) - permanently hard-deletes every row sharing
that timestamp and unlinks any attachment files or project-file blobs from disk. This cannot be
undone and is the only way soft-deleted content is physically removed from the UI.
The existing **Media** trash (`/admin/media`) is the attachment-specific view of the same model and
continues to work as before; see [Media moderation](/docs/media-moderation.html).
## Security and audit notes
- **Accountability.** `deleted_by` records the actor for every removal, including cascades, so the
trash doubles as an audit trail of who removed what and when.
- **Logout is auditable, not destructive.** Signing out soft-deletes the session row (`deleted_by`
is the user) and the auth cache is cleared, so the session is rejected immediately; expired
sessions are still hard-swept by the cleanup path.
- **Right-to-be-forgotten.** Bulk "forget" operations (clearing a Devii conversation, forgetting all
lessons, resetting a usage quota) remain hard deletes by design, so a privacy wipe leaves nothing
recoverable.
- **Purge is irreversible.** It is the only hard delete reachable from the admin UI and deletes files
from disk; the confirmation dialog reflects that.
- **Retention is unchanged.** Job artifacts, metrics rings, and AI usage ledgers continue to be
pruned on their existing schedules; soft delete does not extend their lifetime.
## Reference
- Restore and purge are admin UI endpoints under `/admin/trash`; the member-facing delete endpoints
live on each content type's API page.
- The data-layer primitives are `soft_delete`, `soft_delete_in`, `restore`, `purge`, `restore_event`,
and `purge_event` in `database.py`.
</div>
+3 -1
View File
@@ -41,6 +41,8 @@ def create_session(user_uid: str, max_age_seconds: int = SESSION_MAX_AGE) -> str
"user_uid": user_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at.isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token
@@ -77,7 +79,7 @@ def _user_from_session(request: Request):
if cached is not None:
return cached
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
session = sessions.find_one(session_token=token, deleted_at=None)
if not session:
return None
expires = datetime.fromisoformat(session["expires_at"])