From cc776b1d342e22b232c0dda0f36cccfda25e0785 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Fri, 24 Jan 2025 16:55:40 +0100 Subject: [PATCH 01/22] Add MentionService --- h/services/__init__.py | 3 +++ h/services/annotation_write.py | 6 +++++ h/services/mention.py | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 h/services/mention.py diff --git a/h/services/__init__.py b/h/services/__init__.py index 3ed27124858..451e0b912ed 100644 --- a/h/services/__init__.py +++ b/h/services/__init__.py @@ -42,6 +42,9 @@ def includeme(config): # pragma: no cover config.register_service_factory( "h.services.annotation_write.service_factory", iface=AnnotationWriteService ) + config.register_service_factory( + "h.services.mention.service_factory", name="mention" + ) # Other services config.register_service_factory( diff --git a/h/services/annotation_write.py b/h/services/annotation_write.py index 10d5497ecb9..868e4d65f1f 100644 --- a/h/services/annotation_write.py +++ b/h/services/annotation_write.py @@ -13,6 +13,7 @@ from h.services.annotation_metadata import AnnotationMetadataService from h.services.annotation_read import AnnotationReadService from h.services.job_queue import JobQueueService +from h.services.mention import MentionService from h.traversal.group import GroupContext from h.util.group_scope import url_in_scope @@ -29,12 +30,14 @@ def __init__( queue_service: JobQueueService, annotation_read_service: AnnotationReadService, annotation_metadata_service: AnnotationMetadataService, + mention_service: MentionService, ): self._db = db_session self._has_permission = has_permission self._queue_service = queue_service self._annotation_read_service = annotation_read_service self._annotation_metadata_service = annotation_metadata_service + self._mention_service = mention_service def create_annotation(self, data: dict) -> Annotation: """ @@ -88,6 +91,8 @@ def create_annotation(self, data: dict) -> Annotation: schedule_in=60, ) + self._mention_service.update_mentions(annotation) + return annotation def update_annotation( @@ -281,4 +286,5 @@ def service_factory(_context, request) -> AnnotationWriteService: queue_service=request.find_service(name="queue_service"), annotation_read_service=request.find_service(AnnotationReadService), annotation_metadata_service=request.find_service(AnnotationMetadataService), + mention_service=request.find_service(MentionService), ) diff --git a/h/services/mention.py b/h/services/mention.py new file mode 100644 index 00000000000..bc0050c4787 --- /dev/null +++ b/h/services/mention.py @@ -0,0 +1,40 @@ +import re + +from sqlalchemy.orm import Session +import sqlalchemy as sa + +from h.models import Annotation, Mention +from h.services.user import UserService + +USERID_PAT = re.compile(r"data-userid=\"([^\"]+)\"") + + +class MentionService: + """A service for managing user mentions.""" + + def __init__(self, session: Session, user_service: UserService): + self._session = session + self._user_service = user_service + + def update_mentions(self, annotation: Annotation) -> None: + self._session.flush() + + userids = set(self._parse_userids(annotation.text)) + users = self._user_service.fetch_all(userids) + self._session.execute( + sa.delete(Mention).where(Mention.annotation_id == annotation.id) + ) + for user in users: + mention = Mention(annotation_id=annotation.id, user_id=user.id) + self._session.add(mention) + + @staticmethod + def _parse_userids(text: str) -> list[str]: + return USERID_PAT.findall(text) + + +def service_factory(_context, request) -> MentionService: + """Return a MentionService instance for the passed context and request.""" + return MentionService( + session=request.db, user_service=request.find_service(name="user") + ) From 461ea5ffccf76c3cc8a097340907883e1da78133 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Fri, 24 Jan 2025 18:41:08 +0100 Subject: [PATCH 02/22] Fix MentionService --- h/services/__init__.py | 3 ++- h/services/annotation_write.py | 2 ++ h/services/mention.py | 8 +++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/h/services/__init__.py b/h/services/__init__.py index 451e0b912ed..128f52e9458 100644 --- a/h/services/__init__.py +++ b/h/services/__init__.py @@ -12,6 +12,7 @@ ) from h.services.job_queue import JobQueueService from h.services.subscription import SubscriptionService +from h.services.mention import MentionService def includeme(config): # pragma: no cover @@ -43,7 +44,7 @@ def includeme(config): # pragma: no cover "h.services.annotation_write.service_factory", iface=AnnotationWriteService ) config.register_service_factory( - "h.services.mention.service_factory", name="mention" + "h.services.mention.service_factory", iface=MentionService ) # Other services diff --git a/h/services/annotation_write.py b/h/services/annotation_write.py index 868e4d65f1f..4e4e9fc13e1 100644 --- a/h/services/annotation_write.py +++ b/h/services/annotation_write.py @@ -156,6 +156,8 @@ def update_annotation( force=not update_timestamp, ) + self._mention_service.update_mentions(annotation) + return annotation def hide(self, annotation): diff --git a/h/services/mention.py b/h/services/mention.py index bc0050c4787..20870fa5576 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -2,12 +2,14 @@ from sqlalchemy.orm import Session import sqlalchemy as sa - +import logging from h.models import Annotation, Mention from h.services.user import UserService USERID_PAT = re.compile(r"data-userid=\"([^\"]+)\"") +logger = logging.getLogger(__name__) + class MentionService: """A service for managing user mentions.""" @@ -22,10 +24,10 @@ def update_mentions(self, annotation: Annotation) -> None: userids = set(self._parse_userids(annotation.text)) users = self._user_service.fetch_all(userids) self._session.execute( - sa.delete(Mention).where(Mention.annotation_id == annotation.id) + sa.delete(Mention).where(Mention.annotation_id == annotation.slim.id) ) for user in users: - mention = Mention(annotation_id=annotation.id, user_id=user.id) + mention = Mention(annotation_id=annotation.slim.id, user_id=user.id) self._session.add(mention) @staticmethod From 0ce87038de58338530435dfad10b43c92d757219 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Fri, 24 Jan 2025 18:49:23 +0100 Subject: [PATCH 03/22] Add username to mention --- .../bfc54f0844aa_add_username_to_mention.py | 23 +++++++++++++++++++ h/models/mention.py | 3 +++ h/services/__init__.py | 2 +- h/services/mention.py | 11 ++++++--- 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 h/migrations/versions/bfc54f0844aa_add_username_to_mention.py diff --git a/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py b/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py new file mode 100644 index 00000000000..66cf71d3c08 --- /dev/null +++ b/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py @@ -0,0 +1,23 @@ +"""Add username column to mention table. + +Revision ID: bfc54f0844aa +Revises: 39cc1025a3a2 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "bfc54f0844aa" +down_revision = "39cc1025a3a2" + + +def upgrade() -> None: + op.add_column("mention", sa.Column("username", sa.UnicodeText(), nullable=False)) + + +def downgrade() -> None: + op.drop_constraint( + op.f("uq__annotation_metadata__annotation_id"), + "annotation_metadata", + type_="unique", + ) diff --git a/h/models/mention.py b/h/models/mention.py index 04afb6bfab6..27c0d3fce86 100644 --- a/h/models/mention.py +++ b/h/models/mention.py @@ -28,5 +28,8 @@ class Mention(Base, Timestamps): # pragma: nocover """FK to user.id""" user = sa.orm.relationship("User") + username = sa.Column("username", sa.UnicodeText(), nullable=False) + """Current username of the user mentioned""" + def __repr__(self) -> str: return helpers.repr_(self, ["id", "annotation_id", "user_id"]) diff --git a/h/services/__init__.py b/h/services/__init__.py index 128f52e9458..2ed2efdf15d 100644 --- a/h/services/__init__.py +++ b/h/services/__init__.py @@ -11,8 +11,8 @@ BulkLMSStatsService, ) from h.services.job_queue import JobQueueService -from h.services.subscription import SubscriptionService from h.services.mention import MentionService +from h.services.subscription import SubscriptionService def includeme(config): # pragma: no cover diff --git a/h/services/mention.py b/h/services/mention.py index 20870fa5576..38bc8d7b617 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -1,8 +1,9 @@ +import logging import re -from sqlalchemy.orm import Session import sqlalchemy as sa -import logging +from sqlalchemy.orm import Session + from h.models import Annotation, Mention from h.services.user import UserService @@ -27,7 +28,11 @@ def update_mentions(self, annotation: Annotation) -> None: sa.delete(Mention).where(Mention.annotation_id == annotation.slim.id) ) for user in users: - mention = Mention(annotation_id=annotation.slim.id, user_id=user.id) + mention = Mention( + annotation_id=annotation.slim.id, + user_id=user.id, + username=user.username, + ) self._session.add(mention) @staticmethod From 45be341e8c0bfcc8a26064b0c69d00bec6e308e2 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 09:37:38 +0100 Subject: [PATCH 04/22] Optimize MentionService --- h/models/annotation_slim.py | 2 ++ h/models/mention.py | 2 +- h/services/annotation_json.py | 9 +++++++++ h/services/mention.py | 7 ++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/h/models/annotation_slim.py b/h/models/annotation_slim.py index 6b5954dc5d7..a2b9226d8cd 100644 --- a/h/models/annotation_slim.py +++ b/h/models/annotation_slim.py @@ -96,5 +96,7 @@ class AnnotationSlim(Base): ) group = sa.orm.relationship("Group") + mentions = sa.orm.relationship("Mention", back_populates="annotation") + def __repr__(self): return helpers.repr_(self, ["id"]) diff --git a/h/models/mention.py b/h/models/mention.py index 27c0d3fce86..63f4290854d 100644 --- a/h/models/mention.py +++ b/h/models/mention.py @@ -17,7 +17,7 @@ class Mention(Base, Timestamps): # pragma: nocover nullable=False, ) """FK to annotation_slim.id""" - annotation = sa.orm.relationship("AnnotationSlim") + annotation = sa.orm.relationship("AnnotationSlim", back_populates="mentions") user_id: Mapped[int] = mapped_column( sa.Integer, diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index a88f7368690..f76cf03f1f1 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -4,6 +4,7 @@ from h.presenters import DocumentJSONPresenter from h.security import Identity, identity_permits from h.security.permissions import Permission +from h.services import MentionService from h.services.annotation_read import AnnotationReadService from h.services.flag import FlagService from h.services.links import LinksService @@ -22,6 +23,7 @@ def __init__( links_service: LinksService, flag_service: FlagService, user_service: UserService, + mention_service: MentionService, ): """ Instantiate the service. @@ -30,11 +32,13 @@ def __init__( :param links_service: LinksService instance :param flag_service: FlagService instance :param user_service: UserService instance + :param mention_service: MentionService instance """ self._annotation_read_service = annotation_read_service self._links_service = links_service self._flag_service = flag_service self._user_service = user_service + self._mention_service = mention_service def present(self, annotation: Annotation): """ @@ -71,6 +75,7 @@ def present(self, annotation: Annotation): "target": annotation.target, "document": DocumentJSONPresenter(annotation.document).asdict(), "links": self._links_service.get_all(annotation), + # ... } ) @@ -157,6 +162,9 @@ def present_all_for_user(self, annotation_ids, user: User): # Optimise the user service `fetch()` call self._user_service.fetch_all([annotation.userid for annotation in annotations]) + # Optimise access to mentions + self._mention_service.fetch_all(annotations) + return [self.present_for_user(annotation, user) for annotation in annotations] @classmethod @@ -184,4 +192,5 @@ def factory(_context, request): links_service=request.find_service(name="links"), flag_service=request.find_service(name="flag"), user_service=request.find_service(name="user"), + mention_service=request.find_service(MentionService), ) diff --git a/h/services/mention.py b/h/services/mention.py index 38bc8d7b617..08d836e5fa2 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -1,6 +1,6 @@ import logging import re - +from typing import Sequence, Iterable import sqlalchemy as sa from sqlalchemy.orm import Session @@ -39,6 +39,11 @@ def update_mentions(self, annotation: Annotation) -> None: def _parse_userids(text: str) -> list[str]: return USERID_PAT.findall(text) + def fetch_all(self, annotations: Iterable[Annotation]) -> Sequence[Mention]: + annotation_slim_ids = [annotation.slim.id for annotation in annotations] + stmt = sa.select(Mention).where(Mention.annotation_id.in_(annotation_slim_ids)) + return self._session.execute(stmt).scalars().all() + def service_factory(_context, request) -> MentionService: """Return a MentionService instance for the passed context and request.""" From a1ce1564b9b9d62318245e85c0ff6b17b677fd6d Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 10:04:15 +0100 Subject: [PATCH 05/22] Add MENTION_SCHEMA --- h/presenters/mention_json.py | 17 +++++++++++++++++ h/schemas/annotation.py | 15 +++++++++++++++ h/services/annotation_json.py | 3 ++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 h/presenters/mention_json.py diff --git a/h/presenters/mention_json.py b/h/presenters/mention_json.py new file mode 100644 index 00000000000..e1b85fc338d --- /dev/null +++ b/h/presenters/mention_json.py @@ -0,0 +1,17 @@ +from typing import Any + +from h.models import Mention + + +class MentionJSONPresenter: + """Present a mention in the JSON format returned by API requests.""" + def __init__(self, mention: Mention): + self._mention = mention + + def asdict(self) -> dict[str, Any]: + return { + "userid": self._mention.annotation.user.userid, + "username": self._mention.username, + "display_name": self._mention.user.display_name, + "link": self._mention.user.uri, + } diff --git a/h/schemas/annotation.py b/h/schemas/annotation.py index a86ea64bdae..5651643102c 100644 --- a/h/schemas/annotation.py +++ b/h/schemas/annotation.py @@ -65,6 +65,17 @@ def _validate_wildcard_uri(node, value): }, } +MENTION_SCHEMA = { + "type": "object", + "properties": { + "userid": {"type": "string"}, + "username": {"type": "string"}, + "display_name": {"type": "string"}, + "link": {"type": "string"}, + }, + "required": ["userid", "username"], +} + class AnnotationSchema(JSONSchema): """Validate an annotation object.""" @@ -98,6 +109,10 @@ class AnnotationSchema(JSONSchema): "text": {"type": "string"}, "uri": {"type": "string"}, "metadata": {"type": "object"}, + "mentions": { + "type": "array", + "items": copy.deepcopy(MENTION_SCHEMA), + }, }, } diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index f76cf03f1f1..96302bf8eb6 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -2,6 +2,7 @@ from h.models import Annotation, User from h.presenters import DocumentJSONPresenter +from h.presenters.mention_json import MentionJSONPresenter from h.security import Identity, identity_permits from h.security.permissions import Permission from h.services import MentionService @@ -75,7 +76,7 @@ def present(self, annotation: Annotation): "target": annotation.target, "document": DocumentJSONPresenter(annotation.document).asdict(), "links": self._links_service.get_all(annotation), - # ... + "mentions": [MentionJSONPresenter(mention).asdict() for mention in annotation.slim.mentions], } ) From 0129a3c27810152ffbbf41edb07eb4fed60ebb36 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 12:04:49 +0100 Subject: [PATCH 06/22] Fix migration --- .../versions/bfc54f0844aa_add_username_to_mention.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py b/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py index 66cf71d3c08..15d10db7bfb 100644 --- a/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py +++ b/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py @@ -16,8 +16,4 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_constraint( - op.f("uq__annotation_metadata__annotation_id"), - "annotation_metadata", - type_="unique", - ) + op.drop_column("mention", "username") From 72c06900493d114d56fb055ee070db6dcac27952 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 12:47:35 +0100 Subject: [PATCH 07/22] Optimize mentions query --- h/services/annotation_json.py | 10 ++++++---- h/services/annotation_read.py | 9 +++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index 96302bf8eb6..7b7ecebf171 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -1,6 +1,6 @@ from copy import deepcopy -from h.models import Annotation, User +from h.models import Annotation, User, AnnotationSlim from h.presenters import DocumentJSONPresenter from h.presenters.mention_json import MentionJSONPresenter from h.security import Identity, identity_permits @@ -146,6 +146,9 @@ def present_all_for_user(self, annotation_ids, user: User): self._flag_service.all_flagged(user, annotation_ids) self._flag_service.flag_counts(annotation_ids) + import logging + logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + annotations = self._annotation_read_service.get_annotations_by_id( ids=annotation_ids, eager_load=[ @@ -157,15 +160,14 @@ def present_all_for_user(self, annotation_ids, user: User): # which ultimately depends on group permissions, causing a # group lookup for every annotation without this Annotation.group, + # Optimise access to the mentions + (Annotation.slim, AnnotationSlim.mentions), ], ) # Optimise the user service `fetch()` call self._user_service.fetch_all([annotation.userid for annotation in annotations]) - # Optimise access to mentions - self._mention_service.fetch_all(annotations) - return [self.present_for_user(annotation, user) for annotation in annotations] @classmethod diff --git a/h/services/annotation_read.py b/h/services/annotation_read.py index ad614883fd3..a72dc007333 100644 --- a/h/services/annotation_read.py +++ b/h/services/annotation_read.py @@ -1,7 +1,7 @@ from typing import Iterable, List, Optional from sqlalchemy import select -from sqlalchemy.orm import Query, Session, subqueryload +from sqlalchemy.orm import Query, Session, subqueryload, selectinload from h.db.types import InvalidUUID from h.models import Annotation @@ -54,7 +54,12 @@ def _annotation_search_query( query = query.where(Annotation.id.in_(ids)) if eager_load: - query = query.options(*(subqueryload(prop) for prop in eager_load)) + for prop in eager_load: + if isinstance(prop, tuple) and len(prop) == 2: + parent, child = prop + query = query.options(subqueryload(parent).subqueryload(child)) + else: + query = query.options(subqueryload(prop)) return query From bd27f5a0185314193c1aea3a877cd88f85c2e9f8 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 13:18:25 +0100 Subject: [PATCH 08/22] Fix format --- h/presenters/mention_json.py | 1 + h/services/annotation_json.py | 10 +++++++--- h/services/annotation_read.py | 2 +- h/services/mention.py | 3 ++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/h/presenters/mention_json.py b/h/presenters/mention_json.py index e1b85fc338d..1b45e3d0b3d 100644 --- a/h/presenters/mention_json.py +++ b/h/presenters/mention_json.py @@ -5,6 +5,7 @@ class MentionJSONPresenter: """Present a mention in the JSON format returned by API requests.""" + def __init__(self, mention: Mention): self._mention = mention diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index 7b7ecebf171..a31a7f72495 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -1,6 +1,6 @@ from copy import deepcopy -from h.models import Annotation, User, AnnotationSlim +from h.models import Annotation, AnnotationSlim, User from h.presenters import DocumentJSONPresenter from h.presenters.mention_json import MentionJSONPresenter from h.security import Identity, identity_permits @@ -76,7 +76,10 @@ def present(self, annotation: Annotation): "target": annotation.target, "document": DocumentJSONPresenter(annotation.document).asdict(), "links": self._links_service.get_all(annotation), - "mentions": [MentionJSONPresenter(mention).asdict() for mention in annotation.slim.mentions], + "mentions": [ + MentionJSONPresenter(mention).asdict() + for mention in annotation.slim.mentions + ], } ) @@ -147,7 +150,8 @@ def present_all_for_user(self, annotation_ids, user: User): self._flag_service.flag_counts(annotation_ids) import logging - logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) annotations = self._annotation_read_service.get_annotations_by_id( ids=annotation_ids, diff --git a/h/services/annotation_read.py b/h/services/annotation_read.py index a72dc007333..c49024dc493 100644 --- a/h/services/annotation_read.py +++ b/h/services/annotation_read.py @@ -1,7 +1,7 @@ from typing import Iterable, List, Optional from sqlalchemy import select -from sqlalchemy.orm import Query, Session, subqueryload, selectinload +from sqlalchemy.orm import Query, Session, selectinload, subqueryload from h.db.types import InvalidUUID from h.models import Annotation diff --git a/h/services/mention.py b/h/services/mention.py index 08d836e5fa2..bff948dd7ef 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -1,6 +1,7 @@ import logging import re -from typing import Sequence, Iterable +from typing import Iterable, Sequence + import sqlalchemy as sa from sqlalchemy.orm import Session From 6494f05071a86475ed9bc476d9ab8e239d86b1db Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Mon, 27 Jan 2025 15:23:25 +0100 Subject: [PATCH 09/22] Clean up --- h/services/annotation_json.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index a31a7f72495..2a8bc7b9f9f 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -149,10 +149,6 @@ def present_all_for_user(self, annotation_ids, user: User): self._flag_service.all_flagged(user, annotation_ids) self._flag_service.flag_counts(annotation_ids) - import logging - - logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) - annotations = self._annotation_read_service.get_annotations_by_id( ids=annotation_ids, eager_load=[ From 06605bead145bfcdc005a21e162e59e0baeda339 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 09:15:07 +0100 Subject: [PATCH 10/22] Clean up --- h/schemas/annotation.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/h/schemas/annotation.py b/h/schemas/annotation.py index 5651643102c..a86ea64bdae 100644 --- a/h/schemas/annotation.py +++ b/h/schemas/annotation.py @@ -65,17 +65,6 @@ def _validate_wildcard_uri(node, value): }, } -MENTION_SCHEMA = { - "type": "object", - "properties": { - "userid": {"type": "string"}, - "username": {"type": "string"}, - "display_name": {"type": "string"}, - "link": {"type": "string"}, - }, - "required": ["userid", "username"], -} - class AnnotationSchema(JSONSchema): """Validate an annotation object.""" @@ -109,10 +98,6 @@ class AnnotationSchema(JSONSchema): "text": {"type": "string"}, "uri": {"type": "string"}, "metadata": {"type": "object"}, - "mentions": { - "type": "array", - "items": copy.deepcopy(MENTION_SCHEMA), - }, }, } From 9d0c2c2e6745878a4113e3ec2f520c14252d6869 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 09:22:06 +0100 Subject: [PATCH 11/22] Update annotation.yaml with mentions --- .../api-reference/schemas/annotation.yaml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/_extra/api-reference/schemas/annotation.yaml b/docs/_extra/api-reference/schemas/annotation.yaml index 68e7f0253ca..8d5825b25ae 100644 --- a/docs/_extra/api-reference/schemas/annotation.yaml +++ b/docs/_extra/api-reference/schemas/annotation.yaml @@ -199,3 +199,24 @@ Annotation: description: The annotation creator's display name example: "Felicity Nunsun" - type: null + mentions: + type: array + items: + type: object + properties: + userid: + type: string + pattern: "acct:^[A-Za-z0-9._]{3,30}@.*$" + description: user account ID in the format `"acct:@"` + example: "acct:felicity_nunsun@hypothes.is" + username: + type: string + description: The username of the user + display_name: + type: string + description: The display name of the user + link: + type: string + format: uri + description: The link to the user profile + description: An array of user mentions the annotation text From 66f5a0d4fdee16d485063cd2aa75495762eee395 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 09:31:25 +0100 Subject: [PATCH 12/22] Clean up --- .../bfc54f0844aa_add_username_to_mention.py | 19 ------------------- h/models/mention.py | 3 --- 2 files changed, 22 deletions(-) delete mode 100644 h/migrations/versions/bfc54f0844aa_add_username_to_mention.py diff --git a/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py b/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py deleted file mode 100644 index 15d10db7bfb..00000000000 --- a/h/migrations/versions/bfc54f0844aa_add_username_to_mention.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Add username column to mention table. - -Revision ID: bfc54f0844aa -Revises: 39cc1025a3a2 -""" - -import sqlalchemy as sa -from alembic import op - -revision = "bfc54f0844aa" -down_revision = "39cc1025a3a2" - - -def upgrade() -> None: - op.add_column("mention", sa.Column("username", sa.UnicodeText(), nullable=False)) - - -def downgrade() -> None: - op.drop_column("mention", "username") diff --git a/h/models/mention.py b/h/models/mention.py index 63f4290854d..6e0a5e07912 100644 --- a/h/models/mention.py +++ b/h/models/mention.py @@ -28,8 +28,5 @@ class Mention(Base, Timestamps): # pragma: nocover """FK to user.id""" user = sa.orm.relationship("User") - username = sa.Column("username", sa.UnicodeText(), nullable=False) - """Current username of the user mentioned""" - def __repr__(self) -> str: return helpers.repr_(self, ["id", "annotation_id", "user_id"]) From 99769cd4e2558059b1bb689948f7bff406f803bf Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 09:51:39 +0100 Subject: [PATCH 13/22] Clean up --- h/presenters/mention_json.py | 1 - h/services/mention.py | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/h/presenters/mention_json.py b/h/presenters/mention_json.py index 1b45e3d0b3d..963077ab883 100644 --- a/h/presenters/mention_json.py +++ b/h/presenters/mention_json.py @@ -12,7 +12,6 @@ def __init__(self, mention: Mention): def asdict(self) -> dict[str, Any]: return { "userid": self._mention.annotation.user.userid, - "username": self._mention.username, "display_name": self._mention.user.display_name, "link": self._mention.user.uri, } diff --git a/h/services/mention.py b/h/services/mention.py index bff948dd7ef..393a6eda44f 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -2,7 +2,7 @@ import re from typing import Iterable, Sequence -import sqlalchemy as sa +from sqlalchemy import select, delete from sqlalchemy.orm import Session from h.models import Annotation, Mention @@ -26,13 +26,12 @@ def update_mentions(self, annotation: Annotation) -> None: userids = set(self._parse_userids(annotation.text)) users = self._user_service.fetch_all(userids) self._session.execute( - sa.delete(Mention).where(Mention.annotation_id == annotation.slim.id) + delete(Mention).where(Mention.annotation_id == annotation.slim.id) ) for user in users: mention = Mention( annotation_id=annotation.slim.id, - user_id=user.id, - username=user.username, + user_id=user.id ) self._session.add(mention) @@ -42,7 +41,7 @@ def _parse_userids(text: str) -> list[str]: def fetch_all(self, annotations: Iterable[Annotation]) -> Sequence[Mention]: annotation_slim_ids = [annotation.slim.id for annotation in annotations] - stmt = sa.select(Mention).where(Mention.annotation_id.in_(annotation_slim_ids)) + stmt = select(Mention).where(Mention.annotation_id.in_(annotation_slim_ids)) return self._session.execute(stmt).scalars().all() From 5bd068b7f46ed4519181a544a7ee6efb3f0cf3d6 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 09:53:44 +0100 Subject: [PATCH 14/22] Format --- h/services/mention.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/h/services/mention.py b/h/services/mention.py index 393a6eda44f..9f0310b4deb 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -2,7 +2,7 @@ import re from typing import Iterable, Sequence -from sqlalchemy import select, delete +from sqlalchemy import delete, select from sqlalchemy.orm import Session from h.models import Annotation, Mention @@ -29,10 +29,7 @@ def update_mentions(self, annotation: Annotation) -> None: delete(Mention).where(Mention.annotation_id == annotation.slim.id) ) for user in users: - mention = Mention( - annotation_id=annotation.slim.id, - user_id=user.id - ) + mention = Mention(annotation_id=annotation.slim.id, user_id=user.id) self._session.add(mention) @staticmethod From d16bb034f29308a15d0b08a11a71fa984bf37c2a Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 13:15:24 +0100 Subject: [PATCH 15/22] Update mention to reference annotation --- ..._update_mention_to_reference_annotation.py | 39 +++++++++++++++++++ h/models/annotation.py | 2 + h/models/annotation_slim.py | 2 - h/models/mention.py | 12 +++--- h/services/mention.py | 9 +---- 5 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 h/migrations/versions/022ea4bf4a27_update_mention_to_reference_annotation.py diff --git a/h/migrations/versions/022ea4bf4a27_update_mention_to_reference_annotation.py b/h/migrations/versions/022ea4bf4a27_update_mention_to_reference_annotation.py new file mode 100644 index 00000000000..e386e67e4ed --- /dev/null +++ b/h/migrations/versions/022ea4bf4a27_update_mention_to_reference_annotation.py @@ -0,0 +1,39 @@ +"""Update mention to reference annotation. + +Revision ID: 022ea4bf4a27 +Revises: 39cc1025a3a2 +""" + +import sqlalchemy as sa +from alembic import op + +from h.db import types + +revision = "022ea4bf4a27" +down_revision = "39cc1025a3a2" + + +def upgrade() -> None: + op.drop_column("mention", "annotation_id") + op.add_column( + "mention", + sa.Column( + "annotation_id", + types.URLSafeUUID(), + sa.ForeignKey("annotation.id", ondelete="CASCADE"), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_column("mention", "annotation_id") + op.add_column( + "mention", + sa.Column( + "annotation_id", + sa.INTEGER(), + sa.ForeignKey("annotation_slim.id", ondelete="CASCADE"), + nullable=False, + ), + ) diff --git a/h/models/annotation.py b/h/models/annotation.py index 7254a4630f4..f6611edd01e 100644 --- a/h/models/annotation.py +++ b/h/models/annotation.py @@ -138,6 +138,8 @@ class Annotation(Base): uselist=True, ) + mentions = sa.orm.relationship("Mention", back_populates="annotation") + @property def uuid(self): """ diff --git a/h/models/annotation_slim.py b/h/models/annotation_slim.py index a2b9226d8cd..6b5954dc5d7 100644 --- a/h/models/annotation_slim.py +++ b/h/models/annotation_slim.py @@ -96,7 +96,5 @@ class AnnotationSlim(Base): ) group = sa.orm.relationship("Group") - mentions = sa.orm.relationship("Mention", back_populates="annotation") - def __repr__(self): return helpers.repr_(self, ["id"]) diff --git a/h/models/mention.py b/h/models/mention.py index 6e0a5e07912..be52ba700ad 100644 --- a/h/models/mention.py +++ b/h/models/mention.py @@ -1,7 +1,7 @@ import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column -from h.db import Base +from h.db import Base, types from h.db.mixins import Timestamps from h.models import helpers @@ -11,13 +11,13 @@ class Mention(Base, Timestamps): # pragma: nocover id: Mapped[int] = mapped_column(sa.Integer, autoincrement=True, primary_key=True) - annotation_id: Mapped[int] = mapped_column( - sa.Integer, - sa.ForeignKey("annotation_slim.id", ondelete="CASCADE"), + annotation_id: Mapped[types.URLSafeUUID] = mapped_column( + types.URLSafeUUID, + sa.ForeignKey("annotation.id", ondelete="CASCADE"), nullable=False, ) - """FK to annotation_slim.id""" - annotation = sa.orm.relationship("AnnotationSlim", back_populates="mentions") + """FK to annotation.id""" + annotation = sa.orm.relationship("Annotation", back_populates="mentions") user_id: Mapped[int] = mapped_column( sa.Integer, diff --git a/h/services/mention.py b/h/services/mention.py index 9f0310b4deb..164bdd34dec 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -26,21 +26,16 @@ def update_mentions(self, annotation: Annotation) -> None: userids = set(self._parse_userids(annotation.text)) users = self._user_service.fetch_all(userids) self._session.execute( - delete(Mention).where(Mention.annotation_id == annotation.slim.id) + delete(Mention).where(Mention.annotation_id == annotation.id) ) for user in users: - mention = Mention(annotation_id=annotation.slim.id, user_id=user.id) + mention = Mention(annotation_id=annotation.id, user_id=user.id) self._session.add(mention) @staticmethod def _parse_userids(text: str) -> list[str]: return USERID_PAT.findall(text) - def fetch_all(self, annotations: Iterable[Annotation]) -> Sequence[Mention]: - annotation_slim_ids = [annotation.slim.id for annotation in annotations] - stmt = select(Mention).where(Mention.annotation_id.in_(annotation_slim_ids)) - return self._session.execute(stmt).scalars().all() - def service_factory(_context, request) -> MentionService: """Return a MentionService instance for the passed context and request.""" From 4f44ce487670f378f7ec86038e13f991f8c9110a Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 13:24:44 +0100 Subject: [PATCH 16/22] Clean up --- h/presenters/mention_json.py | 2 +- h/services/annotation_json.py | 4 ++-- h/services/annotation_read.py | 7 +------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/h/presenters/mention_json.py b/h/presenters/mention_json.py index 963077ab883..97e5067ce57 100644 --- a/h/presenters/mention_json.py +++ b/h/presenters/mention_json.py @@ -11,7 +11,7 @@ def __init__(self, mention: Mention): def asdict(self) -> dict[str, Any]: return { - "userid": self._mention.annotation.user.userid, + "userid": self._mention.annotation.userid, "display_name": self._mention.user.display_name, "link": self._mention.user.uri, } diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index 2a8bc7b9f9f..6190a5f2094 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -78,7 +78,7 @@ def present(self, annotation: Annotation): "links": self._links_service.get_all(annotation), "mentions": [ MentionJSONPresenter(mention).asdict() - for mention in annotation.slim.mentions + for mention in annotation.mentions ], } ) @@ -161,7 +161,7 @@ def present_all_for_user(self, annotation_ids, user: User): # group lookup for every annotation without this Annotation.group, # Optimise access to the mentions - (Annotation.slim, AnnotationSlim.mentions), + Annotation.mentions, ], ) diff --git a/h/services/annotation_read.py b/h/services/annotation_read.py index c49024dc493..e224ab14912 100644 --- a/h/services/annotation_read.py +++ b/h/services/annotation_read.py @@ -54,12 +54,7 @@ def _annotation_search_query( query = query.where(Annotation.id.in_(ids)) if eager_load: - for prop in eager_load: - if isinstance(prop, tuple) and len(prop) == 2: - parent, child = prop - query = query.options(subqueryload(parent).subqueryload(child)) - else: - query = query.options(subqueryload(prop)) + query = query.options(*(subqueryload(prop) for prop in eager_load)) return query From 4dd1e6895641753fa83fb1b544d16b9ee0aaa5fa Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 16:08:53 +0100 Subject: [PATCH 17/22] Send mention email notifications --- h/emails/__init__.py | 4 +- h/emails/mention_notification.py | 36 +++++++++++ h/notification/mention.py | 61 +++++++++++++++++++ h/services/annotation_json.py | 2 +- h/services/annotation_read.py | 2 +- h/services/mention.py | 3 +- h/subscribers.py | 27 +++++++- .../emails/mention_notification.html.jinja2 | 26 ++++++++ .../emails/mention_notification.txt.jinja2 | 7 +++ 9 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 h/emails/mention_notification.py create mode 100644 h/notification/mention.py create mode 100644 h/templates/emails/mention_notification.html.jinja2 create mode 100644 h/templates/emails/mention_notification.txt.jinja2 diff --git a/h/emails/__init__.py b/h/emails/__init__.py index f4457355268..0404049722c 100644 --- a/h/emails/__init__.py +++ b/h/emails/__init__.py @@ -1,3 +1,3 @@ -from h.emails import reply_notification, reset_password, signup +from h.emails import mention_notification, reply_notification, reset_password, signup -__all__ = ("reply_notification", "reset_password", "signup") +__all__ = ("reply_notification", "reset_password", "signup", "mention_notification") diff --git a/h/emails/mention_notification.py b/h/emails/mention_notification.py new file mode 100644 index 00000000000..447810b85d4 --- /dev/null +++ b/h/emails/mention_notification.py @@ -0,0 +1,36 @@ +from pyramid.renderers import render +from pyramid.request import Request + +from h import links +from h.notification.mention import Notification + + +def generate(request: Request, notification: Notification): + context = { + "user_url": _get_user_url(notification.mentioning_user, request), + "user_display_name": notification.mentioning_user.display_name + or notification.mentioning_user.username, + "annotation_url": links.incontext_link(request, notification.annotation) + or request.route_url("annotation", id=notification.annotation.id), + "document_title": notification.document.title + or notification.annotation.target_uri, + "document_url": notification.annotation.target_uri, + "annotation": notification.annotation, + } + + subject = f"{context['user_display_name']} has mentioned you in an annotation" + text = render( + "h:templates/emails/mention_notification.txt.jinja2", context, request=request + ) + html = render( + "h:templates/emails/mention_notification.html.jinja2", context, request=request + ) + + return [notification.mentioned_user.email], subject, text, html + + +def _get_user_url(user, request): + if user.authority == request.default_authority: + return request.route_url("stream.user_query", user=user.username) + + return None diff --git a/h/notification/mention.py b/h/notification/mention.py new file mode 100644 index 00000000000..d122982977b --- /dev/null +++ b/h/notification/mention.py @@ -0,0 +1,61 @@ +import logging +from dataclasses import dataclass + +from h.models import Annotation, Document, User + +logger = logging.getLogger(__name__) + + +@dataclass +class Notification: + """A data structure representing a notification of a mention in an annotation.""" + + mentioning_user: User + mentioned_user: User + annotation: Annotation + document: Document + + +def get_notifications(request, annotation: Annotation, action) -> list[Notification]: + # Only send notifications when new annotations are created + if action != "create": + return [] + + user_service = request.find_service(name="user") + + # If the mentioning user doesn't exist (anymore), we can't send emails, but + # this would be super weird, so log a warning. + mentioning_user = user_service.fetch(annotation.userid) + if mentioning_user is None: + logger.warning( + "user who just mentioned another user no longer exists: %s", + annotation.userid, + ) + return [] + + notifications = [] + for mention in annotation.mentions: + # If the mentioning user doesn't exist (anymore), we can't send emails + mentioned_user = user_service.fetch(mention.user.userid) + if mentioned_user is None: + continue + + # If mentioned user doesn't have an email address we can't email them. + if not mention.user.email: + continue + + # Do not notify users about their own replies + if mentioning_user == mentioned_user: + continue + + # If the annotation doesn't have a document, we can't send an email. + if annotation.document is None: + continue + + notifications.append( + Notification( + mentioning_user, mentioned_user, annotation, annotation.document + ) + ) + + return notifications diff --git a/h/services/annotation_json.py b/h/services/annotation_json.py index 6190a5f2094..1dc760b0ea1 100644 --- a/h/services/annotation_json.py +++ b/h/services/annotation_json.py @@ -1,6 +1,6 @@ from copy import deepcopy -from h.models import Annotation, AnnotationSlim, User +from h.models import Annotation, User from h.presenters import DocumentJSONPresenter from h.presenters.mention_json import MentionJSONPresenter from h.security import Identity, identity_permits diff --git a/h/services/annotation_read.py b/h/services/annotation_read.py index e224ab14912..ad614883fd3 100644 --- a/h/services/annotation_read.py +++ b/h/services/annotation_read.py @@ -1,7 +1,7 @@ from typing import Iterable, List, Optional from sqlalchemy import select -from sqlalchemy.orm import Query, Session, selectinload, subqueryload +from sqlalchemy.orm import Query, Session, subqueryload from h.db.types import InvalidUUID from h.models import Annotation diff --git a/h/services/mention.py b/h/services/mention.py index 164bdd34dec..138c6a31a7f 100644 --- a/h/services/mention.py +++ b/h/services/mention.py @@ -1,8 +1,7 @@ import logging import re -from typing import Iterable, Sequence -from sqlalchemy import delete, select +from sqlalchemy import delete from sqlalchemy.orm import Session from h.models import Annotation, Mention diff --git a/h/subscribers.py b/h/subscribers.py index 704d2f306cb..e8e9c078ae6 100644 --- a/h/subscribers.py +++ b/h/subscribers.py @@ -5,7 +5,7 @@ from h import __version__, emails from h.events import AnnotationEvent from h.exceptions import RealtimeMessageQueueError -from h.notification import reply +from h.notification import mention, reply from h.services.annotation_read import AnnotationReadService from h.tasks import mailer @@ -89,3 +89,28 @@ def send_reply_notifications(event): except OperationalError as err: # pragma: no cover # We could not connect to rabbit! So carry on report_exception(err) + + +@subscriber(AnnotationEvent) +def send_mention_notifications(event): + """Send mention notifications triggered by a mention event.""" + + request = event.request + + with request.tm: + annotation = request.find_service(AnnotationReadService).get_annotation_by_id( + event.annotation_id, + ) + notifications = mention.get_notifications(request, annotation, event.action) + + if not notifications: + return + + for notification in notifications: + send_params = emails.mention_notification.generate(request, notification) + + try: + mailer.send.delay(*send_params) + except OperationalError as err: # pragma: no cover + # We could not connect to rabbit! So carry on + report_exception(err) diff --git a/h/templates/emails/mention_notification.html.jinja2 b/h/templates/emails/mention_notification.html.jinja2 new file mode 100644 index 00000000000..eb7ce438f20 --- /dev/null +++ b/h/templates/emails/mention_notification.html.jinja2 @@ -0,0 +1,26 @@ +

