Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Remove unused BadgesField [#3420](https://github.com/opendatateam/udata/pull/3420)
- migrate to pyproject.toml, replace `CIRCLE_TAG` by `setuptools_scm` to compute the correct version automatically [#3413](https://github.com/opendatateam/udata/pull/3413) [#3434](https://github.com/opendatateam/udata/pull/3434) [#3435](https://github.com/opendatateam/udata/pull/3435) [#3437](https://github.com/opendatateam/udata/pull/3437) [#3438](https://github.com/opendatateam/udata/pull/3438/)
- fix(topic): absolute Topic.uri [#3436](https://github.com/opendatateam/udata/pull/3436)
- feat(topic): add elements activities [#3439](https://github.com/opendatateam/udata/pull/3439)

## 11.0.1 (2025-09-15)

Expand Down
58 changes: 57 additions & 1 deletion udata/core/topic/activities.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from flask_security import current_user

from udata.core.topic.models import TopicElement
from udata.i18n import lazy_gettext as _
from udata.models import Activity, Topic, db

__all__ = ("UserCreatedTopic", "UserUpdatedTopic", "TopicRelatedActivity")
__all__ = (
"UserCreatedTopic",
"UserUpdatedTopic",
"UserCreatedTopicElement",
"UserUpdatedTopicElement",
"UserDeletedTopicElement",
"TopicRelatedActivity",
)


class TopicRelatedActivity(object):
Expand All @@ -23,6 +31,26 @@ class UserUpdatedTopic(TopicRelatedActivity, Activity):
label = _("updated a topic")


class UserCreatedTopicElement(TopicRelatedActivity, Activity):
key = "topic:element:created"
icon = "fa fa-plus"
badge_type = "success"
label = _("added an element to a topic")


class UserUpdatedTopicElement(TopicRelatedActivity, Activity):
key = "topic:element:updated"
icon = "fa fa-pencil"
label = _("updated an element in a topic")


class UserDeletedTopicElement(TopicRelatedActivity, Activity):
key = "topic:element:deleted"
icon = "fa fa-remove"
badge_type = "error"
label = _("removed an element from a topic")


@Topic.on_create.connect
def on_user_created_topic(topic):
if current_user and current_user.is_authenticated:
Expand All @@ -34,3 +62,31 @@ def on_user_updated_topic(topic, **kwargs):
changed_fields = kwargs.get("changed_fields", [])
if current_user and current_user.is_authenticated:
UserUpdatedTopic.emit(topic, topic.organization, changed_fields)


@TopicElement.on_create.connect
def on_user_created_topic_element(topic_element):
if current_user and current_user.is_authenticated and topic_element.topic:
extras = {"element_id": str(topic_element.id)}
UserCreatedTopicElement.emit(
topic_element.topic, topic_element.topic.organization, extras=extras
)


@TopicElement.on_update.connect
def on_user_updated_topic_element(topic_element, **kwargs):
changed_fields = kwargs.get("changed_fields", [])
if current_user and current_user.is_authenticated and topic_element.topic:
extras = {"element_id": str(topic_element.id)}
UserUpdatedTopicElement.emit(
topic_element.topic, topic_element.topic.organization, changed_fields, extras=extras
)


@TopicElement.on_delete.connect
def on_user_deleted_topic_element(topic_element):
if current_user and current_user.is_authenticated and topic_element.topic:
extras = {"element_id": str(topic_element.id)}
UserDeletedTopicElement.emit(
topic_element.topic, topic_element.topic.organization, extras=extras
)
6 changes: 2 additions & 4 deletions udata/core/topic/apiv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,13 @@ def post(self, topic):
else:
element = TopicElement()
form.populate_obj(element)
element.topic = topic
element.save()
elements.append(element)

if errors:
apiv2.abort(400, errors=errors)

for element in elements:
element.topic = topic
element.save()

Comment thread
maudetes marked this conversation as resolved.
topic.save()

return topic, 201
Expand All @@ -157,6 +154,7 @@ def delete(self, topic):
if not TopicEditPermission(topic).can():
apiv2.abort(403, "Forbidden")

# TODO: should we ignore activity creation here?
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This will create a lot of deleted activities (and probably crash since it is synchronous) if we empty a universe Topic. I'm not sure we should introduce an exception here to prevent that, WDYT?

Emptying a universe crashes anyway 😬 #3423

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm... I think it can be dealt at the same time as #3423 indeed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I changed the TODO wording.

topic.elements.delete()

return None, 204
Expand Down
21 changes: 19 additions & 2 deletions udata/core/topic/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from blinker import Signal
from flask import url_for
from mongoengine.signals import post_delete, post_save

from udata.api_fields import field
from udata.core.activity.models import Auditable
from udata.core.dataset.models import Dataset
from udata.core.linkable import Linkable
from udata.core.owned import Owned, OwnedQuerySet
from udata.core.reuse.models import Reuse
from udata.models import SpatialCoverage, db
Expand All @@ -13,7 +15,7 @@
__all__ = ("Topic", "TopicElement")


class TopicElement(db.Document):
class TopicElement(Auditable, db.Document):
title = field(db.StringField(required=False))
description = field(db.StringField(required=False))
tags = field(db.ListField(db.StringField()))
Expand All @@ -31,9 +33,16 @@ class TopicElement(db.Document):
"auto_create_index_on_save": True,
}

after_save = Signal()
on_create = Signal()
on_update = Signal()
on_delete = Signal()

@classmethod
def post_save(cls, sender, document, **kwargs):
"""Trigger reindex when element is saved"""
# Call parent post_save for Auditable functionality
super().post_save(sender, document, **kwargs)
if document.topic and document.element and hasattr(document.element, "id"):
reindex.delay(*as_task_param(document.element))

Expand All @@ -42,9 +51,10 @@ def post_delete(cls, sender, document, **kwargs):
"""Trigger reindex when element is deleted"""
if document.topic and document.element and hasattr(document.element, "id"):
reindex.delay(*as_task_param(document.element))
cls.on_delete.send(document)


class Topic(db.Datetimed, Auditable, db.Document, Owned):
class Topic(db.Datetimed, Auditable, Linkable, db.Document, Owned):
name = field(db.StringField(required=True))
slug = field(
db.SlugField(max_length=255, required=True, populate_from="name", update=True, follow=True),
Expand Down Expand Up @@ -108,6 +118,13 @@ def self_web_url(self, **kwargs):
# Useful for Discussions to call self_web_url on their `subject`
return None

def self_api_url(self, **kwargs):
return url_for(
"apiv2.topic",
topic=self._link_id(**kwargs),
**self._self_api_url_kwargs(**kwargs),
)


post_save.connect(Topic.post_save, sender=Topic)
post_save.connect(TopicElement.post_save, sender=TopicElement)
Expand Down
22 changes: 22 additions & 0 deletions udata/tests/api/test_activities_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from udata.core.dataset.models import Dataset
from udata.core.reuse.factories import ReuseFactory
from udata.core.reuse.models import Reuse
from udata.core.topic.factories import TopicFactory
from udata.core.topic.models import Topic
from udata.core.user.factories import AdminFactory, UserFactory
from udata.mongo import db
from udata.tests.helpers import assert200, assert400
Expand All @@ -26,6 +28,11 @@ class FakeReuseActivity(Activity):
related_to = db.ReferenceField(Reuse, required=True)


class FakeTopicActivity(Activity):
key = "fakeTopic"
related_to = db.ReferenceField(Topic, required=True)


class ActivityAPITest:
modules = []

Expand Down Expand Up @@ -95,3 +102,18 @@ def test_activity_api_list_with_private(self, api) -> None:
response: TestResponse = api.get(url_for("api.activity"))
assert200(response)
assert len(response.json["data"]) == len(activities)

def test_activity_api_with_topic(self, api) -> None:
"""It should fetch topic activities from the API"""
topic: Topic = TopicFactory()
FakeTopicActivity.objects.create(actor=UserFactory(), related_to=topic)

response: TestResponse = api.get(url_for("api.activity"))
assert200(response)
assert len(response.json["data"]) == 1

activity_data = response.json["data"][0]
assert activity_data["related_to"] == topic.name
assert activity_data["related_to_id"] == str(topic.id)
assert activity_data["related_to_kind"] == "Topic"
assert activity_data["related_to_url"] == topic.self_api_url()
75 changes: 75 additions & 0 deletions udata/tests/apiv2/test_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from udata.core.spatial.factories import SpatialCoverageFactory
from udata.core.spatial.models import spatial_granularities
from udata.core.topic import DEFAULT_PAGE_SIZE
from udata.core.topic.activities import UserCreatedTopicElement, UserUpdatedTopicElement
from udata.core.topic.factories import (
TopicElementDatasetFactory,
TopicElementFactory,
Expand Down Expand Up @@ -591,6 +592,80 @@ def test_clear_elements(self):
topic.reload()
self.assertEqual(len(topic.elements), 0)

def test_add_elements_creates_correct_activity(self):
"""It should create 'created' activities when adding elements via POST"""
owner = self.login()
topic = TopicFactory(owner=owner)
dataset = DatasetFactory()

response = self.post(
url_for("apiv2.topic_elements", topic=topic),
[
{
"title": "A dataset",
"description": "A dataset description",
"element": {"class": "Dataset", "id": dataset.id},
}
],
)
assert response.status_code == 201

created_activities = UserCreatedTopicElement.objects(related_to=topic)
updated_activities = UserUpdatedTopicElement.objects(related_to=topic)

assert len(created_activities) == 1
assert len(updated_activities) == 0

created_activity = created_activities.first()
assert created_activity.actor == owner
assert created_activity.related_to == topic
assert "element_id" in created_activity.extras

def test_topic_api_create_with_elements_creates_correct_activities(self):
"""It should create 'created' activities when creating a topic with elements"""
owner = self.login()
dataset = DatasetFactory()
reuse = ReuseFactory()

data = {
"name": "Test Topic",
"description": "A test topic",
"tags": ["test-tag"],
"elements": [
{
"title": "A dataset element",
"description": "A dataset description",
"element": {"class": "Dataset", "id": str(dataset.id)},
},
{
"title": "A reuse element",
"description": "A reuse description",
"element": {"class": "Reuse", "id": str(reuse.id)},
},
{
"title": "An element without reference",
"description": "No element reference",
"element": None,
},
],
}

response = self.post(url_for("apiv2.topics_list"), data)
self.assert201(response)

topic = Topic.objects.first()

created_activities = UserCreatedTopicElement.objects(related_to=topic)
updated_activities = UserUpdatedTopicElement.objects(related_to=topic)

assert len(created_activities) == 3
assert len(updated_activities) == 0

for activity in created_activities:
assert activity.actor == owner
assert activity.related_to == topic
assert "element_id" in activity.extras


class TopicElementAPITest(APITestCase):
def test_delete_element(self):
Expand Down
69 changes: 68 additions & 1 deletion udata/tests/test_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
from mongoengine.errors import ValidationError

from udata.core.discussions.factories import DiscussionFactory
from udata.core.topic.activities import UserCreatedTopic, UserUpdatedTopic
from udata.core.topic.activities import (
UserCreatedTopic,
UserCreatedTopicElement,
UserDeletedTopicElement,
UserUpdatedTopic,
UserUpdatedTopicElement,
)
from udata.core.topic.factories import (
TopicElementDatasetFactory,
TopicElementFactory,
Expand Down Expand Up @@ -70,6 +76,67 @@ def test_topic_activities(self, api, mocker):
topic.save()
mock_updated.assert_called()

def test_topic_element_activities(self, api, mocker):
# A user must be authenticated for activities to be emitted
user = api.login()
topic = TopicFactory(owner=user)

mock_topic_created = mocker.patch.object(UserCreatedTopic, "emit")
mock_topic_updated = mocker.patch.object(UserUpdatedTopic, "emit")
mock_element_created = mocker.patch.object(UserCreatedTopicElement, "emit")
mock_element_updated = mocker.patch.object(UserUpdatedTopicElement, "emit")
mock_element_deleted = mocker.patch.object(UserDeletedTopicElement, "emit")

# Test TopicElement creation
element = TopicElementDatasetFactory(topic=topic)
mock_element_created.assert_called_once()
mock_topic_created.assert_not_called()
mock_topic_updated.assert_not_called()
mock_element_updated.assert_not_called()
mock_element_deleted.assert_not_called()

call_args = mock_element_created.call_args
assert call_args[0][0] == topic # related_to
assert call_args[0][1] == topic.organization # organization
assert call_args[1]["extras"]["element_id"] == str(element.id)

mock_element_created.reset_mock()

# Test TopicElement update
element.title = "Updated title"
element.extras = {"key": "value"}
element.save()
mock_element_updated.assert_called_once()
mock_topic_created.assert_not_called()
mock_topic_updated.assert_not_called()
mock_element_created.assert_not_called()
mock_element_deleted.assert_not_called()

call_args = mock_element_updated.call_args
assert call_args[0][0] == topic # related_to
assert call_args[0][1] == topic.organization # organization
assert call_args[0][2] == ["title", "extras"] # changed_fields
assert call_args[1]["extras"]["element_id"] == str(element.id)

mock_element_updated.reset_mock()

# Test TopicElement deletion
element_id = element.id
element.delete()

# Deletion should only trigger delete activity
mock_element_deleted.assert_called_once()
mock_element_updated.assert_not_called()
mock_topic_created.assert_not_called()
mock_topic_updated.assert_not_called()
mock_element_created.assert_not_called()

# Verify delete activity arguments
delete_call_args = mock_element_deleted.call_args
assert delete_call_args[0][0] == topic # related_to
assert delete_call_args[0][1] == topic.organization # organization
assert delete_call_args[1]["extras"]["element_id"] == str(element_id)

def test_topic_element_wrong_class(self):
# use a model instance that is not supported
with pytest.raises(ValidationError):
Expand Down