Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mentions PoC #9279

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions h/migrations/versions/bfc54f0844aa_add_username_to_mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""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))
mtomilov marked this conversation as resolved.
Show resolved Hide resolved


def downgrade() -> None:
op.drop_column("mention", "username")
2 changes: 2 additions & 0 deletions h/models/annotation_slim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
5 changes: 4 additions & 1 deletion h/models/mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"""
mtomilov marked this conversation as resolved.
Show resolved Hide resolved

def __repr__(self) -> str:
return helpers.repr_(self, ["id", "annotation_id", "user_id"])
18 changes: 18 additions & 0 deletions h/presenters/mention_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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,
}
15 changes: 15 additions & 0 deletions h/schemas/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -98,6 +109,10 @@ class AnnotationSchema(JSONSchema):
"text": {"type": "string"},
"uri": {"type": "string"},
"metadata": {"type": "object"},
"mentions": {
"type": "array",
"items": copy.deepcopy(MENTION_SCHEMA),
},
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
},
}

Expand Down
4 changes: 4 additions & 0 deletions h/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
BulkLMSStatsService,
)
from h.services.job_queue import JobQueueService
from h.services.mention import MentionService
from h.services.subscription import SubscriptionService


Expand Down Expand Up @@ -42,6 +43,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", iface=MentionService
)

# Other services
config.register_service_factory(
Expand Down
14 changes: 13 additions & 1 deletion h/services/annotation_json.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from copy import deepcopy

from h.models import Annotation, User
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
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
Expand All @@ -22,6 +24,7 @@ def __init__(
links_service: LinksService,
flag_service: FlagService,
user_service: UserService,
mention_service: MentionService,
):
"""
Instantiate the service.
Expand All @@ -30,11 +33,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):
"""
Expand Down Expand Up @@ -71,6 +76,10 @@ def present(self, annotation: Annotation):
"target": annotation.target,
"document": DocumentJSONPresenter(annotation.document).asdict(),
"links": self._links_service.get_all(annotation),
"mentions": [
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
MentionJSONPresenter(mention).asdict()
for mention in annotation.slim.mentions
],
}
)

Expand Down Expand Up @@ -151,6 +160,8 @@ 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
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
(Annotation.slim, AnnotationSlim.mentions),
],
)

Expand Down Expand Up @@ -184,4 +195,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),
)
9 changes: 7 additions & 2 deletions h/services/annotation_read.py
Original file line number Diff line number Diff line change
@@ -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, selectinload, subqueryload

from h.db.types import InvalidUUID
from h.models import Annotation
Expand Down Expand Up @@ -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:
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
parent, child = prop
query = query.options(subqueryload(parent).subqueryload(child))
else:
query = query.options(subqueryload(prop))

return query

Expand Down
8 changes: 8 additions & 0 deletions h/services/annotation_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -151,6 +156,8 @@ def update_annotation(
force=not update_timestamp,
)

self._mention_service.update_mentions(annotation)

return annotation

def hide(self, annotation):
Expand Down Expand Up @@ -281,4 +288,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),
)
53 changes: 53 additions & 0 deletions h/services/mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging
import re
from typing import Iterable, Sequence

import sqlalchemy as sa
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't be shy about importing anything from sqlalchemy. I mean speling the whole from sqlalchemy import select, ....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem, is this the recommended way?

from sqlalchemy.orm import Session

from h.models import Annotation, Mention
from h.services.user import UserService

USERID_PAT = re.compile(r"data-userid=\"([^\"]+)\"")
mtomilov marked this conversation as resolved.
Show resolved Hide resolved

logger = logging.getLogger(__name__)


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))
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
users = self._user_service.fetch_all(userids)
self._session.execute(
sa.delete(Mention).where(Mention.annotation_id == annotation.slim.id)
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
)
for user in users:
mention = Mention(
annotation_id=annotation.slim.id,
user_id=user.id,
username=user.username,
)
self._session.add(mention)

@staticmethod
def _parse_userids(text: str) -> list[str]:
return USERID_PAT.findall(text)
Copy link
Contributor Author

@mtomilov mtomilov Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll probably want to parse userids from text_rendered html.


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."""
return MentionService(
session=request.db, user_service=request.find_service(name="user")
)
Loading