+ {% if user_url %} + {{ user_display_name }} + {% else %} + {{ user_display_name }} + {% endif %} + has + mentioned you + on + “{{ document_title }}”: +

+ +

+ On + {{ annotation.updated | human_timestamp }} + {% if user_url %} + {{ user_display_name }} + {% else %} + {{ user_display_name }} + {% endif %} + commented: +

+ +
{{ annotation.text or "" }}
+ +

View the thread and respond.

diff --git a/h/templates/emails/mention_notification.txt.jinja2 b/h/templates/emails/mention_notification.txt.jinja2 new file mode 100644 index 00000000000..66726b21ed0 --- /dev/null +++ b/h/templates/emails/mention_notification.txt.jinja2 @@ -0,0 +1,7 @@ +{{ user_display_name }} has replied to your annotation on "{{ document_title }}": + +On {{ annotation.updated | human_timestamp }} {{ user_display_name }} replied: + +> {{ annotation.text or "" }} + +View the thread and respond: {{ annotation_url }} From 9fd2ebef53676492bb3ba95c643cb4d882afd177 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 16:10:21 +0100 Subject: [PATCH 18/22] Update docstring --- h/notification/mention.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h/notification/mention.py b/h/notification/mention.py index d122982977b..13126b3caa1 100644 --- a/h/notification/mention.py +++ b/h/notification/mention.py @@ -8,7 +8,7 @@ @dataclass class Notification: - """A data structure representing a notification of a mention in an annotation.""" + """A data structure representing a mention notification in an annotation.""" mentioning_user: User mentioned_user: User From d089ea02b766d8bfc49303e1441e8bf0a5a1146d Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 16:11:21 +0100 Subject: [PATCH 19/22] Fix MentionJSONPresenter --- h/presenters/mention_json.py | 1 + 1 file changed, 1 insertion(+) diff --git a/h/presenters/mention_json.py b/h/presenters/mention_json.py index 97e5067ce57..106fc7afca1 100644 --- a/h/presenters/mention_json.py +++ b/h/presenters/mention_json.py @@ -12,6 +12,7 @@ def __init__(self, mention: Mention): def asdict(self) -> dict[str, Any]: return { "userid": self._mention.annotation.userid, + "username": self._mention.user.username, "display_name": self._mention.user.display_name, "link": self._mention.user.uri, } From c5a2a27d15559e6d7fbe0f51d64898754f6b0776 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 16:41:22 +0100 Subject: [PATCH 20/22] Strip html tags --- h/templates/emails/mention_notification.html.jinja2 | 2 +- h/templates/emails/mention_notification.txt.jinja2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/h/templates/emails/mention_notification.html.jinja2 b/h/templates/emails/mention_notification.html.jinja2 index eb7ce438f20..17e29bf6140 100644 --- a/h/templates/emails/mention_notification.html.jinja2 +++ b/h/templates/emails/mention_notification.html.jinja2 @@ -21,6 +21,6 @@ commented:

