Skip to content

Commit 42922ed

Browse files
committed
Process mention on annotation creation #9322
1 parent 044ac34 commit 42922ed

17 files changed

+376
-5
lines changed

docs/_extra/api-reference/schemas/annotation.yaml

+21
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,24 @@ Annotation:
199199
description: The annotation creator's display name
200200
example: "Felicity Nunsun"
201201
- type: null
202+
mentions:
203+
type: array
204+
items:
205+
type: object
206+
properties:
207+
userid:
208+
type: string
209+
pattern: "acct:^[A-Za-z0-9._]{3,30}@.*$"
210+
description: user account ID in the format `"acct:<username>@<authority>"`
211+
example: "acct:[email protected]"
212+
username:
213+
type: string
214+
description: The username of the user at the time of the mention
215+
display_name:
216+
type: string
217+
description: The display name of the user
218+
link:
219+
type: string
220+
format: uri
221+
description: The link to the user profile
222+
description: An array of user mentions the annotation text

h/models/mention.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from h.models import helpers
77

88

9-
class Mention(Base, Timestamps): # pragma: nocover
9+
class Mention(Base, Timestamps):
1010
__tablename__ = "mention"
1111

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

2224
user_id: Mapped[int] = mapped_column(
2325
sa.Integer,

h/presenters/mention_json.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Any
2+
3+
from h.models import Mention
4+
5+
6+
class MentionJSONPresenter:
7+
"""Present a mention in the JSON format returned by API requests."""
8+
9+
def __init__(self, mention: Mention):
10+
self._mention = mention
11+
12+
def asdict(self) -> dict[str, Any]:
13+
return {
14+
"userid": self._mention.user.userid,
15+
"username": self._mention.username,
16+
"display_name": self._mention.user.display_name,
17+
"link": self._mention.user.uri,
18+
}

h/services/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
BulkLMSStatsService,
1212
)
1313
from h.services.job_queue import JobQueueService
14+
from h.services.mention import MentionService
1415
from h.services.subscription import SubscriptionService
1516

1617

@@ -42,6 +43,7 @@ def includeme(config): # pragma: no cover
4243
config.register_service_factory(
4344
"h.services.annotation_write.service_factory", iface=AnnotationWriteService
4445
)
46+
config.register_service_factory("h.services.mention.factory", iface=MentionService)
4547

4648
# Other services
4749
config.register_service_factory(

h/services/annotation_json.py

+12
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from h.models import Annotation, User
44
from h.presenters import DocumentJSONPresenter
5+
from h.presenters.mention_json import MentionJSONPresenter
56
from h.security import Identity, identity_permits
67
from h.security.permissions import Permission
8+
from h.services import MentionService
79
from h.services.annotation_read import AnnotationReadService
810
from h.services.flag import FlagService
911
from h.services.links import LinksService
@@ -22,6 +24,7 @@ def __init__(
2224
links_service: LinksService,
2325
flag_service: FlagService,
2426
user_service: UserService,
27+
mention_service: MentionService,
2528
):
2629
"""
2730
Instantiate the service.
@@ -30,11 +33,13 @@ def __init__(
3033
:param links_service: LinksService instance
3134
:param flag_service: FlagService instance
3235
:param user_service: UserService instance
36+
:param mention_service: MentionService instance
3337
"""
3438
self._annotation_read_service = annotation_read_service
3539
self._links_service = links_service
3640
self._flag_service = flag_service
3741
self._user_service = user_service
42+
self._mention_service = mention_service
3843

3944
def present(self, annotation: Annotation):
4045
"""
@@ -71,6 +76,10 @@ def present(self, annotation: Annotation):
7176
"target": annotation.target,
7277
"document": DocumentJSONPresenter(annotation.document).asdict(),
7378
"links": self._links_service.get_all(annotation),
79+
"mentions": [
80+
MentionJSONPresenter(mention).asdict()
81+
for mention in annotation.mentions
82+
],
7483
}
7584
)
7685

@@ -151,6 +160,8 @@ def present_all_for_user(self, annotation_ids, user: User):
151160
# which ultimately depends on group permissions, causing a
152161
# group lookup for every annotation without this
153162
Annotation.group,
163+
# Optimise access to the mentions
164+
Annotation.mentions,
154165
],
155166
)
156167

@@ -184,4 +195,5 @@ def factory(_context, request):
184195
links_service=request.find_service(name="links"),
185196
flag_service=request.find_service(name="flag"),
186197
user_service=request.find_service(name="user"),
198+
mention_service=request.find_service(MentionService),
187199
)

h/services/annotation_write.py

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from h.services.annotation_metadata import AnnotationMetadataService
1414
from h.services.annotation_read import AnnotationReadService
1515
from h.services.job_queue import JobQueueService
16+
from h.services.mention import MentionService
1617
from h.traversal.group import GroupContext
1718
from h.util.group_scope import url_in_scope
1819

@@ -29,12 +30,14 @@ def __init__(
2930
queue_service: JobQueueService,
3031
annotation_read_service: AnnotationReadService,
3132
annotation_metadata_service: AnnotationMetadataService,
33+
mention_service: MentionService,
3234
):
3335
self._db = db_session
3436
self._has_permission = has_permission
3537
self._queue_service = queue_service
3638
self._annotation_read_service = annotation_read_service
3739
self._annotation_metadata_service = annotation_metadata_service
40+
self._mention_service = mention_service
3841

3942
def create_annotation(self, data: dict) -> Annotation:
4043
"""
@@ -88,6 +91,8 @@ def create_annotation(self, data: dict) -> Annotation:
8891
schedule_in=60,
8992
)
9093

