Skip to content

Commit

Permalink
Process mention on annotation creation #9322
Browse files Browse the repository at this point in the history
  • Loading branch information
mtomilov committed Feb 5, 2025
1 parent 044ac34 commit 008f84f
Show file tree
Hide file tree
Showing 17 changed files with 376 additions and 5 deletions.
21 changes: 21 additions & 0 deletions docs/_extra/api-reference/schemas/annotation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<username>@<authority>"`
example: "acct:[email protected]"
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
6 changes: 4 additions & 2 deletions h/models/mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from h.models import helpers


class Mention(Base, Timestamps): # pragma: nocover
class Mention(Base, Timestamps):
__tablename__ = "mention"

id: Mapped[int] = mapped_column(sa.Integer, autoincrement=True, primary_key=True)
Expand All @@ -17,7 +17,9 @@ class Mention(Base, Timestamps): # pragma: nocover
nullable=False,
)
"""FK to annotation.id"""
annotation = sa.orm.relationship("Annotation", back_populates="mentions")
annotation = sa.orm.relationship(
"Annotation", back_populates="mentions", uselist=False
)

user_id: Mapped[int] = mapped_column(
sa.Integer,
Expand Down
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.user.userid,
"username": self._mention.username,
"display_name": self._mention.user.display_name,
"link": self._mention.user.uri,
}
2 changes: 2 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,7 @@ 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.factory", iface=MentionService)

# Other services
config.register_service_factory(
Expand Down
12 changes: 12 additions & 0 deletions h/services/annotation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

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
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": [
MentionJSONPresenter(mention).asdict()
for mention in annotation.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
Annotation.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),
)
6 changes: 6 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 @@ -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),
)
20 changes: 20 additions & 0 deletions h/services/html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from html.parser import HTMLParser


class LinkParser(HTMLParser):
def __init__(self):
super().__init__()
self._links = []

def handle_starttag(self, tag, attrs):
if tag == "a":
self._links.append(dict(attrs))

def get_links(self) -> list[dict]:
return self._links


def parse_html_links(html: str) -> list[dict]:
parser = LinkParser()
parser.feed(html)
return parser.get_links()
79 changes: 79 additions & 0 deletions h/services/mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import logging
from collections import OrderedDict

from sqlalchemy import delete
from sqlalchemy.orm import Session

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

MENTION_CLASS = "data-hyp-mention"
MENTION_USERID = "data-userid"
MENTION_LIMIT = 5

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()

# Only shared annotations can have mentions
if not annotation.shared:
return
mentioning_user = self._user_service.fetch(annotation.userid)
# NIPSA users do not send mentions
if mentioning_user.nipsa:
return

mentioned_userids = OrderedDict.fromkeys(self._parse_userids(annotation.text))
mentioned_users = self._user_service.fetch_all(mentioned_userids)
self._session.execute(
delete(Mention).where(Mention.annotation_id == annotation.id)
)

for i, user in enumerate(mentioned_users):
if i >= MENTION_LIMIT:
logger.warning(
"Annotation %s has more than %s mentions",
annotation.id,
MENTION_LIMIT,
)
break
# NIPSA users do not receive mentions
if user.nipsa:
continue
# Only allow mentions if the annotation is in the public group
# or the annotation is in one of mentioned user's groups
if not (
annotation.groupid == "__world__" or annotation.group in user.groups
):
continue

mention = Mention(
annotation_id=annotation.id, user_id=user.id, username=user.username
)
self._session.add(mention)

@staticmethod
def _parse_userids(text: str) -> list[str]:
links = parse_html_links(text)
return [
user_id
for link in links
if MENTION_CLASS in link and (user_id := link.get(MENTION_USERID))
]


def 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")
)
1 change: 1 addition & 0 deletions tests/common/factories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from tests.common.factories.group import Group, OpenGroup, RestrictedGroup
from tests.common.factories.group_scope import GroupScope
from tests.common.factories.job import ExpungeUserJob, Job, SyncAnnotationJob
from tests.common.factories.mention import Mention
from tests.common.factories.organization import Organization
from tests.common.factories.setting import Setting
from tests.common.factories.subscriptions import Subscriptions
Expand Down
17 changes: 17 additions & 0 deletions tests/common/factories/mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import factory

from h import models

from .annotation import Annotation
from .base import ModelFactory
from .user import User


class Mention(ModelFactory):
class Meta:
model = models.Mention
sqlalchemy_session_persistence = "flush"

annotation = factory.SubFactory(Annotation)
user = factory.SubFactory(User)
username = factory.LazyAttribute(lambda obj: obj.user.username)
7 changes: 7 additions & 0 deletions tests/common/fixtures/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from h.services import MentionService
from h.services.analytics import AnalyticsService
from h.services.annotation_delete import AnnotationDeleteService
from h.services.annotation_json import AnnotationJSONService
Expand Down Expand Up @@ -84,6 +85,7 @@
"user_signup_service",
"user_unique_service",
"user_update_service",
"mention_service",
)


Expand Down Expand Up @@ -306,3 +308,8 @@ def user_unique_service(mock_service):
@pytest.fixture
def user_update_service(mock_service):
return mock_service(UserUpdateService, name="user_update")


@pytest.fixture
def mention_service(mock_service):
return mock_service(MentionService)
8 changes: 8 additions & 0 deletions tests/unit/h/models/mention_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
def test_repr(factories):
annotation = factories.Annotation()
mention = factories.Mention(annotation=annotation)

assert (
repr(mention)
== f"Mention(id={mention.id}, annotation_id={mention.annotation.id!r}, user_id={mention.user.id})"
)
26 changes: 26 additions & 0 deletions tests/unit/h/presenters/mention_json_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest

from h.models import Mention
from h.presenters.mention_json import MentionJSONPresenter


class TestMentionJSONPresenter:
def test_as_dict(self, user, annotation):
mention = Mention(annotation=annotation, user=user, username=user.username)

data = MentionJSONPresenter(mention).asdict()

assert data == {
"userid": user.userid,
"username": user.username,
"display_name": user.display_name,
"link": user.uri,
}

@pytest.fixture
def user(self, factories):
return factories.User.build()

@pytest.fixture
def annotation(self, factories):
return factories.Annotation.build()
Loading

0 comments on commit 008f84f

Please sign in to comment.