Skip to content

Commit 85f0de1

Browse files
committed
Process mention on annotation creation #9322
1 parent 044ac34 commit 85f0de1

17 files changed

+400
-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

+17
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
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
10+
from h.services.feature import FeatureService
811
from h.services.flag import FlagService
912
from h.services.links import LinksService
1013
from h.services.user import UserService
@@ -22,6 +25,8 @@ def __init__(
2225
links_service: LinksService,
2326
flag_service: FlagService,
2427
user_service: UserService,
28+
mention_service: MentionService,
29+
feature_service: FeatureService,
2530
):
2631
"""
2732
Instantiate the service.
@@ -30,11 +35,14 @@ def __init__(
3035
:param links_service: LinksService instance
3136
:param flag_service: FlagService instance
3237
:param user_service: UserService instance
38+
:param mention_service: MentionService instance
3339
"""
3440
self._annotation_read_service = annotation_read_service
3541
self._links_service = links_service
3642
self._flag_service = flag_service
3743
self._user_service = user_service
44+
self._mention_service = mention_service
45+
self._feature_service = feature_service
3846

3947
def present(self, annotation: Annotation):
4048
"""
@@ -73,6 +81,11 @@ def present(self, annotation: Annotation):
7381
"links": self._links_service.get_all(annotation),
7482
}
7583
)
84+
if self._feature_service.enabled("at_mentions"): # pragma: no cover
85+
model["mentions"] = [
86+
MentionJSONPresenter(mention).asdict()
87+
for mention in annotation.mentions
88+
]
7689

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

@@ -151,6 +164,8 @@ def present_all_for_user(self, annotation_ids, user: User):
151164
# which ultimately depends on group permissions, causing a
152165
# group lookup for every annotation without this
153166
Annotation.group,
167+
# Optimise access to the mentions
168+
Annotation.mentions,
154169
],
155170
)
156171

@@ -184,4 +199,6 @@ def factory(_context, request):
184199
links_service=request.find_service(name="links"),
185200
flag_service=request.find_service(name="flag"),
186201
user_service=request.find_service(name="user"),
202+
mention_service=request.find_service(MentionService),
203+
feature_service=request.find_service(name="feature"),
187204
)

h/services/annotation_write.py

+10
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,16 @@ def __init__(
2930
queue_service: JobQueueService,
3031
annotation_read_service: AnnotationReadService,
3132
annotation_metadata_service: AnnotationMetadataService,
33+
mention_service: MentionService,
34+
feature_service,
3235
):
3336
self._db = db_session
3437
self._has_permission = has_permission
3538
self._queue_service = queue_service
3639
self._annotation_read_service = annotation_read_service
3740
self._annotation_metadata_service = annotation_metadata_service
41+
self._mention_service = mention_service
42+
self._feature_service = feature_service
3843

3944
def create_annotation(self, data: dict) -> Annotation:
4045
"""
@@ -88,6 +93,9 @@ def create_annotation(self, data: dict) -> Annotation:
8893
schedule_in=60,
8994
)
9095

96+
if self._feature_service.enabled("at_mentions"): # pragma: no cover
97+
self._mention_service.update_mentions(annotation)
98+
9199
return annotation
92100

93101
def update_annotation(
@@ -281,4 +289,6 @@ def service_factory(_context, request) -> AnnotationWriteService:
281289
queue_service=request.find_service(name="queue_service"),
282290
annotation_read_service=request.find_service(AnnotationReadService),
283291
annotation_metadata_service=request.find_service(AnnotationMetadataService),
292+
mention_service=request.find_service(MentionService),
293+
feature_service=request.find_service(name="feature"),
284294
)

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_ATTRIBUTE = "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_ATTRIBUTE 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

+14
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
@@ -19,6 +20,7 @@
1920
BulkLMSStatsService,
2021
)
2122
from h.services.developer_token import DeveloperTokenService
23+
from h.services.feature import FeatureService
2224
from h.services.flag import FlagService
2325
from h.services.group import GroupService
2426
from h.services.group_create import GroupCreateService
@@ -84,6 +86,8 @@
8486
"user_signup_service",
8587
"user_unique_service",
8688
"user_update_service",
89+
"mention_service",
90+
"feature_service",
8791
)
8892

8993

@@ -306,3 +310,13 @@ def user_unique_service(mock_service):
306310
@pytest.fixture
307311
def user_update_service(mock_service):
308312
return mock_service(UserUpdateService, name="user_update")
313+
314+
315+
@pytest.fixture
316+
def mention_service(mock_service):
317+
return mock_service(MentionService)
318+
319+
320+
@pytest.fixture
321+
def feature_service(mock_service):
322+
return mock_service(FeatureService, name="feature")

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)