94+
self._mention_service.update_mentions(annotation)
95+
9196
return annotation
9297

9398
def update_annotation(
@@ -281,4 +286,5 @@ def service_factory(_context, request) -> AnnotationWriteService:
281286
queue_service=request.find_service(name="queue_service"),
282287
annotation_read_service=request.find_service(AnnotationReadService),
283288
annotation_metadata_service=request.find_service(AnnotationMetadataService),
289+
mention_service=request.find_service(MentionService),
284290
)

h/services/html.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from html.parser import HTMLParser
2+
3+
4+
class LinkParser(HTMLParser):
5+
def __init__(self):
6+
super().__init__()
7+
self._links = []
8+
9+
def handle_starttag(self, tag, attrs):
10+
if tag == "a":
11+
self._links.append(dict(attrs))
12+
13+
def get_links(self) -> list[dict]:
14+
return self._links
15+
16+
17+
def parse_html_links(html: str) -> list[dict]:
18+
parser = LinkParser()
19+
parser.feed(html)
20+
return parser.get_links()

h/services/mention.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import logging
2+
from collections import OrderedDict
3+
4+
from sqlalchemy import delete
5+
from sqlalchemy.orm import Session
6+
7+
from h.models import Annotation, Mention
8+
from h.services.html import parse_html_links
9+
from h.services.user import UserService
10+
11+
MENTION_CLASS = "data-hyp-mention"
12+
MENTION_USERID = "data-userid"
13+
MENTION_LIMIT = 5
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class MentionService:
19+
"""A service for managing user mentions."""
20+
21+
def __init__(self, session: Session, user_service: UserService):
22+
self._session = session
23+
self._user_service = user_service
24+
25+
def update_mentions(self, annotation: Annotation) -> None:
26+
self._session.flush()
27+
28+
# Only shared annotations can have mentions
29+
if not annotation.shared:
30+
return
31+
mentioning_user = self._user_service.fetch(annotation.userid)
32+
# NIPSA users do not send mentions
33+
if mentioning_user.nipsa:
34+
return
35+
36+
mentioned_userids = OrderedDict.fromkeys(self._parse_userids(annotation.text))
37+
mentioned_users = self._user_service.fetch_all(mentioned_userids)
38+
self._session.execute(
39+
delete(Mention).where(Mention.annotation_id == annotation.id)
40+
)
41+
42+
for i, user in enumerate(mentioned_users):
43+
if i >= MENTION_LIMIT:
44+
logger.warning(
45+
"Annotation %s has more than %s mentions",
46+
annotation.id,
47+
MENTION_LIMIT,
48+
)
49+
break
50+
# NIPSA users do not receive mentions
51+
if user.nipsa:
52+
continue
53+
# Only allow mentions if the annotation is in the public group
54+
# or the annotation is in one of mentioned user's groups
55+
if not (
56+
annotation.groupid == "__world__" or annotation.group in user.groups
57+
):
58+
continue
59+
60+
mention = Mention(
61+
annotation_id=annotation.id, user_id=user.id, username=user.username
62+
)
63+
self._session.add(mention)
64+
65+
@staticmethod
66+
def _parse_userids(text: str) -> list[str]:
67+
links = parse_html_links(text)
68+
return [
69+
user_id
70+
for link in links
71+
if MENTION_CLASS in link and (user_id := link.get(MENTION_USERID))
72+
]
73+
74+
75+
def factory(_context, request) -> MentionService:
76+
"""Return a MentionService instance for the passed context and request."""
77+
return MentionService(
78+
session=request.db, user_service=request.find_service(name="user")
79+
)