-
{{ annotation.text or "" }}
+
{{ annotation.text|striptags or "" }}

View the thread and respond.

diff --git a/h/templates/emails/mention_notification.txt.jinja2 b/h/templates/emails/mention_notification.txt.jinja2 index 66726b21ed0..eca7e622be4 100644 --- a/h/templates/emails/mention_notification.txt.jinja2 +++ b/h/templates/emails/mention_notification.txt.jinja2 @@ -2,6 +2,6 @@ On {{ annotation.updated | human_timestamp }} {{ user_display_name }} replied: -> {{ annotation.text or "" }} +> {{ annotation.text|striptags or "" }} View the thread and respond: {{ annotation_url }} From 8fc7df111c0b742a6db86adbff167e91ff9ed911 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Wed, 29 Jan 2025 17:04:25 +0100 Subject: [PATCH 21/22] Strip html tags in replies --- h/templates/emails/reply_notification.html.jinja2 | 4 ++-- h/templates/emails/reply_notification.txt.jinja2 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/h/templates/emails/reply_notification.html.jinja2 b/h/templates/emails/reply_notification.html.jinja2 index e2553ef39b1..33e967b1660 100644 --- a/h/templates/emails/reply_notification.html.jinja2 +++ b/h/templates/emails/reply_notification.html.jinja2 @@ -21,7 +21,7 @@ commented:

