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

Process mention on annotation creation #9322

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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 at the time of the mention
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
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
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
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
)

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,
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
"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 @@ -12,6 +12,7 @@
)
from h.services.email import EmailService
from h.services.job_queue import JobQueueService
from h.services.mention import MentionService
from h.services.subscription import SubscriptionService


Expand Down Expand Up @@ -43,6 +44,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
19 changes: 18 additions & 1 deletion h/services/annotation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

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.feature import FeatureService
from h.services.flag import FlagService
from h.services.links import LinksService
from h.services.user import UserService
Expand All @@ -16,12 +19,14 @@
class AnnotationJSONService:
"""A service for generating API compatible JSON for annotations."""

def __init__(
def __init__( # noqa: PLR0913
self,
annotation_read_service: AnnotationReadService,
links_service: LinksService,
flag_service: FlagService,
user_service: UserService,
mention_service: MentionService,
feature_service: FeatureService,
):
"""
Instantiate the service.
Expand All @@ -30,11 +35,14 @@ 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
self._feature_service = feature_service

def present(self, annotation: Annotation):
"""
Expand Down Expand Up @@ -73,6 +81,11 @@ def present(self, annotation: Annotation):
"links": self._links_service.get_all(annotation),
}
)
if self._feature_service.enabled("at_mentions"): # pragma: no cover
model["mentions"] = [
MentionJSONPresenter(mention).asdict()
for mention in annotation.mentions
]

model.update(user_info(self._user_service.fetch(annotation.userid)))

Expand Down Expand Up @@ -151,6 +164,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 +199,6 @@ 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),
feature_service=request.find_service(name="feature"),
)
12 changes: 11 additions & 1 deletion 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 @@ -22,19 +23,23 @@
class AnnotationWriteService:
"""A service for storing and retrieving annotations."""

def __init__(
def __init__( # noqa: PLR0913
self,
db_session: Session,
has_permission: Callable,
queue_service: JobQueueService,
annotation_read_service: AnnotationReadService,
annotation_metadata_service: AnnotationMetadataService,
mention_service: MentionService,
feature_service,
):
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
self._feature_service = feature_service

def create_annotation(self, data: dict) -> Annotation:
"""
Expand Down Expand Up @@ -88,6 +93,9 @@ def create_annotation(self, data: dict) -> Annotation:
schedule_in=60,
)

if self._feature_service.enabled("at_mentions"): # pragma: no cover
self._mention_service.update_mentions(annotation)

return annotation

def update_annotation(
Expand Down Expand Up @@ -281,4 +289,6 @@ 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),
feature_service=request.find_service(name="feature"),
)
22 changes: 22 additions & 0 deletions h/services/html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# noqa: A005

from html.parser import HTMLParser


class LinkParser(HTMLParser):
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
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_ATTRIBUTE = "data-hyp-mention"
MENTION_USERID = "data-userid"
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
MENTION_LIMIT = 5
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()

# Only shared annotations can have mentions
if not annotation.shared:
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
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))
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
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
mtomilov marked this conversation as resolved.
Show resolved Hide resolved
):
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_ATTRIBUTE 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)
14 changes: 14 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 All @@ -20,6 +21,7 @@
)
from h.services.developer_token import DeveloperTokenService
from h.services.email import EmailService
from h.services.feature import FeatureService
from h.services.flag import FlagService
from h.services.group import GroupService
from h.services.group_create import GroupCreateService
Expand Down Expand Up @@ -60,6 +62,7 @@
"bulk_stats_service",
"developer_token_service",
"email_service",
"feature_service",
"flag_service",
"group_create_service",
"group_delete_service",
Expand All @@ -70,6 +73,7 @@
"group_update_service",
"links_service",
"list_organizations_service",
"mention_service",
"mock_service",
"moderation_service",
"nipsa_service",
Expand Down Expand Up @@ -310,6 +314,16 @@ def user_update_service(mock_service):
return mock_service(UserUpdateService, name="user_update")


@pytest.fixture
def mention_service(mock_service):
return mock_service(MentionService)


@pytest.fixture
def feature_service(mock_service):
return mock_service(FeatureService, name="feature")


@pytest.fixture
def email_service(mock_service):
return mock_service(EmailService)
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})"
)
Loading
Loading