tests/common/factories/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from tests.common.factories.group import Group, OpenGroup, RestrictedGroup
1717
from tests.common.factories.group_scope import GroupScope
1818
from tests.common.factories.job import ExpungeUserJob, Job, SyncAnnotationJob
19+
from tests.common.factories.mention import Mention
1920
from tests.common.factories.organization import Organization
2021
from tests.common.factories.setting import Setting
2122
from tests.common.factories.subscriptions import Subscriptions

tests/common/factories/mention.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import factory
2+
3+
from h import models
4+
5+
from .annotation import Annotation
6+
from .base import ModelFactory
7+
from .user import User
8+
9+
10+
class Mention(ModelFactory):
11+
class Meta:
12+
model = models.Mention
13+
sqlalchemy_session_persistence = "flush"
14+
15+
annotation = factory.SubFactory(Annotation)
16+
user = factory.SubFactory(User)
17+
username = factory.LazyAttribute(lambda obj: obj.user.username)

tests/common/fixtures/services.py

+7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44

5+
from h.services import MentionService
56
from h.services.analytics import AnalyticsService
67
from h.services.annotation_delete import AnnotationDeleteService
78
from h.services.annotation_json import AnnotationJSONService
@@ -84,6 +85,7 @@
8485
"user_signup_service",
8586
"user_unique_service",
8687
"user_update_service",
88+
"mention_service",
8789
)
8890

8991

@@ -306,3 +308,8 @@ def user_unique_service(mock_service):
306308
@pytest.fixture
307309
def user_update_service(mock_service):
308310
return mock_service(UserUpdateService, name="user_update")
311+
312+
313+
@pytest.fixture
314+
def mention_service(mock_service):
315+
return mock_service(MentionService)

tests/unit/h/models/mention_test.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def test_repr(factories):
2+
annotation = factories.Annotation()
3+
mention = factories.Mention(annotation=annotation)
4+
5+
assert (
6+
repr(mention)
7+
== f"Mention(id={mention.id}, annotation_id={mention.annotation.id!r}, user_id={mention.user.id})"
8+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pytest
2+
3+
from h.models import Mention
4+
from h.presenters.mention_json import MentionJSONPresenter
5+
6+
7+
class TestMentionJSONPresenter:
8+
def test_as_dict(self, user, annotation):
9+
mention = Mention(annotation=annotation, user=user, username=user.username)
10+
11+
data = MentionJSONPresenter(mention).asdict()
12+
13+
assert data == {
14+
"userid": user.userid,
15+
"username": user.username,
16+
"display_name": user.display_name,
17+
"link": user.uri,
18+
}
19+
20+
@pytest.fixture
21+
def user(self, factories):
22+
return factories.User.build()
23+
24+
@pytest.fixture
25+
def annotation(self, factories):
26+
return factories.Annotation.build()

0 commit comments

Comments
 (0)