-
{{ parent.text or "" }}
+
{{ parent.text|striptags or "" }}

On @@ -34,7 +34,7 @@ replied:

-
{{ reply.text or "" }}
+
{{ reply.text|striptags or "" }}

View the thread and respond.

diff --git a/h/templates/emails/reply_notification.txt.jinja2 b/h/templates/emails/reply_notification.txt.jinja2 index 80fd0967271..eb2170871c9 100644 --- a/h/templates/emails/reply_notification.txt.jinja2 +++ b/h/templates/emails/reply_notification.txt.jinja2 @@ -2,11 +2,11 @@ On {{ parent.created | human_timestamp }} {{ parent_user_display_name }} commented: -> {{ parent.text or "" }} +> {{ parent.text|striptags or "" }} On {{ reply.created | human_timestamp }} {{ reply_user_display_name }} replied: -> {{ reply.text or "" }} +> {{ reply.text|striptags or "" }} View the thread and respond: {{ reply_url }} From 740ff4942f463d9c3ef12da8f63e2195d79bc700 Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Thu, 30 Jan 2025 16:17:41 +0100 Subject: [PATCH 22/22] Fix striptags --- h/templates/emails/mention_notification.html.jinja2 | 2 +- h/templates/emails/mention_notification.txt.jinja2 | 2 +- h/templates/emails/reply_notification.html.jinja2 | 4 ++-- h/templates/emails/reply_notification.txt.jinja2 | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/h/templates/emails/mention_notification.html.jinja2 b/h/templates/emails/mention_notification.html.jinja2 index 17e29bf6140..47a5f251bbe 100644 --- a/h/templates/emails/mention_notification.html.jinja2 +++ b/h/templates/emails/mention_notification.html.jinja2 @@ -21,6 +21,6 @@ commented:

