remarkbox/remarkbox/models/meta.py
russell@unturf.com f1cffe2e79 Resolve all 14 tracked tickets (T0-T13)
High priority fixes:
- T0: Profile page now filters comments by namespace (was leaking cross-site)
- T1: URI hostnames and namespace names normalized to lowercase (was causing
  duplicate threads and "stock comments" bug). Includes merge script.
- T2: Thread detail API now paginated with SQL-side filtering (was 502 on
  267+ reply threads)

Features:
- T3: GDPR account deletion (tombstone user with scrubbed PII) and data export
- T4: Customizable button text and comment labels per namespace
- T5: Self-service namespace deletion for owners
- T6: @mention notifications with profile links
- T7: Webmention receiving endpoint with h-card extraction
- T8: Configurable max nesting depth and collapse depth per namespace
- T9: AJAX thread title search to prevent duplicates
- T10: Browser push notification support (VAPID/service worker)

Docs and housekeeping:
- T11: Documented thread_uri behavior when moving embeds
- T12/T13: Drafted community replies for resolved feature requests
- Collapse depth defaults to infinite (load-more disabled unless configured)

364 tests pass, 4 skipped.
2026-02-01 20:02:47 -05:00

159 lines
4.3 KiB
Python

from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm.session import object_session
from sqlalchemy.schema import MetaData
from sqlalchemy import ForeignKey
from time import time
import base64
import uuid
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
# Recommended naming convention used by Alembic, as various different database
# providers will autogenerate vastly different names making migrations more
# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
CLASS_TO_TABLE = {
"Uri": "rb_uri",
"User": "rb_user",
"UserSurrogate": "rb_user_surrogate",
"Vote": "rb_vote",
"Node": "rb_node",
"NodeCache": "rb_node_cache",
"Namespace": "rb_namespace",
"NamespaceUser": "rb_namespace_user",
"NamespaceRequest": "rb_namespace_request",
"Oauth": "rb_oauth",
"Watcher": "rb_watcher",
"NodeEvent": "rb_node_event",
"NodeEventNotification": "rb_node_event_notification",
"PayWhatYouCan": "rb_pay_what_you_can",
"Payment": "rb_payment",
"Webmention": "rb_webmention",
}
# node (threads), namespace (forum)
WATCHER_TYPES = {"reply", "node", "namespace"}
NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"}
NOTIFICATION_METHODS = {"email", "push"}
NODE_EVENT_ACTIONS = {
"enabled",
"disabled",
"deleted",
"verified",
"created", # this is a new thread.
"commented", # this is a reply to an existing thread.
"approved",
"edited",
# These event type actions don't exist yet, but could fuel some ideas for the future.
# "flagged",
# "voted",
}
SUBSCRIPTION_TYPES = {"development", "production", "trial"}
# currently not used anywhere.
USER_EVENT_ACTIONS = {"granted", "promoted", "demoted", "blocked"}
metadata = MetaData(naming_convention=NAMING_CONVENTION)
# Base = declarative_base(metadata=metadata)
Base = declarative_base()
def short_id_to_bytes(short_id):
"""Accept a short_id (sanitized url safe base64 string) and return a byte string.
>>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA')
b'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX'
"""
return base64.b64decode((short_id + "===").replace("_", "/").replace("-", "+"))
def id_to_uuid(the_id):
"""
Accept an id string, return a UUID object.
>>> id_to_uuid('dbHeSEFLEeeuz5xONpxxWA')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48414b11e7aecf9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48-414b-11e7-aecf-9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
"""
if isinstance(the_id, uuid.UUID):
# if the_id is already a UUID object, return it.
return the_id
try:
# assume the_id is already a hex uuid string.
return uuid.UUID(hex=the_id)
except ValueError:
try:
# assume the_id is a base64 "short_id" string.
return uuid.UUID(bytes=short_id_to_bytes(the_id))
except ValueError:
pass
return None
def get_object_by_id(dbsession, object_id, cls):
"""Try to get object from database by id or return None"""
object_uuid = id_to_uuid(object_id)
if object_uuid:
return dbsession.query(cls).filter(cls.id == object_uuid).one_or_none()
def now_timestamp():
return int(time() * 1000)
def foreign_key(class_name, column_name):
return ForeignKey("{}.{}".format(CLASS_TO_TABLE[class_name], column_name))
class RBase(object):
"""Mixin for RemarkBox models."""
def __eq__(self, other):
"""Determine if equal by id."""
if other:
return self.id == other.id
return False
def __ne__(self, other):
"""Determine if not equal by user id."""
return not self.__eq__(other)
def __hash__(self):
return hash(self.id)
@declared_attr
def __tablename__(cls):
return CLASS_TO_TABLE[cls.__name__]
@property
def dbsession(self):
return object_session(self)
@property
def id_without_dashes(self):
return self.id.__str__().replace("-","")