-
{{ annotation.text|striptags or "" }}
+
{{ (annotation.text or "")|striptags }}

View the thread and respond.

diff --git a/h/templates/emails/mention_notification.txt.jinja2 b/h/templates/emails/mention_notification.txt.jinja2 index eca7e622be4..5d0ffa5ae09 100644 --- a/h/templates/emails/mention_notification.txt.jinja2 +++ b/h/templates/emails/mention_notification.txt.jinja2 @@ -2,6 +2,6 @@ On {{ annotation.updated | human_timestamp }} {{ user_display_name }} replied: -> {{ annotation.text|striptags or "" }} +> {{ (annotation.text or "")|striptags }} View the thread and respond: {{ annotation_url }} diff --git a/h/templates/emails/reply_notification.html.jinja2 b/h/templates/emails/reply_notification.html.jinja2 index 33e967b1660..28b1e9eeae2 100644 --- a/h/templates/emails/reply_notification.html.jinja2 +++ b/h/templates/emails/reply_notification.html.jinja2 @@ -21,7 +21,7 @@ commented:

-
{{ parent.text|striptags or "" }}
+
{{ (parent.text or "")|striptags }}

On @@ -34,7 +34,7 @@ replied:

-
{{ reply.text|striptags or "" }}
+
{{ (reply.text or "")|striptags }}

View the thread and respond.

diff --git a/h/templates/emails/reply_notification.txt.jinja2 b/h/templates/emails/reply_notification.txt.jinja2 index eb2170871c9..76ff756d04c 100644 --- a/h/templates/emails/reply_notification.txt.jinja2 +++ b/h/templates/emails/reply_notification.txt.jinja2 @@ -2,11 +2,11 @@ On {{ parent.created | human_timestamp }} {{ parent_user_display_name }} commented: -> {{ parent.text|striptags or "" }} +> {{ (parent.text or "")|striptags }} On {{ reply.created | human_timestamp }} {{ reply_user_display_name }} replied: -> {{ reply.text|striptags or "" }} +> {{ (reply.text or "")|striptags }} View the thread and respond: {{ reply_url }}