diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bdb8cf803c2f5129b43387b48b3dd2eb8f234cf4..760fe0af70242cb26200bef74122e564eb1ce187 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -153,7 +153,7 @@ review_smoketest: review_destroy: stage: destroy image: - name: 'europe-north1-docker.pkg.dev/holi-shared/docker-hub-remote/hashicorp/terraform:1.11.1' + name: 'europe-north1-docker.pkg.dev/holi-shared/docker-hub-remote/hashicorp/terraform:1.11.2' # default entrypoint is terraform command, but we want to run shell scripts entrypoint: ['/bin/sh', '-c'] variables: diff --git a/.terraform-version b/.terraform-version index 720c7384c6195b916c795feaddb08bbe023d183c..ca7176690dd6f501842f3ef4b70bb32118edb489 100644 --- a/.terraform-version +++ b/.terraform-version @@ -1 +1 @@ -1.11.1 +1.11.2 diff --git a/feed_posts/admin.py b/feed_posts/admin.py index b7c79759eb78706791521c01fa1318f2d6050956..27c266e4d52616708be70358e2b32499962e92ee 100644 --- a/feed_posts/admin.py +++ b/feed_posts/admin.py @@ -16,6 +16,7 @@ class FeedPostAdminModel(admin.ModelAdmin): "image", "visibility", "display_topics", + "display_topics_v2", "language_code", "creator", "display_category", @@ -31,6 +32,9 @@ class FeedPostAdminModel(admin.ModelAdmin): def display_topics(self, obj): return ", ".join([topic.title for topic in obj.topics.all()]) + def display_topics_v2(self, obj): + return ", ".join(obj.topics_v2) + def display_category(self, obj): return obj.category.title if obj.category else "" @@ -38,6 +42,7 @@ class FeedPostAdminModel(admin.ModelAdmin): return obj.creator.role == UserRole.CO_FOUNDER display_topics.short_description = "Topics" + display_topics_v2.short_description = "Topics V2" display_category.short_description = "Category" display_is_co_founder.short_description = "Co-founder's Post" diff --git a/feed_posts/migrations/0018_feedpost_topics_v2.py b/feed_posts/migrations/0018_feedpost_topics_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..ae424501e0f940d96a70fb93072a7f6a627e3093 --- /dev/null +++ b/feed_posts/migrations/0018_feedpost_topics_v2.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.12 on 2025-02-28 10:00 + +import django.contrib.postgres.fields +from django.db import migrations, models + +from openbook_terms.helpers import topics_to_topic_v2_slugs + + +def migrate_feed_post_topics(apps, schema_editor): + FeedPost = apps.get_model("feed_posts", "FeedPost") + + for post in FeedPost.objects.all(): + old_topics = post.topics.all() + new_topics = topics_to_topic_v2_slugs(old_topics) + + post.topics_v2 = new_topics + post.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("feed_posts", "0017_feedconfig"), + ] + + operations = [ + migrations.AddField( + model_name="feedpost", + name="topics_v2", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), blank=True, default=list, size=None + ), + ), + migrations.RunPython(migrate_feed_post_topics), + ] diff --git a/feed_posts/migrations/0019_feedpost_feed_posts__topics__9ffb13_gin.py b/feed_posts/migrations/0019_feedpost_feed_posts__topics__9ffb13_gin.py new file mode 100644 index 0000000000000000000000000000000000000000..be6eda3d131e514b19e476ee7a37a9884640b7af --- /dev/null +++ b/feed_posts/migrations/0019_feedpost_feed_posts__topics__9ffb13_gin.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.12 on 2025-02-28 15:49 + +import django.contrib.postgres.indexes +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("feed_posts", "0018_feedpost_topics_v2"), + ("openbook_common", "0011_delete_featuretoggle"), + ("openbook_terms", "0010_delete_inspirationpostcategory"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="feedpost", + index=django.contrib.postgres.indexes.GinIndex( + fields=["topics_v2"], name="feed_posts__topics__9ffb13_gin" + ), + ), + ] diff --git a/feed_posts/migrations/tests/test_0018_feedpost_topics_v2.py b/feed_posts/migrations/tests/test_0018_feedpost_topics_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..e661fee6ea71aa454fa6e91081144af1ef7f4603 --- /dev/null +++ b/feed_posts/migrations/tests/test_0018_feedpost_topics_v2.py @@ -0,0 +1,25 @@ +from importlib import import_module +import pytest +from django.apps import apps +from django.db import connection +from feed_posts.models import FeedPost +from openbook_common.tests.helpers import make_feed_post, make_topic + +# Required because of file name starting with a number +migration = import_module("feed_posts.migrations.0018_feedpost_topics_v2") + + +@pytest.mark.django_db +class TestMigrateFeedPostTopic: + def test_migrate_feedpost_topics(self): + # GIVEN a feed post with only V1 topics + topics_v1 = [make_topic(slug="eco-tourism"), make_topic(slug="elderly-people")] + feed_post = make_feed_post() + feed_post.topics.set(topics_v1) + + # WHEN migrating feed post topics from V1 to V2 + migration.migrate_feed_post_topics(apps, connection.schema_editor()) + + # THEN the feed post has mapped V2 topics + updated_feed_post = FeedPost.objects.get(id=feed_post.id) + assert sorted(updated_feed_post.topics_v2) == sorted(["lifestyle-consumption", "elderly-care"]) diff --git a/feed_posts/models.py b/feed_posts/models.py index 8b5f6f3c6dd991264f770cabf0d0c89823a3223c..8621461375e0bc0046684fc4e166773dd8c35505 100644 --- a/feed_posts/models.py +++ b/feed_posts/models.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Optional from django.contrib.gis.db import models +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex from django.core.files.storage import default_storage from django.utils import timezone from django.utils.text import slugify @@ -36,6 +38,8 @@ class FeedPost(CommentNotificationMixin, ModelWithUUID): topics = models.ManyToManyField(Topic, related_name="feed_posts", blank=True, db_index=True) + topics_v2 = ArrayField(models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH), blank=True, default=list) + category = models.ForeignKey(FeedPostCategory, on_delete=models.PROTECT, null=True) creator = models.ForeignKey( @@ -86,6 +90,9 @@ class FeedPost(CommentNotificationMixin, ModelWithUUID): help_text="Specifies how many days the post will remain at the top of the feed.", ) + class Meta: + indexes = [GinIndex(fields=["topics_v2"])] + @property def has_priority(self): if ( diff --git a/feed_posts/repositories.py b/feed_posts/repositories.py index 367bad2259f68935ea360115b9069f82f820353e..2e36301316ceec2329a12316789e8b0bc72e6403 100644 --- a/feed_posts/repositories.py +++ b/feed_posts/repositories.py @@ -8,6 +8,8 @@ from openbook_auth.models import User, get_reactions, UserRole from openbook_common.enums import ReactionType from openbook_common.schema.types import GeolocationPointInput from openbook_common.utils.helpers import is_uuid +from openbook_terms.helpers import topics_to_topic_v2_slugs +from openbook_terms.models import Topic class FeedPostRepository: @@ -16,7 +18,8 @@ class FeedPostRepository: title, visibility, description, - topics, + topic_ids, + topics_v2, category, creator, image, @@ -38,6 +41,9 @@ class FeedPostRepository: elif images is not None and len(images) > 0: post_image = images[0]["image"] + if topics_v2 is None: + topics_v2 = [] + feed_post = FeedPost.objects.create( title=title, visibility=visibility, @@ -50,10 +56,14 @@ class FeedPostRepository: geolocation=GeolocationPointInput.to_point(geolocation), priority_duration=priority_duration, image=post_image, + topics_v2=topics_v2, ) - if topics is not None: + if topic_ids is not None: + topics = Topic.objects.filter(id__in=topic_ids) feed_post.topics.set(topics) + feed_post.topics_v2 = topics_to_topic_v2_slugs(topics) + feed_post.save() # This code is required for backward compatibility. Remove it after version 1.30.x if image is not None: @@ -80,7 +90,8 @@ class FeedPostRepository: description=None, visibility=None, category=None, - topics=None, + topic_ids=None, + topics_v2=None, image=None, remove_image=False, link_preview=None, @@ -104,8 +115,14 @@ class FeedPostRepository: if category is not None: post.category = category - if topics is not None: + if topics_v2 is not None: + post.topics_v2 = topics_v2 + + if topic_ids is not None: + topics = Topic.objects.filter(id__in=topic_ids) post.topics.set(topics) + post.topics_v2 = topics_to_topic_v2_slugs(topics) + post.save() # This code is required for backward compatibility. Remove it after version 1.30.x if remove_image: diff --git a/feed_posts/schema/feed_queries.py b/feed_posts/schema/feed_queries.py index cb56b78d47bc089b2a101383fd5f329618f5759a..a6ccfc3a405b86a79a3abdaeddb0af2f63435a79 100644 --- a/feed_posts/schema/feed_queries.py +++ b/feed_posts/schema/feed_queries.py @@ -138,7 +138,7 @@ class Query: .order_by("-created_at", "-reaction_count", "-total_comments") ) - user_interests = user.profile.interests.values_list("id", flat=True) + user_interests_v2 = user.profile.interests_v2 user_connections = user.connections.values_list("target_user_id", flat=True) current_time = timezone.now() @@ -169,10 +169,10 @@ class Query: created__gte=current_time - priority_duration, ), # Feed posts matching user's topics - feed_posts.filter(topics__id__in=user_interests, created_at__gte=current_time - priority_duration), + feed_posts.filter(topics_v2__overlap=user_interests_v2, created_at__gte=current_time - priority_duration), # Space posts matching user's topics space_posts.filter( - community__topics__id__in=user_interests, created__gte=current_time - priority_duration + community__topics_v2__overlap=user_interests_v2, created__gte=current_time - priority_duration ), # Editors posts feed_posts.filter(creator__role=UserRole.EDITOR, created_at__gte=current_time - priority_duration), diff --git a/feed_posts/schema/mutations.py b/feed_posts/schema/mutations.py index 6721a02daf11fde9400fff0d2aa592974b29fa7d..81bfef3d422d1d75b482793b805c048c858e3e45 100644 --- a/feed_posts/schema/mutations.py +++ b/feed_posts/schema/mutations.py @@ -32,6 +32,7 @@ class Mutation: description=data.get("description"), category_id=data.get("category_id"), topic_ids=data.get("topic_ids"), + topics_v2=data.get("topics_v2"), image=data.get("image"), creator=user, language=data.get("language"), @@ -58,6 +59,7 @@ class Mutation: description=data.get("description"), category_id=data.get("category_id"), topic_ids=data.get("topic_ids"), + topics_v2=data.get("topics_v2"), image=data.get("image"), remove_image=is_parameter_null(data, "image"), language=data.get("language"), @@ -71,9 +73,7 @@ class Mutation: @sync_to_async def delete_feed_post(self, info, post_id: uuid.UUID) -> Result: user = verify_authorized_user(info) - FeedPostService().delete_feed_post(user=user, post_id=post_id) - return Result(success=True) @strawberry.mutation diff --git a/feed_posts/schema/types.py b/feed_posts/schema/types.py index 9c59eed79316435133454211378923acd92e4962..fa3caa8fec19135c7274e5d3566d1492786aa9e9 100644 --- a/feed_posts/schema/types.py +++ b/feed_posts/schema/types.py @@ -1,5 +1,5 @@ import uuid -from typing import Optional, List +from typing import Optional, List, Annotated import strawberry import strawberry_django @@ -40,14 +40,20 @@ class FeedPost: description: strawberry.auto location: Optional[str] link_preview: Optional[LinkPreview] - image: Optional[ - str - ] # This field is deprecated. Please use the new images field instead. Remove it after version 1.30.x - image_blurhash: Optional[ - str - ] # This field is deprecated. Please use the new images field instead. Remove it after version 1.30.x + image: Annotated[ + Optional[str], + strawberry.argument(description="This field is deprecated. Please use the new images field instead."), + ] # Can be removed after version 1.30.x + image_blurhash: Annotated[ + Optional[str], + strawberry.argument(description="This field is deprecated. Please use the new images field instead."), + ] # Can be removed after version 1.30.x visibility: VisibilityLevel - topics: Optional[List[Topic]] + topics: Annotated[ + Optional[List[Topic]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] + topics_v2: Optional[List[str]] category: Optional[FeedPostCategory] creator: Optional[User] @@ -119,7 +125,11 @@ class CreateFeedPostInput: strawberry.UNSET ) # This field is deprecated. Please use the new images field instead. Remove it after version 1.30.x visibility: VisibilityLevel - topic_ids: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + topic_ids: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] = strawberry.UNSET + topics_v2: Optional[List[str]] = strawberry.UNSET category_id: Optional[uuid.UUID] = strawberry.UNSET language: Optional[str] = strawberry.UNSET location: Optional[str] = strawberry.UNSET @@ -144,7 +154,11 @@ class UpdateFeedPostInput: strawberry.UNSET ) # This field is deprecated. Please use the new images field instead. Remove it after version 1.30.x visibility: Optional[VisibilityLevel] = strawberry.UNSET - topic_ids: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + topic_ids: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] = strawberry.UNSET + topics_v2: Optional[List[str]] = strawberry.UNSET category_id: Optional[uuid.UUID] = strawberry.UNSET language: Optional[str] = strawberry.UNSET location: Optional[str] = strawberry.UNSET diff --git a/feed_posts/serializers.py b/feed_posts/serializers.py index db68f8b0fe6b69a16ef47b3029e8602edf759225..f05e3bba9faac73735e79d1b21dd8d9b0963c7aa 100644 --- a/feed_posts/serializers.py +++ b/feed_posts/serializers.py @@ -10,7 +10,7 @@ from openbook import settings from openbook_common.serializers import GeolocationPointSerializer from openbook_common.serializers_fields.request import RestrictedImageFileSizeField from openbook_common.types import LanguageCode -from openbook_common.validators import sanitize_text +from openbook_common.validators import sanitize_text, topic_v2_exists class FeedPostImageSerializer(serializers.Serializer): @@ -42,43 +42,39 @@ class CreateFeedPostSerializer(serializers.Serializer): required=False, allow_blank=True, ) - description = serializers.CharField( min_length=settings.FEED_POST_DESCRIPTION_MIN_LENGTH, max_length=settings.FEED_POST_DESCRIPTION_MAX_LENGTH, required=True, validators=[sanitize_text], ) - visibility = serializers.ChoiceField(required=True, choices=VisibilityLevel.choices) - topic_ids = serializers.ListField( max_length=settings.FEED_POST_TOPICS_MAX_AMOUNT, required=False, child=serializers.UUIDField(validators=[validate_topic_exists_by_id]), ) - + topics_v2 = serializers.ListField( + max_length=settings.FEED_POST_TOPICS_MAX_AMOUNT, + required=False, + child=serializers.CharField(validators=[topic_v2_exists]), + ) category_id = serializers.UUIDField( required=False, allow_null=True, validators=[validate_feed_post_category_exists_by_id] ) - image = RestrictedImageFileSizeField( allow_empty_file=False, required=False, max_upload_size=settings.FEED_POST_IMAGE_MAX_SIZE, ) - images = FeedPostImageSerializer(many=True, required=False, allow_null=True) - language = serializers.CharField(max_length=2, required=False, allow_blank=True) - location = serializers.CharField( max_length=settings.APPOINTMENT_LOCATION_MAX_LENGTH, required=False, allow_null=True, allow_blank=True, ) - geolocation = GeolocationPointSerializer(required=False) @@ -92,44 +88,39 @@ class UpdateFeedPostSerializer(serializers.Serializer): max_length=settings.FEED_POST_TITLE_MAX_LENGTH, required=False, ) - description = serializers.CharField( min_length=settings.FEED_POST_DESCRIPTION_MIN_LENGTH, max_length=settings.FEED_POST_DESCRIPTION_MAX_LENGTH, required=False, validators=[sanitize_text], ) - visibility = serializers.ChoiceField(required=False, choices=VisibilityLevel.choices) - topic_ids = serializers.ListField( max_length=settings.FEED_POST_TOPICS_MAX_AMOUNT, required=False, child=serializers.UUIDField(validators=[validate_topic_exists_by_id]), ) - + topics_v2 = serializers.ListField( + max_length=settings.FEED_POST_TOPICS_MAX_AMOUNT, + required=False, + child=serializers.CharField(validators=[topic_v2_exists]), + ) category_id = serializers.UUIDField( required=False, allow_null=True, validators=[validate_feed_post_category_exists_by_id] ) - image = RestrictedImageFileSizeField( allow_empty_file=False, required=False, max_upload_size=settings.FEED_POST_IMAGE_MAX_SIZE, allow_null=True, ) - language = serializers.CharField(max_length=2, required=False, allow_blank=True, default=LanguageCode.ENGLISH) - location = serializers.CharField( max_length=settings.APPOINTMENT_LOCATION_MAX_LENGTH, required=False, allow_null=True, allow_blank=True, ) - geolocation = GeolocationPointSerializer(required=False) - existing_images = FeedPostImageMetadataSerializer(many=True, required=False, allow_null=True) - new_images = FeedPostImageSerializer(many=True, required=False, allow_null=True) diff --git a/feed_posts/services.py b/feed_posts/services.py index 43d7732082f1e231840e7fa88d612e509a8edf00..f107c75b3e7b363bea67c342ad2e496a836b7e41 100644 --- a/feed_posts/services.py +++ b/feed_posts/services.py @@ -9,7 +9,7 @@ from openbook_common.models import LinkPreview from openbook_common.tracking import TrackingEvent, track from openbook_common.utils.helpers import extract_usernames_from_string from openbook_notifications.services import NotificationsService -from openbook_terms.models import FeedPostCategory, Topic +from openbook_terms.models import FeedPostCategory class FeedPostService: @@ -27,6 +27,7 @@ class FeedPostService: image, category_id=None, topic_ids=None, + topics_v2=None, language=None, location=None, geolocation=None, @@ -45,7 +46,8 @@ class FeedPostService: category=category, creator=creator, image=image, - topics=topic_ids, + topic_ids=topic_ids, + topics_v2=topics_v2, link_preview=link_preview, language=language, location=location, @@ -64,7 +66,7 @@ class FeedPostService: "locations": 0 if geolocation is None else 1, "categories": 0 if category is None else 1, "images": 0 if images is None else len(images), - "topics": 0 if topic_ids is None else len(topic_ids), + "topics": len(topic_ids or topics_v2 or []), }, ), ) @@ -91,6 +93,7 @@ class FeedPostService: visibility=None, category_id=None, topic_ids=None, + topics_v2=None, image=None, remove_image=False, language=None, @@ -105,8 +108,6 @@ class FeedPostService: category = FeedPostCategory.objects.get(id=category_id) if category_id else None - topics = Topic.objects.filter(id__in=topic_ids) if topic_ids else None - urls = extract_urls_from_string(description) link_preview = LinkPreview.from_url(urls[0]) if len(urls) > 0 else None @@ -119,7 +120,8 @@ class FeedPostService: description=description, visibility=visibility, category=category, - topics=topics, + topic_ids=topic_ids, + topics_v2=topics_v2, image=image, remove_image=remove_image, link_preview=link_preview, diff --git a/feed_posts/tests/helpers.py b/feed_posts/tests/helpers.py index cb99c26d5c34fc504cf3bb3bedcaf63604dfd4a7..4bb76d267fdd5d0648301d91e3e96df4e27ebf4a 100644 --- a/feed_posts/tests/helpers.py +++ b/feed_posts/tests/helpers.py @@ -23,6 +23,7 @@ def create_feed_post(user, input): topics { id } + topicsV2 creator { id } @@ -68,6 +69,7 @@ def update_feed_post(user, input): topics { id } + topicsV2 creator { id } @@ -175,6 +177,7 @@ def my_posts(user): topics { id } + topicsV2 creator { id } @@ -211,6 +214,7 @@ def posts_by_user_id(user, user_id): topics { id } + topicsV2 creator { id } @@ -248,6 +252,7 @@ def feed_posts(user): topics { id } + topicsV2 creator { id } @@ -282,6 +287,7 @@ def post(user, id_or_slug): topics { id } + topicsV2 creator { id } diff --git a/feed_posts/tests/test_graphql.py b/feed_posts/tests/test_graphql.py index 32d62f8b12418e242fcf5eba17085a6689a07dea..42a3d28b4f2cd73069d3fa684900311dcd211753 100644 --- a/feed_posts/tests/test_graphql.py +++ b/feed_posts/tests/test_graphql.py @@ -27,11 +27,11 @@ from openbook_auth.models import UserRole from openbook_common.enums import ReactionType, ReactionAction, VisibilityType from openbook_common.tests.helpers import ( make_user, - make_topic, make_feed_post_category, simple_jpeg_bytes, make_community, make_feed_post_founder_update_category, + make_topics, ) from openbook_notifications.notifications import PostReactionNotification @@ -54,13 +54,15 @@ class TestFeedPosts(TestCase): cls.yet_another_user = make_user() cls.space = make_community(creator=cls.user) cls.space.add_member(cls.yet_another_user) + topics = make_topics(6) + cls.topics_v2 = [t.slug for t in topics] cls.public_feed_post_input_data = { "title": fake.text(max_nb_chars=settings.FEED_POST_TITLE_MAX_LENGTH), "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.PUBLIC, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(6)], + "topicIds": [str(t.id) for t in topics], "image": SimpleUploadedFile(name="image_1.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -74,7 +76,7 @@ class TestFeedPosts(TestCase): "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.REGISTERED_USERS_ONLY, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(3)], + "topicsV2": cls.topics_v2[:3], "image": SimpleUploadedFile(name="image_2.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -88,7 +90,7 @@ class TestFeedPosts(TestCase): "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.PUBLIC, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(6)], + "topicIds": [str(t.id) for t in topics], "image": SimpleUploadedFile(name="image_3.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -102,7 +104,7 @@ class TestFeedPosts(TestCase): "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.REGISTERED_USERS_ONLY, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(3)], + "topicIds": [str(t.id) for t in topics[:3]], "image": SimpleUploadedFile(name="image_4.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -116,7 +118,7 @@ class TestFeedPosts(TestCase): "description": f"@{cls.another_user.username} @{cls.yet_another_user.username} {fake.text(max_nb_chars=100)}", "visibility": VisibilityLevel.PUBLIC, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(6)], + "topicIds": [str(t.id) for t in topics], "image": SimpleUploadedFile(name="image_1.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -130,7 +132,7 @@ class TestFeedPosts(TestCase): "description": f"@{cls.another_user.username} {fake.text(max_nb_chars=100)}", "visibility": VisibilityLevel.PUBLIC, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(6)], + "topicIds": [str(t.id) for t in topics], "image": SimpleUploadedFile(name="image_1.jpg", content=simple_jpeg_bytes), "location": "Munich, Germany", "geolocation": { @@ -144,7 +146,7 @@ class TestFeedPosts(TestCase): "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.REGISTERED_USERS_ONLY, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(3)], + "topicIds": [str(t.id) for t in topics[:3]], "newImages": [ { "image": SimpleUploadedFile(name="image_2.jpg", content=simple_jpeg_bytes), @@ -165,7 +167,7 @@ class TestFeedPosts(TestCase): "description": fake.text(max_nb_chars=settings.FEED_POST_DESCRIPTION_MAX_LENGTH), "visibility": VisibilityLevel.PUBLIC, "categoryId": str(make_feed_post_category().id), - "topicIds": [str(make_topic().id) for _ in range(6)], + "topicIds": [str(t.id) for t in topics], "image": SimpleUploadedFile(name="image_3.jpg", content=simple_jpeg_bytes), "images": [ { @@ -220,6 +222,7 @@ class TestFeedPosts(TestCase): assert data["location"] == self.public_feed_post_input_data["location"] assert data["geolocation"]["latitude"] == self.public_feed_post_input_data["geolocation"]["latitude"] assert data["geolocation"]["longitude"] == self.public_feed_post_input_data["geolocation"]["longitude"] + assert sorted(data["topicsV2"]) == sorted(self.topics_v2) def test_can_query_my_posts_if_anonymous_user(self): first_mutation_response = create_feed_post(user=self.user, input=self.public_feed_post_input_data) @@ -332,6 +335,7 @@ class TestFeedPosts(TestCase): assert data["visibility"] == self.public_feed_post_input_data["visibility"] assert data["category"]["id"] == self.public_feed_post_input_data["categoryId"] assert len(data["topics"]) == len(self.public_feed_post_input_data["topicIds"]) + assert sorted(data["topicsV2"]) == sorted(self.topics_v2) assert data["creator"]["id"] == str(self.user.pk) assert data["image"] @@ -353,7 +357,7 @@ class TestFeedPosts(TestCase): assert data["description"] == self.registered_users_only_feed_post_input_data["description"] assert data["visibility"] == self.registered_users_only_feed_post_input_data["visibility"] assert data["category"]["id"] == self.registered_users_only_feed_post_input_data["categoryId"] - assert len(data["topics"]) == len(self.registered_users_only_feed_post_input_data["topicIds"]) + assert sorted(data["topicsV2"]) == sorted(self.registered_users_only_feed_post_input_data["topicsV2"]) assert data["creator"]["id"] == str(self.user.pk) assert data["image"] assert data["location"] == self.registered_users_only_feed_post_input_data["location"] @@ -381,6 +385,7 @@ class TestFeedPosts(TestCase): assert data["visibility"] == self.public_feed_post_input_data["visibility"] assert data["category"]["id"] == self.public_feed_post_input_data["categoryId"] assert len(data["topics"]) == len(self.public_feed_post_input_data["topicIds"]) + assert sorted(data["topicsV2"]) == sorted(self.topics_v2) assert data["creator"]["id"] == str(self.user.pk) assert data["image"] assert data["location"] == self.public_feed_post_input_data["location"] @@ -446,6 +451,7 @@ class TestFeedPosts(TestCase): assert data["visibility"] == self.registered_users_only_feed_post_update_input_data["visibility"] assert data["category"]["id"] == self.registered_users_only_feed_post_update_input_data["categoryId"] assert len(data["topics"]) == len(self.registered_users_only_feed_post_update_input_data["topicIds"]) + assert sorted(data["topicsV2"]) == sorted(self.topics_v2[:3]) assert data["creator"]["id"] == str(self.user.pk) assert data["image"] assert len(data["images"]) == 1 @@ -835,14 +841,15 @@ class TestFeedPosts(TestCase): title=input_data["title"], visibility=input_data["visibility"], description=input_data["description"], - topics=input_data["topicIds"], + topic_ids=input_data["topicIds"], + topics_v2=None, category=founder_update_category, creator=self.co_founder, image=input_data["image"], - location=input_data["location"], - geolocation=input_data["geolocation"], link_preview=None, language=None, + location=input_data["location"], + geolocation=input_data["geolocation"], images=None, ) @@ -850,14 +857,15 @@ class TestFeedPosts(TestCase): title=input_data["title"], visibility=input_data["visibility"], description=input_data["description"], - topics=input_data["topicIds"], + topic_ids=input_data["topicIds"], + topics_v2=None, category=regular_category, creator=self.user, image=input_data["image"], - location=input_data["location"], - geolocation=input_data["geolocation"], link_preview=None, language=None, + location=input_data["location"], + geolocation=input_data["geolocation"], images=None, ) diff --git a/openbook_auth/fixtures/users.json b/openbook_auth/fixtures/users.json index b0186e4f8dd61b3a62df010b86aa9f1de4aaaabf..e0acc2ed0a34db9ccce9ac190db4aca3617f62f3 100644 --- a/openbook_auth/fixtures/users.json +++ b/openbook_auth/fixtures/users.json @@ -1,143 +1,168 @@ [ - { - "model": "openbook_auth.User", - "pk": "1", - "fields": { - "username": "9ebfc2c2-2d2b-413d-8bfd-ec30af1b7d49", - "password": "F0OB4R", - "is_superuser": false, - "is_staff": false, - "is_active": true, - "date_joined": "2022-08-25 12:08:16+00", - "email": "oswald_rippin@example.com", - "is_email_verified": false, - "uuid": "9ebfc2c2-2d2b-413d-8bfd-ec30af1b7d49", - "invite_count": 0, - "are_guidelines_accepted": true, - "is_deleted": false, - "visibility": "O" - } - }, - { - "model": "openbook_auth.UserProfile", - "pk": "9829b4fb-9c99-44b5-8568-476be3ae01f9", - "fields": { - "user_id": 1, - "followers_count_visible": false, - "is_of_legal_age": true, - "community_posts_visible": true, - "interests": ["2ebecfaa-3a6d-4f39-9389-28bbbca628c9", "e61f3914-930e-4178-a954-73c7a129bd1f", "6244ff16-823b-4c51-b695-58b1aadab32c"], - "sdgs": ["4c832a1f-9b1d-4bb7-8186-57a8eaacf1ef"], - "skills": ["fcc4a764-d646-4fc6-ba8a-41dc4e224022"], - "name": "Oswald", - "last_name": "Rippin", - "avatar": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/871.jpg", - "engagement_level": "INTERESTED" - } - }, - { - "model": "openbook_auth.User", - "pk": "2", - "fields": { - "username": "5846dd02-e2c9-4350-9e68-e4c79bf02e02", - "password": "F0OB4R", - "is_superuser": false, - "is_staff": false, - "is_active": true, - "date_joined": "2022-08-24 12:08:16+00", - "email": "lamar_maggio@example.com", - "is_email_verified": false, - "uuid": "5846dd02-e2c9-4350-9e68-e4c79bf02e02", - "invite_count": 0, - "are_guidelines_accepted": true, - "is_deleted": false, - "visibility": "O" - } - }, - { - "model": "openbook_auth.UserProfile", - "pk": "4ee7305e-ef9b-42ec-9b85-a4602f01a72e", - "fields": { - "user_id": 2, - "followers_count_visible": false, - "is_of_legal_age": true, - "community_posts_visible": true, - "interests": [], - "sdgs": [], - "skills": [] - } - }, - { - "model": "openbook_auth.User", - "pk": "3", - "fields": { - "username": "a09124b7-cdaf-4922-9c02-10b40b3b1530", - "password": "F0OB4R", - "is_superuser": false, - "is_staff": false, - "is_active": true, - "date_joined": "2022-08-23 12:08:16+00", - "email": "nelda_auer@example.com", - "is_email_verified": false, - "uuid": "a09124b7-cdaf-4922-9c02-10b40b3b1530", - "invite_count": 0, - "are_guidelines_accepted": true, - "is_deleted": false, - "visibility": "O" - } - }, - { - "model": "openbook_auth.UserProfile", - "pk": "46fd9173-5895-4c3e-997d-568f0838ab24", - "fields": { - "user_id": 3, - "followers_count_visible": false, - "is_of_legal_age": true, - "community_posts_visible": true, - "interests": ["9cafd8e2-85ab-4e54-9cca-f4327015648a"], - "sdgs": ["9fbb8ea2-c82d-44cc-867e-2f94032f4d37"], - "skills": ["f8399855-4123-4ed6-9595-e0fb65bc2ade", "8c0e9623-0b29-4203-a4aa-ebfbe5ef39e5"], - "name": "Nelda", - "last_name": "Auer", - "engagement_level": "INTERESTED", - "about_me": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." - } - }, - { - "model": "openbook_auth.User", - "pk": "4", - "fields": { - "username": "d2384748-0151-40d6-8527-0fe182112a6c", - "password": "F0OB4R", - "is_superuser": false, - "is_staff": false, - "is_active": true, - "date_joined": "2022-08-22 12:08:16+00", - "email": "kaitlyn_veum@example.com", - "is_email_verified": false, - "uuid": "d2384748-0151-40d6-8527-0fe182112a6c", - "invite_count": 0, - "are_guidelines_accepted": true, - "is_deleted": false, - "visibility": "O" - } - }, - { - "model": "openbook_auth.UserProfile", - "pk": "ac17e49d-7eeb-4e8f-a34d-dc4ee0921c67", - "fields": { - "user_id": 4, - "followers_count_visible": false, - "is_of_legal_age": true, - "community_posts_visible": true, - "interests": ["3c285e6f-239e-43a5-ae83-927ce8694ca9", "13049222-f742-49f1-8091-760c2100d7c8"], - "sdgs": ["ec00e781-ed8f-4295-9429-28be990bb42d", "681124cc-1acb-4b59-b616-868b48837c74"], - "skills": ["df485e09-9e38-420b-a990-fc94211e1b84"], - "name": "Kaitlyn", - "last_name": "Veum", - "avatar": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/513.jpg", - "engagement_level": "ENGAGED", - "about_me": "At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." - } - } + { + "model": "openbook_auth.User", + "pk": "1", + "fields": { + "username": "9ebfc2c2-2d2b-413d-8bfd-ec30af1b7d49", + "password": "F0OB4R", + "is_superuser": false, + "is_staff": false, + "is_active": true, + "date_joined": "2022-08-25 12:08:16+00", + "email": "oswald_rippin@example.com", + "is_email_verified": false, + "uuid": "9ebfc2c2-2d2b-413d-8bfd-ec30af1b7d49", + "invite_count": 0, + "are_guidelines_accepted": true, + "is_deleted": false, + "visibility": "O" + } + }, + { + "model": "openbook_auth.UserProfile", + "pk": "9829b4fb-9c99-44b5-8568-476be3ae01f9", + "fields": { + "user_id": 1, + "followers_count_visible": false, + "is_of_legal_age": true, + "community_posts_visible": true, + "interests": [ + "2ebecfaa-3a6d-4f39-9389-28bbbca628c9", + "e61f3914-930e-4178-a954-73c7a129bd1f", + "6244ff16-823b-4c51-b695-58b1aadab32c" + ], + "interests_v2": [ + "food-hunger-relief", + "mental-physical-health", + "climate-environment" + ], + "sdgs": ["4c832a1f-9b1d-4bb7-8186-57a8eaacf1ef"], + "skills": ["fcc4a764-d646-4fc6-ba8a-41dc4e224022"], + "skills_v2": ["fundraising"], + "name": "Oswald", + "last_name": "Rippin", + "avatar": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/871.jpg", + "engagement_level": "INTERESTED" + } + }, + { + "model": "openbook_auth.User", + "pk": "2", + "fields": { + "username": "5846dd02-e2c9-4350-9e68-e4c79bf02e02", + "password": "F0OB4R", + "is_superuser": false, + "is_staff": false, + "is_active": true, + "date_joined": "2022-08-24 12:08:16+00", + "email": "lamar_maggio@example.com", + "is_email_verified": false, + "uuid": "5846dd02-e2c9-4350-9e68-e4c79bf02e02", + "invite_count": 0, + "are_guidelines_accepted": true, + "is_deleted": false, + "visibility": "O" + } + }, + { + "model": "openbook_auth.UserProfile", + "pk": "4ee7305e-ef9b-42ec-9b85-a4602f01a72e", + "fields": { + "user_id": 2, + "followers_count_visible": false, + "is_of_legal_age": true, + "community_posts_visible": true, + "interests": [], + "interests_v2": [], + "sdgs": [], + "skills": [], + "skills_v2": [] + } + }, + { + "model": "openbook_auth.User", + "pk": "3", + "fields": { + "username": "a09124b7-cdaf-4922-9c02-10b40b3b1530", + "password": "F0OB4R", + "is_superuser": false, + "is_staff": false, + "is_active": true, + "date_joined": "2022-08-23 12:08:16+00", + "email": "nelda_auer@example.com", + "is_email_verified": false, + "uuid": "a09124b7-cdaf-4922-9c02-10b40b3b1530", + "invite_count": 0, + "are_guidelines_accepted": true, + "is_deleted": false, + "visibility": "O" + } + }, + { + "model": "openbook_auth.UserProfile", + "pk": "46fd9173-5895-4c3e-997d-568f0838ab24", + "fields": { + "user_id": 3, + "followers_count_visible": false, + "is_of_legal_age": true, + "community_posts_visible": true, + "interests": ["9cafd8e2-85ab-4e54-9cca-f4327015648a"], + "interests_v2": ["gender-equality"], + "sdgs": ["9fbb8ea2-c82d-44cc-867e-2f94032f4d37"], + "skills": [ + "f8399855-4123-4ed6-9595-e0fb65bc2ade", + "8c0e9623-0b29-4203-a4aa-ebfbe5ef39e5" + ], + "skills_v2": ["finances-taxes", "translation"], + "name": "Nelda", + "last_name": "Auer", + "engagement_level": "INTERESTED", + "about_me": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." + } + }, + { + "model": "openbook_auth.User", + "pk": "4", + "fields": { + "username": "d2384748-0151-40d6-8527-0fe182112a6c", + "password": "F0OB4R", + "is_superuser": false, + "is_staff": false, + "is_active": true, + "date_joined": "2022-08-22 12:08:16+00", + "email": "kaitlyn_veum@example.com", + "is_email_verified": false, + "uuid": "d2384748-0151-40d6-8527-0fe182112a6c", + "invite_count": 0, + "are_guidelines_accepted": true, + "is_deleted": false, + "visibility": "O" + } + }, + { + "model": "openbook_auth.UserProfile", + "pk": "ac17e49d-7eeb-4e8f-a34d-dc4ee0921c67", + "fields": { + "user_id": 4, + "followers_count_visible": false, + "is_of_legal_age": true, + "community_posts_visible": true, + "interests": [ + "3c285e6f-239e-43a5-ae83-927ce8694ca9", + "13049222-f742-49f1-8091-760c2100d7c8" + ], + "interests_v2": ["animal-welfare", "climate-environment"], + "sdgs": [ + "ec00e781-ed8f-4295-9429-28be990bb42d", + "681124cc-1acb-4b59-b616-868b48837c74" + ], + "skills": ["df485e09-9e38-420b-a990-fc94211e1b84"], + "skills_v2": ["research-analysis"], + "name": "Kaitlyn", + "last_name": "Veum", + "avatar": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/513.jpg", + "engagement_level": "ENGAGED", + "about_me": "At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + } ] diff --git a/openbook_auth/migrations/0035_userprofile_interests_v2_userprofile_skills_v2.py b/openbook_auth/migrations/0035_userprofile_interests_v2_userprofile_skills_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..af94d848729699c6a1a5683b83304a206bc393fd --- /dev/null +++ b/openbook_auth/migrations/0035_userprofile_interests_v2_userprofile_skills_v2.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.8 on 2025-02-24 16:15 + +import django.contrib.postgres.fields +from django.db import migrations, models + +from openbook_terms.helpers import topics_to_topic_v2_slugs, skills_to_skill_v2_slugs + + +def migrate_topics_and_skills(apps, schema_editor): + UserProfile = apps.get_model("openbook_auth", "UserProfile") + + for user_profile in UserProfile.objects.all(): + old_interests = user_profile.interests.all() + new_interests = topics_to_topic_v2_slugs(old_interests) + + old_skills = user_profile.skills.all() + new_skills = skills_to_skill_v2_slugs(old_skills) + + user_profile.interests_v2 = new_interests + user_profile.skills_v2 = new_skills + user_profile.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("openbook_auth", "0034_remove_userprofile_cover_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="interests_v2", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), blank=True, default=list, size=None + ), + ), + migrations.AddField( + model_name="userprofile", + name="skills_v2", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), blank=True, default=list, size=None + ), + ), + migrations.RunPython(migrate_topics_and_skills), + ] diff --git a/openbook_auth/migrations/tests/__init__.py b/openbook_auth/migrations/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openbook_auth/migrations/tests/test_0035_userprofile_interests_v2_userprofile_skills_v2.py b/openbook_auth/migrations/tests/test_0035_userprofile_interests_v2_userprofile_skills_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..aef6c38bebb9feb0f94178878afc3785b9f53bb4 --- /dev/null +++ b/openbook_auth/migrations/tests/test_0035_userprofile_interests_v2_userprofile_skills_v2.py @@ -0,0 +1,26 @@ +from importlib import import_module +import pytest +from django.apps import apps +from django.db import connection +from openbook_auth.models import User +from openbook_common.tests.helpers import make_user, make_topic, make_skill +# Required because of file name starting with a number +migration = import_module('openbook_auth.migrations.0035_userprofile_interests_v2_userprofile_skills_v2') + +@pytest.mark.django_db +class TestMigrateUserProfileTopicsAndSkills: + def test_migrate_topics_and_skills(self): + # GIVEN a user with only V1 interests and skills + interests_v1 = [make_topic(slug="eco-tourism"), make_topic(slug="elderly-people")] + skills_v1 = [make_skill(slug="writing-translation"), make_skill(slug="assertiveness")] + user = make_user(interests=[], skills=[], interests_v2=[], skills_v2=[]) + user.profile.interests.set(interests_v1) + user.profile.skills.set(skills_v1) + + # WHEN migrating user profile interests and skills from V1 to V2 + migration.migrate_topics_and_skills(apps, connection.schema_editor()) + + # THEN the user has mapped V2 interests and skills + updated_profile = User.objects.get(username=user.username).profile + assert sorted(updated_profile.interests_v2) == sorted(["lifestyle-consumption", "elderly-care"]) + assert sorted(updated_profile.skills_v2) == sorted(["translation", "other"]) diff --git a/openbook_auth/models.py b/openbook_auth/models.py index 905f06fe21099b34a348bc16af56f0fde5c9235a..b19fc5fde736a6e11c499d9197bb447662a6d942 100644 --- a/openbook_auth/models.py +++ b/openbook_auth/models.py @@ -5,14 +5,16 @@ import secrets from datetime import datetime, timedelta from enum import Enum from itertools import chain -from typing import Iterable, Set, Callable, Type, Optional +from typing import Iterable, Set, Callable, Type, Optional, List from uuid import UUID import strawberry +from openbook_terms.helpers import topics_to_topic_v2_slugs, skills_to_skill_v2_slugs from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.auth.validators import UnicodeUsernameValidator from django.contrib.contenttypes.fields import GenericRelation from django.contrib.gis.db import models +from django.contrib.postgres.fields import ArrayField from django.core import validators from django.core.cache import caches from django.core.mail import EmailMultiAlternatives @@ -38,6 +40,8 @@ from openbook_common.enums import VisibilityType from openbook_common.helpers import ExifRotate, generate_blurhash, PrefixedQ from openbook_common.models import Badge, ModelWithUUID from openbook_common.schema.types import Paged +from openbook_common.skills import slug_to_skill_v2 +from openbook_common.topics import slug_to_topic_v2 from openbook_common.types import GeolocationFeature, ReactableType from openbook_common.utils.helpers import delete_file_field from openbook_common.utils.model_loaders import ( @@ -788,9 +792,11 @@ class User(ModelWithUUID, AbstractUser): last_name=None, pronouns=None, engagement_level=None, - interests=None, + interests: Optional[List[Topic]] = None, + interests_v2: Optional[List[str]] = None, sdgs=None, - skills=None, + skills: Optional[List[Skill]] = None, + skills_v2: Optional[List[str]] = None, save=True, update_location=False, remove_avatar=False, @@ -851,11 +857,19 @@ class User(ModelWithUUID, AbstractUser): if engagement_level: self.profile.engagement_level = engagement_level if interests is not None: - self.profile.interests.set(interests) + updated_interests = Topic.objects.filter(id__in=interests) + self.profile.interests.set(updated_interests) + self.profile.interests_v2 = topics_to_topic_v2_slugs(updated_interests) + elif interests_v2 is not None: + self.profile.interests_v2 = interests_v2 if sdgs is not None: self.profile.sdgs.set(sdgs) if skills is not None: - self.profile.skills.set(skills) + updated_skills = Skill.objects.filter(id__in=skills) + self.profile.skills.set(updated_skills) + self.profile.skills_v2 = skills_to_skill_v2_slugs(updated_skills) + elif skills_v2 is not None: + self.profile.skills_v2 = skills_v2 if remove_avatar: self._delete_profile_avatar(save=False) @@ -1721,6 +1735,7 @@ class User(ModelWithUUID, AbstractUser): invites_enabled=None, location=None, topic_ids=None, + topics_v2=None, ): Community = get_community_model() community = Community.create_community( @@ -1738,6 +1753,7 @@ class User(ModelWithUUID, AbstractUser): invites_enabled=invites_enabled, location=location, topic_ids=topic_ids, + topics_v2=topics_v2, ) return community @@ -1803,6 +1819,7 @@ class User(ModelWithUUID, AbstractUser): categories_names=None, invites_enabled=None, topic_ids=None, + topics_v2=None, cover=None, remove_cover=False, avatar=None, @@ -1830,6 +1847,7 @@ class User(ModelWithUUID, AbstractUser): categories_names=categories_names, invites_enabled=invites_enabled, topic_ids=topic_ids, + topics_v2=topics_v2, cover=cover, remove_cover=remove_cover, avatar=avatar, @@ -1856,6 +1874,7 @@ class User(ModelWithUUID, AbstractUser): categories_names=None, invites_enabled=None, topic_ids=None, + topics_v2=None, cover=None, remove_cover=False, avatar=None, @@ -1886,6 +1905,7 @@ class User(ModelWithUUID, AbstractUser): categories_names=categories_names, invites_enabled=invites_enabled, topic_ids=topic_ids, + topics_v2=topics_v2, cover=cover, remove_cover=remove_cover, avatar=avatar, @@ -4938,6 +4958,7 @@ class User(ModelWithUUID, AbstractUser): regularity=None, contact_phone=None, skills=None, + skills_v2=None, thumbnail=None, ): Community = get_community_model() @@ -4961,6 +4982,7 @@ class User(ModelWithUUID, AbstractUser): regularity=regularity, contact_phone=contact_phone, skills=skills, + skills_v2=skills_v2, thumbnail=thumbnail, ) @@ -4978,7 +5000,8 @@ class User(ModelWithUUID, AbstractUser): geolocation=None, regularity=None, contact_phone=None, - skills=None, + skill_ids=None, + skills_v2=None, thumbnail=None, remove_thumbnail=False, ): @@ -5000,7 +5023,8 @@ class User(ModelWithUUID, AbstractUser): geolocation=geolocation, regularity=regularity, contact_phone=contact_phone, - skills=skills, + skill_ids=skill_ids, + skills_v2=skills_v2, thumbnail=thumbnail, remove_thumbnail=remove_thumbnail, ) @@ -5198,8 +5222,10 @@ class UserProfile(ModelWithUUID): null=True, ) interests = models.ManyToManyField(Topic, blank=True) + interests_v2 = ArrayField(models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH), blank=True, default=list) sdgs = models.ManyToManyField(SDG, blank=True) skills = models.ManyToManyField(Skill, blank=True) + skills_v2 = ArrayField(models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH), blank=True, default=list) is_of_legal_age = models.BooleanField(default=False) avatar = ProcessedImageField( verbose_name=_("avatar"), diff --git a/openbook_auth/schema/mutations.py b/openbook_auth/schema/mutations.py index 7728ec0804c9bcfabc40e650cf80b31bfd9265d7..272889f7c657faaee6e112e598b1b6745da4dc75 100644 --- a/openbook_auth/schema/mutations.py +++ b/openbook_auth/schema/mutations.py @@ -35,7 +35,7 @@ from openbook.graphql_errors import GraphQLErrorCodes from openbook_common.helpers import is_parameter_null, to_input_dict from openbook_common.schema.types import Result from openbook_common.utils.model_loaders import get_insight_model, get_community_model, get_topic_model -from openbook_communities.schema.helpers import create_filtered_spaces_query +from openbook_communities.schema.helpers import create_filtered_spaces_query, DISCOVER_DEFAULT_SPACES_V2 from openbook_notifications.api import NotificationApi from openbook_notifications.enums import NotificationProvider from openbook_devices.serializers import RegisterDeviceSerializer @@ -55,9 +55,9 @@ def _capture_user_fields(user: UserModel) -> Dict[str, str]: "aboutMe": user.profile.about_me, "pronouns": user.profile.pronouns, "engagementLevel": user.profile.engagement_level, - "interests": set(user.profile.interests.values_list("id", flat=True)), + "interests": set(user.profile.interests.values_list("id", flat=True)) | set(user.profile.interests_v2), "sdgs": set(user.profile.sdgs.values_list("id", flat=True)), - "skills": set(user.profile.skills.values_list("id", flat=True)), + "skills": set(user.profile.skills.values_list("id", flat=True)) | set(user.profile.skills_v2), "avatar": user.profile.avatar.url if user.profile.avatar else None, } @@ -106,8 +106,10 @@ class Mutation: update_location=input.location is not strawberry.UNSET or input.geolocation is not strawberry.UNSET, engagement_level=data.get("engagement_level"), interests=data.get("interests"), + interests_v2=data.get("interests_v2"), sdgs=data.get("sdgs"), skills=data.get("skills"), + skills_v2=data.get("skills_v2"), avatar=data.get("avatar"), remove_avatar=remove_avatar, tracking_consent_analytics=data.get("tracking_consent_analytics"), @@ -137,22 +139,18 @@ class Mutation: topic_model = get_topic_model() whens = [] - topic_ids = list(current_user.profile.interests.values_list("id", flat=True).all()) + topics_v2 = list(current_user.profile.interests_v2) geolocation = current_user.geolocation - if topic_ids: + if topics_v2: whens.append( - When(create_filtered_spaces_query(topic_ids=topic_ids, geolocation=geolocation), then=Value(10)) + When(create_filtered_spaces_query(topics_v2=topics_v2, geolocation=geolocation), then=Value(10)) ) if geolocation: whens.append(When(create_filtered_spaces_query(geolocation=geolocation), then=Value(20))) - default_topics = list( - topic_model.objects.filter(is_discover_spaces_default=True).values_list("id", flat=True) - ) - if default_topics: - whens.append(When(create_filtered_spaces_query(topic_ids=default_topics), then=Value(30))) + whens.append(When(create_filtered_spaces_query(topics_v2=DISCOVER_DEFAULT_SPACES_V2), then=Value(30))) spaces = ( community_model.objects.prefetch_related("links", "memberships__user__profile") diff --git a/openbook_auth/schema/types.py b/openbook_auth/schema/types.py index dfaddd2e7e7d87a1b6b2618fc459134e8e136b9a..e4266c0b3cd9acf801feebe06eb97c8a4b33f0fd 100644 --- a/openbook_auth/schema/types.py +++ b/openbook_auth/schema/types.py @@ -1,6 +1,6 @@ import logging import uuid -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Annotated import strawberry import strawberry_django @@ -33,9 +33,17 @@ class UpdateAuthenticatedUserInput: location: Optional[str] = strawberry.UNSET geolocation: Optional[GeoJSONInput] = strawberry.UNSET engagement_level: Optional[str] = strawberry.UNSET - interests: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + interests: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use interests_v2 instead."), + ] = strawberry.UNSET + interests_v2: Optional[List[str]] = strawberry.UNSET sdgs: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET - skills: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + skills: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use skills_v2 instead."), + ] = strawberry.UNSET + skills_v2: Optional[List[str]] = strawberry.UNSET tracking_consent_analytics: Optional[bool] = strawberry.UNSET tracking_consent_personalization: Optional[bool] = strawberry.UNSET @@ -94,18 +102,30 @@ class User: def engagement_level(self) -> Optional[str]: return self.profile.engagement_level - @strawberry_django.field(select_related=["profile__interests"]) + @strawberry_django.field( + select_related=["profile__interests"], deprecation_reason="Deprecated since 1.52. Use interests_v2 instead." + ) def interests(self) -> List[Optional[Topic]]: return self.profile.interests.all() + @strawberry_django.field(select_related=["profile"]) + def interests_v2(self) -> List[Optional[str]]: + return self.profile.interests_v2 + @strawberry_django.field(select_related=["profile__sdgs"]) def sdgs(self) -> List[Optional[SDG]]: return self.profile.sdgs.all().order_by("number") - @strawberry_django.field(select_related=["profile__skills"]) + @strawberry_django.field( + select_related=["profile__skills"], deprecation_reason="Deprecated since 1.52. Use skills_v2 instead." + ) def skills(self) -> List[Optional[Skill]]: return self.profile.skills.all() + @strawberry_django.field(select_related=["profile"]) + def skills_v2(self) -> List[Optional[str]]: + return self.profile.skills_v2 + @strawberry_django.field() def connection_status_to_myself(self, info) -> Optional[UserConnectionStatus]: user = info.context.request.user diff --git a/openbook_auth/serializers/authenticated_user_serializers.py b/openbook_auth/serializers/authenticated_user_serializers.py index 916dca8a17fffd90da1d81ef70e57b57ebe7f73f..a1613c4762b58a08302da941b29979679078bd3e 100644 --- a/openbook_auth/serializers/authenticated_user_serializers.py +++ b/openbook_auth/serializers/authenticated_user_serializers.py @@ -21,7 +21,13 @@ from openbook_common.serializers_fields.request import ( FriendlyUrlField, RestrictedImageFileSizeField, ) -from openbook_common.validators import name_characters_validator, sanitize_text, url_safety_validator +from openbook_common.validators import ( + name_characters_validator, + sanitize_text, + url_safety_validator, + topic_v2_exists, + skill_v2_exists, +) class UpdateAuthenticatedUserSerializer(serializers.Serializer): @@ -83,6 +89,11 @@ class UpdateAuthenticatedUserSerializer(serializers.Serializer): allow_empty=True, child=serializers.UUIDField(validators=[interest_with_id_exists]), ) + interests_v2 = serializers.ListField( + required=False, + allow_empty=True, + child=serializers.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, validators=[topic_v2_exists]), + ) sdgs = serializers.ListField( required=False, allow_empty=True, @@ -93,6 +104,11 @@ class UpdateAuthenticatedUserSerializer(serializers.Serializer): allow_empty=True, child=serializers.UUIDField(validators=[skill_with_id_exists]), ) + skills_v2 = serializers.ListField( + required=False, + allow_empty=True, + child=serializers.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, validators=[skill_v2_exists]), + ) identity = serializers.CharField( min_length=1, max_length=settings.IDENTITY_MAX_LENGTH, diff --git a/openbook_auth/tests/helpers.py b/openbook_auth/tests/helpers.py index 052554c310c501c8cb30391dbaf88a1572d57582..ec1cc5c01bc3056cb4fda06fd798c95de6aa82b1 100644 --- a/openbook_auth/tests/helpers.py +++ b/openbook_auth/tests/helpers.py @@ -14,19 +14,23 @@ def get_user_with_id_data(user_to_get, calling_user=None): return response.data["userById"] -def get_user_with_id_raw(user_to_get, calling_user=None): +def get_user_by_id_raw(user_to_get, fields: str = "id", calling_user=None): return async_to_sync(schema.execute)( f""" - query {{ - userById(id: "{user_to_get.username}") {{ - id + query {{ + userById(id: "{user_to_get.username}") {{ + {fields} + }} }} - }} - """, + """, context_value=benedict({"request": {"user": calling_user or AnonymousUser}}), ) +def get_user_with_id_raw(user_to_get, calling_user=None): + return get_user_by_id_raw(user_to_get, fields="id", calling_user=calling_user) + + def get_users_by_chat_identities(user: User, identities: List[str]): response = async_to_sync(schema.execute)( """ diff --git a/openbook_auth/tests/test_graphql.py b/openbook_auth/tests/test_graphql.py index d479cdb6d716ebd5c83cdbd7114f0f3d2e55c052..347f40d1155288a1fd45160f593fa94911a8e0ef 100644 --- a/openbook_auth/tests/test_graphql.py +++ b/openbook_auth/tests/test_graphql.py @@ -30,6 +30,7 @@ from openbook_auth.tests.helpers import ( api_update_authenticated_user_names_data, get_user_connection_status_to_space_data, get_user_with_id_raw, + get_user_by_id_raw, get_users_by_chat_identities, get_connected_users, get_connected_users_raw, @@ -525,6 +526,163 @@ class TestUsers: ) self._validate_tracking_event_user_updated(mock_track) + @mock.patch("openbook_auth.tasks.requests.patch") + @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") + @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") + @mock.patch("django.utils.translation.get_language_from_request", return_value="en") + @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") + @mock.patch("openbook_auth.schema.mutations.track") + def test_updating_topics_skills_is_backward_compatible( + self, + mock_track, + mock_create_or_update_subscriber, + mock_get_language_from_request, + mock_publish_user_name_updated_event, + mock_publish_user_updated_event, + mock_patch, + ): + # test exemplary topics and skills, there are separate unit tests for exhaustive testing the + # backwards-compatibility mapping itself + + # GIVEN V1 topic slugs and V1 skill slugs + topics_v1 = [make_topic(slug="biodiversity-animal-welfare"), make_topic(slug="agriculture-food")] + skills_v1 = [make_skill(slug="development-concept"), make_skill(slug="empathy")] + user = make_user(interests=[], skills=[], interests_v2=[], skills_v2=[]) + + # WHEN updating topics and skills of a user using the V1 API + self._update_user( + user, + {"interests": [str(topic.id) for topic in topics_v1], "skills": [str(skill.id) for skill in skills_v1]}, + ) + + # THEN they are updated for the V1 API + response = get_user_by_id_raw( + user, + fields=""" + interests { slug } + skills { slug } + interestsV2 + skillsV2 + """, + ) + user_data = response.data["userById"] + + assert len(user_data["interests"]) == 2 + assert sorted([interest["slug"] for interest in user_data["interests"]]) == [ + "agriculture-food", + "biodiversity-animal-welfare", + ] + + assert len(user_data["skills"]) == 2 + assert sorted([skill["slug"] for skill in user_data["skills"]]) == ["development-concept", "empathy"] + + # AND THEN they are updated in a backwards-compatible way in the V2 API + assert len(user_data["interestsV2"]) == 2 + assert sorted(user_data["interestsV2"]) == ["animal-welfare", "food-hunger-relief"] + assert len(user_data["skillsV2"]) == 2 + assert sorted(user_data["skillsV2"]) == ["creativity-design", "other"] + + @mock.patch("openbook_auth.tasks.requests.patch") + @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") + @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") + @mock.patch("django.utils.translation.get_language_from_request", return_value="en") + @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") + @mock.patch("openbook_auth.schema.mutations.track") + def test_updating_topics_skills_v2( + self, + mock_track, + mock_create_or_update_subscriber, + mock_get_language_from_request, + mock_publish_user_name_updated_event, + mock_publish_user_updated_event, + mock_patch, + ): + # GIVEN V2 topic slugs and V2 skill slugs + topics_v2 = ["human-rights", "literacy-education"] + skills_v2 = ["legal-assistance", "nursing-caregiving"] + user = make_user(interests=[], skills=[], interests_v2=[], skills_v2=[]) + + # WHEN updating topics and skills of a user using the V2 API + self._update_user( + user, + {"interestsV2": topics_v2, "skillsV2": skills_v2}, + ) + + # THEN they are updated for the V2 API + response = get_user_by_id_raw( + user, + fields=""" + interests { slug } + skills { slug } + interestsV2 + skillsV2 + """, + ) + user_data = response.data["userById"] + + assert len(user_data["interestsV2"]) == 2 + assert user_data["interestsV2"] == topics_v2 + assert len(user_data["skillsV2"]) == 2 + assert user_data["skillsV2"] == skills_v2 + + # AND THEN they are NOT updated in the V1 API + assert len(user_data["interests"]) == 0 + assert len(user_data["skills"]) == 0 + + @mock.patch("openbook_auth.tasks.requests.patch") + @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") + @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") + @mock.patch("django.utils.translation.get_language_from_request", return_value="en") + @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber") + @mock.patch("openbook_auth.schema.mutations.track") + def test_validating_topics_skills_v2( + self, + mock_track, + mock_create_or_update_subscriber, + mock_get_language_from_request, + mock_publish_user_name_updated_event, + mock_publish_user_updated_event, + mock_patch, + ): + # GIVEN V2 topic slugs and V2 skill slugs with invalid values + topics_v2 = ["literacy-education", "invalid"] + skills_v2 = ["unknown", "nursing-caregiving"] + user = make_user() + + # WHEN updating topics and skills of a user using the V2 API + request = HttpRequest() + request.COOKIES = {} + request.user = user + response = async_to_sync(schema.execute)( + """ + mutation updateAuthenticatedUserV2($input: UpdateAuthenticatedUserInput!) { + updateAuthenticatedUserV2(input: $input) { id } + } + """, + context_value=benedict({"request": request}), + variable_values={"input": {"interestsV2": topics_v2, "skillsV2": skills_v2}}, + ) + + # THEN the request fails + assert response.data is None + assert response.errors is not None + + # noinspection PyMethodMayBeStatic + def _update_user(self, user: User, inputs: Dict[str, any]) -> None: + request = HttpRequest() + request.COOKIES = {} + request.user = user + executed = async_to_sync(schema.execute)( + """ + mutation updateAuthenticatedUserV2($input: UpdateAuthenticatedUserInput!) { + updateAuthenticatedUserV2(input: $input) { id } + } + """, + context_value=benedict({"request": request}), + variable_values={"input": inputs}, + ) + assert executed.errors is None + @mock.patch("openbook_auth.tasks.requests.patch") @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event") @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event") @@ -542,57 +700,50 @@ class TestUsers: ): response = self.create_response(200) mock_patch.return_value = response - topic = make_topic() - skill = make_skill() + topic = make_topic(slug="animal-welfare") + topic_v2 = "arts-culture" + + skill = make_skill(slug="animal-care") + skill_v2 = "arts-crafts" + sdg = make_sdg() user = make_user(name="John", last_name="Doe", engagement_level=EngagementLevelChoice.ENGAGED.value) - def update_user(inputs: Dict[str, any]) -> None: - request = HttpRequest() - request.COOKIES = {} - request.user = user - executed = async_to_sync(schema.execute)( - """ - mutation updateAuthenticatedUserV2($input: UpdateAuthenticatedUserInput!) { - updateAuthenticatedUserV2(input: $input) { id } - } - """, - context_value=benedict({"request": request}), - variable_values={"input": inputs}, - ) - assert executed.errors is None - - update_user({}) + self._update_user(user, {}) mock_track.assert_not_called() mock_track.reset_mock() # should not contain updated_fields if field values didn't change - update_user({"firstName": "John", "lastName": "Doe"}) + self._update_user(user, {"firstName": "John", "lastName": "Doe"}) mock_track.assert_not_called() mock_track.reset_mock() - update_user({"firstName": "Johnny", "lastName": "Doey", "aboutMe": "All about me"}) + self._update_user(user, {"firstName": "Johnny", "lastName": "Doey", "aboutMe": "All about me"}) self._validate_tracking_event_user_updated(mock_track, {"firstName", "lastName", "aboutMe"}) mock_track.reset_mock() - update_user({"location": "New York", "geolocation": geolocation}) + self._update_user(user, {"location": "New York", "geolocation": geolocation}) self._validate_tracking_event_user_updated(mock_track, {"location", "geolocation"}) mock_track.reset_mock() - update_user({"pronouns": "They/Them", "engagementLevel": EngagementLevelChoice.INITIATOR.value}) + self._update_user(user, {"pronouns": "They/Them", "engagementLevel": EngagementLevelChoice.INITIATOR.value}) self._validate_tracking_event_user_updated(mock_track, {"pronouns", "engagementLevel"}) mock_track.reset_mock() - update_user({"interests": [str(topic.id)], "sdgs": [str(sdg.id)], "skills": [str(skill.id)]}) + self._update_user(user, {"interests": [str(topic.id)], "sdgs": [str(sdg.id)], "skills": [str(skill.id)]}) self._validate_tracking_event_user_updated(mock_track, {"interests", "sdgs", "skills"}) mock_track.reset_mock() + self._update_user(user, {"interestsV2": [topic_v2], "skillsV2": [skill_v2]}) + self._validate_tracking_event_user_updated(mock_track, {"interests", "skills"}) + mock_track.reset_mock() + current_dir = os.path.dirname(os.path.abspath(__file__)) test_image_path = os.path.join(current_dir, "test_avatar.jpg") with open(test_image_path, "rb") as image_file: image_content = image_file.read() image = SimpleUploadedFile(name="test_avatar.jpg", content=image_content, content_type="image/jpeg") - update_user({"avatar": image}) + self._update_user(user, {"avatar": image}) self._validate_tracking_event_user_updated(mock_track, {"avatar"}) mock_track.reset_mock() diff --git a/openbook_common/skills.py b/openbook_common/skills.py new file mode 100644 index 0000000000000000000000000000000000000000..bd050ff4f53fbc2bc17fcbc91e9a3f5da7921149 --- /dev/null +++ b/openbook_common/skills.py @@ -0,0 +1,114 @@ +from typing import Optional, Literal + +# keys are the V2 skill slugs, values is a combined list of V1 and V2 skill slugs (for backwards-compatibility) +skill_v2_slugs = { + "administration": ["administration", "organizational-skills"], + "animal-care": ["animal-care"], + "arts-crafts": ["arts-crafts", "arts-music-culture"], + "building-handicraft": ["building-handicraft"], + "childcare": ["childcare"], + "community-organizing": ["community-organizing"], + "creativity-design": ["creativity-design", "creativity", "design", "development-concept"], + "driving": ["driving", "drive-transport"], + "emergency-care": ["emergency-care"], + "event-planning": ["event-planning"], + "finances-taxes": ["finances-taxes"], + "food-service": ["food-service", "cooking-baking"], + "fundraising": ["fundraising"], + "gardening-conservation": ["gardening-conservation"], + "intercultural-competence": ["intercultural-competence", "inclusive-mindset-intercultural-competence"], + "it-technology": ["it-technology"], + "legal-assistance": ["legal-assistance", "law-regulations"], + "management-strategy": ["management-strategy"], + "marketing-communications": [ + "marketing-communications", + "marketing-social-media", + "communication-skills", + "public-relations", + ], + "moderation-facilitation": ["moderation-facilitation"], + "music-performance": ["music-performance"], + "nursing-caregiving": ["nursing-caregiving", "caring-nursing"], + "other": [ + "other", + "environmental-awareness", + "commitment", + "conflict-management", + "discipline", + "empathy", + "hands-on-support", + "holistic-thinking", + "humour", + "independence", + "initiative-and-energy", + "mindfulness", + "motivation", + "negotiation-skills", + "openness", + "relationship-skills", + "resilience", + "self-confident", + "solution-orientedness", + "willingness-to-learn", + "adaptability", + "visionary-thinking", + "assertiveness", + ], + "religious-spiritual-care": ["religious-spiritual-care"], + "research-analysis": ["research-analysis", "critical-thinking", "analytical-thinking", "knowledge-management"], + "sales-distribution": ["sales-distribution"], + "team-leadership": ["team-leadership", "coordination-coordination-of-volunteers", "leadership", "teamwork"], + "translation": ["translation", "writing-translation"], + "tutoring-coaching": ["tutoring-coaching", "teaching-education", "coaching & mentoring"], +} + +SkillV2 = Literal[ + "administration", + "animal-care", + "arts-crafts", + "building-handicraft", + "childcare", + "community-organizing", + "creativity-design", + "driving", + "emergency-care", + "event-planning", + "finances-taxes", + "food-service", + "fundraising", + "gardening-conservation", + "intercultural-competence", + "it-technology", + "legal-assistance", + "management-strategy", + "marketing-communications", + "moderation-facilitation", + "music-performance", + "nursing-caregiving", + "other", + "religious-spiritual-care", + "research-analysis", + "sales-distribution", + "team-leadership", + "translation", + "tutoring-coaching", +] + + +def slug_to_skill_v2(slug: str) -> Optional[str]: + """ + Convert a slug to its corresponding V2 skill. + Returns None if no match is found. + """ + if slug in skill_v2_slugs: + return slug + + for skill, slugs in skill_v2_slugs.items(): + if slug in slugs: + return skill + + return None + + +def slugs_to_skill_v2_slugs(slugs: [str]) -> [str]: + return list(set(filter(None, map(slug_to_skill_v2, slugs)))) diff --git a/openbook_common/tests/helpers.py b/openbook_common/tests/helpers.py index a8c076aa91a699a0905ab457666c268009fafeec..2fbaf00e3fcf1981107713b0e434f09e9a0025cc 100644 --- a/openbook_common/tests/helpers.py +++ b/openbook_common/tests/helpers.py @@ -1,12 +1,13 @@ import random import tempfile import uuid -from typing import List, Optional +from typing import List, Optional, get_args from PIL import Image from asgiref.sync import async_to_sync from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone from faker import Faker @@ -19,6 +20,8 @@ from openbook_auth.models import EngagementLevelChoice, User, UserProfile, UserR from openbook_categories.models import Category from openbook_circles.models import Circle from openbook_common.enums import VisibilityType +from openbook_common.topics import TopicV2 +from openbook_common.skills import SkillV2 from openbook_common.models import ( Badge, Emoji, @@ -27,6 +30,7 @@ from openbook_common.models import ( ProxyBlacklistedDomain, ) from openbook_common.utils.helpers import get_random_pastel_color +from openbook_common.utils.model_loaders import get_topic_model, get_skill_model from openbook_communities.models import CollaborationTool, Community, ExternalLink, SpacesRelationship, Task from openbook_devices.models import Device from openbook_hashtags.models import Hashtag @@ -43,6 +47,7 @@ from openbook_moderation.models import ( from openbook_notifications.models import Notification from openbook_posts.models import Post from openbook_terms.models import SDG, Skill, Topic, FeedPostCategory +from openbook_terms.helpers import topics_to_topic_v2_slugs, skills_to_skill_v2_slugs fake = Faker() @@ -86,6 +91,10 @@ def make_user( role: UserRole = UserRole.REGULAR, is_employee: bool = False, engagement_level=None, + interests: Optional[List[Topic]] = None, + interests_v2: Optional[List[str]] = None, + skills: Optional[List[Skill]] = None, + skills_v2: Optional[List[str]] = None, ) -> User: defined_arguments = {k: v for k, v in locals().items() if v is not None} defined_arguments.setdefault("username", str(uuid.uuid4())) @@ -265,39 +274,73 @@ def make_category(): return mixer.blend(Category) -def make_topic(is_discover_spaces_default=False) -> Topic: - return mixer.blend(Topic, is_discover_spaces_default=is_discover_spaces_default) +def make_topic( + title: Optional[str] = None, slug: Optional[str] = None, is_discover_spaces_default: bool = False +) -> Topic: + TopicModel = get_topic_model() + if slug is not None: + try: + return TopicModel.objects.get(slug=slug) + except ObjectDoesNotExist: + pass + + defined_arguments = {k: v for k, v in locals().items() if v is not None} + if slug is None: + existing_topics = [t.slug for t in TopicModel.objects.all()] + all_topic_slugs = set(get_args(TopicV2)) + existing_topic_slugs = set(existing_topics) + defined_arguments["slug"] = random.choice(list(all_topic_slugs.difference(existing_topic_slugs))) # noqa: S311 + return mixer.blend(TopicModel, **defined_arguments) def make_sdg() -> SDG: return mixer.blend(SDG) -def make_community_topics(): - community_topics = [] +def make_topics(count: int) -> List[Topic]: + slugs = list(get_args(TopicV2)) + return [make_topic(slug=slugs[index]) for index in range(count)] - for _ in range(0, settings.COMMUNITY_TOPICS_MAX_AMOUNT): - topic = make_topic() - community_topics.append(topic.id) - return community_topics +def make_skill(title: Optional[str] = None, slug: Optional[str] = None) -> Skill: + SkillModel = get_skill_model() + if slug is not None: + try: + return SkillModel.objects.get(slug=slug) + except ObjectDoesNotExist: + pass + defined_arguments = {k: v for k, v in locals().items() if v is not None} + if slug is None: + existing_skills = [t.slug for t in SkillModel.objects.all()] + all_skill_slugs = set(get_args(SkillV2)) + existing_skill_slugs = set(existing_skills) + defined_arguments["slug"] = random.choice(list(all_skill_slugs.difference(existing_skill_slugs))) # noqa: S311 + return mixer.blend(Skill, **defined_arguments) -def make_skill() -> Skill: - return mixer.blend(Skill) + +def make_skills(count: int) -> List[Skill]: + slugs = list(get_args(SkillV2)) + return [make_skill(slug=slugs[index]) for index in range(count)] def make_community_invites_enabled(): return fake.boolean() -def make_community_task(community, skills=None, visibility=VisibilityType.MEMBERS, is_deleted=False) -> Task: +def make_community_task( + community, skills=None, skills_v2=None, visibility=VisibilityType.MEMBERS, is_deleted=False +) -> Task: if not skills: skills = [make_skill()] + if not skills_v2: + skills_v2 = skills_to_skill_v2_slugs(skills) deleted_at = timezone.now() if is_deleted else None - return mixer.blend(Task, community=community, skills=skills, visibility=visibility, deleted_at=deleted_at) + return mixer.blend( + Task, community=community, skills=skills, skills_v2=skills_v2, visibility=visibility, deleted_at=deleted_at + ) def make_community( @@ -305,6 +348,7 @@ def make_community( type=Community.COMMUNITY_TYPE_PUBLIC, title=None, topics=None, + topics_v2=None, location=None, geolocation=None, admins: List[User] = None, @@ -319,7 +363,10 @@ def make_community( title = make_community_title() if not topics: - topics = make_community_topics() + topics = make_topics(settings.COMMUNITY_TOPICS_MAX_AMOUNT) + + if not topics_v2: + topics_v2 = topics_to_topic_v2_slugs(topics) if not location: location = fake.text(max_nb_chars=settings.COMMUNITY_LOCATION_MAX_LENGTH) @@ -335,6 +382,7 @@ def make_community( categories_names=[make_category().name], invites_enabled=make_community_invites_enabled(), location=location, + topics_v2=topics_v2, ) community.topics.set(topics) community.geolocation = geolocation @@ -696,6 +744,10 @@ def make_insight(): return insight +def make_feed_post(): + return mixer.blend(FeedPost) + + def make_feed_post_category(): return mixer.blend(FeedPostCategory) diff --git a/openbook_common/tests/test_skills.py b/openbook_common/tests/test_skills.py new file mode 100644 index 0000000000000000000000000000000000000000..5ac7ee377594fe164e9683c942243062e834d892 --- /dev/null +++ b/openbook_common/tests/test_skills.py @@ -0,0 +1,176 @@ +import logging +import unittest + +from openbook_common.skills import skill_v2_slugs, slug_to_skill_v2, slugs_to_skill_v2_slugs + +logger = logging.getLogger(__name__) + + +class TestSlugToSkillV2(unittest.TestCase): + # Define test cases as a list of tuples (input, expected) + test_cases = [ + # Music Performance + ("music-performance", "music-performance"), + # Childcare + ("childcare", "childcare"), + # Sales & Distribution + ("sales-distribution", "sales-distribution"), + # Emergency Care + ("emergency-care", "emergency-care"), + # Animal Care + ("animal-care", "animal-care"), + # Religious & Spiritual Care + ("religious-spiritual-care", "religious-spiritual-care"), + # Gardening & Conservation + ("gardening-conservation", "gardening-conservation"), + # Community Organizing + ("community-organizing", "community-organizing"), + # Event Planning + ("event-planning", "event-planning"), + # Arts & Crafts + ("arts-crafts", "arts-crafts"), + ("arts-music-culture", "arts-crafts"), + # Intercultural Competence + ("intercultural-competence", "intercultural-competence"), + ("inclusive-mindset-intercultural-competence", "intercultural-competence"), + # Driving + ("driving", "driving"), + ("drive-transport", "driving"), + # Legal Assistance + ("legal-assistance", "legal-assistance"), + ("law-regulations", "legal-assistance"), + # Nursing & Caregiving + ("nursing-caregiving", "nursing-caregiving"), + ("caring-nursing", "nursing-caregiving"), + # Food Service + ("food-service", "food-service"), + ("cooking-baking", "food-service"), + # Translation + ("translation", "translation"), + ("writing-translation", "translation"), + # Administration + ("administration", "administration"), + ("organizational-skills", "administration"), + # Creativity & Design + ("creativity-design", "creativity-design"), + ("creativity", "creativity-design"), + ("design", "creativity-design"), + ("development-concept", "creativity-design"), + # Tutoring & Coaching + ("tutoring-coaching", "tutoring-coaching"), + ("teaching-education", "tutoring-coaching"), + ("coaching & mentoring", "tutoring-coaching"), + # Marketing & Communications + ("marketing-communications", "marketing-communications"), + ("marketing-social-media", "marketing-communications"), + ("communication-skills", "marketing-communications"), + ("public-relations", "marketing-communications"), + # Team Leadership + ("team-leadership", "team-leadership"), + ("coordination-coordination-of-volunteers", "team-leadership"), + ("leadership", "team-leadership"), + ("teamwork", "team-leadership"), + # Research & Analysis + ("research-analysis", "research-analysis"), + ("critical-thinking", "research-analysis"), + ("analytical-thinking", "research-analysis"), + ("knowledge-management", "research-analysis"), + # Other + ("other", "other"), + ("environmental-awareness", "other"), + ("commitment", "other"), + ("conflict-management", "other"), + ("discipline", "other"), + ("empathy", "other"), + ("hands-on-support", "other"), + ("holistic-thinking", "other"), + ("humour", "other"), + ("independence", "other"), + ("initiative-and-energy", "other"), + ("mindfulness", "other"), + ("motivation", "other"), + ("negotiation-skills", "other"), + ("openness", "other"), + ("relationship-skills", "other"), + ("resilience", "other"), + ("self-confident", "other"), + ("solution-orientedness", "other"), + ("willingness-to-learn", "other"), + ("adaptability", "other"), + ("visionary-thinking", "other"), + ("assertiveness", "other"), + # Fundraising + ("fundraising", "fundraising"), + # Moderation & Facilitation + ("moderation-facilitation", "moderation-facilitation"), + # Management & Strategy + ("management-strategy", "management-strategy"), + # IT & Technology + ("it-technology", "it-technology"), + # Finances & Taxes + ("finances-taxes", "finances-taxes"), + # Building & Handicraft + ("building-handicraft", "building-handicraft"), + # Edge cases + ("", None), + ("unknown-slug", None), + ] + + def test_slug_to_skill_v2_mapping(self): + """Test that each slug maps to its expected skill.""" + for input_slug, expected in self.test_cases: + assert slug_to_skill_v2(input_slug) == expected + + def test_all_implemented_slugs_are_tested(self): + """Test that all slugs in the implementation have a corresponding test case.""" + all_defined_slugs = [slug for slugs in skill_v2_slugs.values() for slug in slugs] + tested_slugs = [test_case[0] for test_case in self.test_cases] + + for slug in all_defined_slugs: + assert slug in tested_slugs, f"Slug '{slug}' is missing a test case" + + +class TestSlugsToSkillV2Slugs(unittest.TestCase): + def test_skill_v1_slugs_mapping(self): + """Test that a list of SkillV1 slugs is mapped to a list of SkillV2 slugs""" + input_slugs = ["law-regulations", "empathy", "it-technology", "discipline"] + expected = ["legal-assistance", "other", "it-technology"] + + actual = slugs_to_skill_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_skill_v2_slugs_mapping(self): + """Test that a list of SkillV2 slugs is mapped to a distinct list of SkillV2 slugs""" + input_slugs = ["animal-care", "other", "it-technology", "other"] + expected = ["animal-care", "other", "it-technology"] + + actual = slugs_to_skill_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_mixed_version_slugs_mapping(self): + """Test that a list of mixed version slugs is mapped to a list of SkillV2 slugs""" + input_slugs = ["law-regulations", "empathy", "it-technology", "animal-care"] + expected = ["legal-assistance", "other", "it-technology", "animal-care"] + + actual = slugs_to_skill_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_unknown_slug_mapping(self): + """Test that unknown or empty slugs are ignored when mapping to a list of SkillV2 slugs""" + input_slugs = ["law-regulations", "", "it-technology", "unknown"] + expected = ["legal-assistance", "it-technology"] + + actual = slugs_to_skill_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" diff --git a/openbook_common/tests/test_topics.py b/openbook_common/tests/test_topics.py new file mode 100644 index 0000000000000000000000000000000000000000..e5340b52a3e063968a68725886dfce3d2cf24748 --- /dev/null +++ b/openbook_common/tests/test_topics.py @@ -0,0 +1,145 @@ +import unittest + +from openbook_common.topics import topic_v2_slugs, slug_to_topic_v2, slugs_to_topic_v2_slugs + + +class TestSlugToTopicV2(unittest.TestCase): + # Define test cases as a list of tuples (input, expected) + test_cases = [ + # ANIMAL_WELFARE + ("animal-welfare", "animal-welfare"), + ("biodiversity-animal-welfare", "animal-welfare"), + # ARTS_CULTURE + ("arts-culture", "arts-culture"), + ("culture-social-change", "arts-culture"), + # CLIMATE_ENVIRONMENT + ("climate-environment", "climate-environment"), + ("climate-protection", "climate-environment"), + ("water-ocean", "climate-environment"), + ("energy-transition", "climate-environment"), + ("natural-resources", "climate-environment"), + # DEMOCRACY_POLITICS + ("democracy-politics", "democracy-politics"), + ("governance", "democracy-politics"), + ("politics-society", "democracy-politics"), + # DISABILITY_INCLUSION + ("disability-inclusion", "disability-inclusion"), + ("disabilities-inclusion", "disability-inclusion"), + # DISASTER_RELIEF + ("disaster-relief", "disaster-relief"), + ("natural-disaster-response", "disaster-relief"), + # ELDERLY_CARE + ("elderly-care", "elderly-care"), + ("elderly-people", "elderly-care"), + # FAMILY_KIDS + ("family-kids", "family-kids"), + # FOOD_HUNGER_RELIEF + ("food-hunger-relief", "food-hunger-relief"), + ("agriculture-food", "food-hunger-relief"), + # GENDER_EQUALITY + ("gender-equality", "gender-equality"), + ("diversity-gender-equality", "gender-equality"), + # HOUSING + ("housing", "housing"), + ("building-housing", "housing"), + # HUMAN_RIGHTS + ("human-rights", "human-rights"), + ("democracy-human-rights", "human-rights"), + # LGBTQIA_PLUS + ("lgbtqia-plus", "lgbtqia-plus"), + # LIFESTYLE_CONSUMPTION + ("lifestyle-consumption", "lifestyle-consumption"), + ("consumption-lifestyle", "lifestyle-consumption"), + ("eco-tourism", "lifestyle-consumption"), + # LITERACY_EDUCATION + ("literacy-education", "literacy-education"), + ("education-science", "literacy-education"), + # MENTAL_PHYSICAL_HEALTH + ("mental-physical-health", "mental-physical-health"), + ("physical-mental-health", "mental-physical-health"), + # MIGRATION_REFUGEES + ("migration-refugees", "migration-refugees"), + ("migration-integration", "migration-refugees"), + # MOBILITY_TRANSPORT + ("mobility-transport", "mobility-transport"), + ("mobility-infrastructure", "mobility-transport"), + # OTHER + ("other", "other"), + ("alternative-economy", "other"), + ("urban-rural-development", "other"), + # PEACE_SECURITY + ("peace-security", "peace-security"), + # POVERTY_HOMELESSNESS + ("poverty-homelessness", "poverty-homelessness"), + ("poverty-homelesness", "poverty-homelessness"), + # SPORTS_RECREATION + ("sports-recreation", "sports-recreation"), + # TECHNOLOGY_INNOVATION + ("technology-innovation", "technology-innovation"), + ("technological-innovation", "technology-innovation"), + # YOUTH_DEVELOPMENT + ("youth-development", "youth-development"), + ("children-youth", "youth-development"), + # Edge cases + ("", None), + ("unknown-slug", None), + ] + + def test_slug_to_topic_v2_mapping(self): + """Test that each slug maps to its expected topic.""" + for input_slug, expected in self.test_cases: + assert slug_to_topic_v2(input_slug) == expected + + def test_all_implemented_slugs_are_tested(self): + """Test that all slugs in the implementation have a corresponding test case.""" + all_slugs = [slug for slugs in topic_v2_slugs.values() for slug in slugs] + tested_slugs = [test_case[0] for test_case in self.test_cases] + + for slug in all_slugs: + assert slug in tested_slugs, f"Slug '{slug}' is missing a test case" + + +class TestSlugsToTopicV2Slugs(unittest.TestCase): + def test_topic_v1_slugs_mapping(self): + """Test that a list of TopicV1 slugs is mapped to a list of TopicV2 slugs""" + input_slugs = ["water-ocean", "alternative-economy", "peace-security", "urban-rural-development"] + expected = ["climate-environment", "other", "peace-security"] + + actual = slugs_to_topic_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_topic_v2_slugs_mapping(self): + """Test that a list of TopicV2 slugs is mapped to a distinct list of TopicV2 slugs""" + input_slugs = ["sports-recreation", "other", "peace-security", "other"] + expected = ["sports-recreation", "other", "peace-security"] + + actual = slugs_to_topic_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_mixed_version_slugs_mapping(self): + """Test that a list of mixed version slugs is mapped to a list of TopicV2 slugs""" + input_slugs = ["water-ocean", "alternative-economy", "peace-security", "sports-recreation"] + expected = ["climate-environment", "other", "peace-security", "sports-recreation"] + + actual = slugs_to_topic_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" + + def test_unknown_slug_mapping(self): + """Test that unknown or empty slugs are ignored when mapping to a list of TopicV2 slugs""" + input_slugs = ["water-ocean", "", "peace-security", "unknown"] + expected = ["climate-environment", "peace-security"] + + actual = slugs_to_topic_v2_slugs(input_slugs) + + assert len(actual) == len(expected), "Invalid number of slugs" + for slug in expected: + assert slug in actual, f"Slug '{slug}' is missing" diff --git a/openbook_common/tests/test_validators.py b/openbook_common/tests/test_validators.py new file mode 100644 index 0000000000000000000000000000000000000000..c67b15b2dfa1d7edaa7ecc2e87145067067a27ab --- /dev/null +++ b/openbook_common/tests/test_validators.py @@ -0,0 +1,58 @@ +import unittest +from rest_framework.exceptions import NotFound + +from openbook_common.topics import topic_v2_slugs +from openbook_common.skills import skill_v2_slugs +from openbook_common.validators import topic_v2_exists, skill_v2_exists + + +class TestTopicV2Validation(unittest.TestCase): + def test_validation_succeeds_for_existing_slugs(self): + all_v2_slugs = topic_v2_slugs.keys() + + for slug in all_v2_slugs: + topic_v2_exists(slug) + + def test_validation_fails_for_v1_slugs(self): + all_v2_slugs = topic_v2_slugs.keys() + all_v1_slugs = filter( + lambda slug: slug not in all_v2_slugs, [slug for slugs in topic_v2_slugs.values() for slug in slugs] + ) + + for slug in all_v1_slugs: + with self.assertRaises(NotFound): + topic_v2_exists(slug) + + def test_validation_fails_for_unknown_slug(self): + with self.assertRaises(NotFound): + topic_v2_exists("unknown") + + def test_validation_fails_for_empty_slug(self): + with self.assertRaises(NotFound): + topic_v2_exists("") + + +class TestSkillV2Validation(unittest.TestCase): + def test_validation_succeeds_for_existing_slugs(self): + all_v2_slugs = skill_v2_slugs.keys() + + for slug in all_v2_slugs: + skill_v2_exists(slug) + + def test_validation_fails_for_v1_slugs(self): + all_v2_slugs = skill_v2_slugs.keys() + all_v1_slugs = filter( + lambda slug: slug not in all_v2_slugs, [slug for slugs in skill_v2_slugs.values() for slug in slugs] + ) + + for slug in all_v1_slugs: + with self.assertRaises(NotFound): + skill_v2_exists(slug) + + def test_validation_fails_for_unknown_slug(self): + with self.assertRaises(NotFound): + skill_v2_exists("unknown") + + def test_validation_fails_for_empty_slug(self): + with self.assertRaises(NotFound): + skill_v2_exists("") diff --git a/openbook_common/topics.py b/openbook_common/topics.py new file mode 100644 index 0000000000000000000000000000000000000000..55d6a8b07ab8c64e129c028ef754041873f6e38a --- /dev/null +++ b/openbook_common/topics.py @@ -0,0 +1,81 @@ +from typing import Optional, Literal, List + +# keys are the V2 topic slugs, values is a combined list of V1 and V2 topic slugs (for backwards-compatibility) +topic_v2_slugs = { + "animal-welfare": ["animal-welfare", "biodiversity-animal-welfare"], + "arts-culture": ["arts-culture", "culture-social-change"], + "climate-environment": [ + "climate-environment", + "climate-protection", + "water-ocean", + "energy-transition", + "natural-resources", + ], + "democracy-politics": ["democracy-politics", "governance", "politics-society"], + "disability-inclusion": ["disability-inclusion", "disabilities-inclusion"], + "disaster-relief": ["disaster-relief", "natural-disaster-response"], + "elderly-care": ["elderly-care", "elderly-people"], + "family-kids": ["family-kids"], + "food-hunger-relief": ["food-hunger-relief", "agriculture-food"], + "gender-equality": ["gender-equality", "diversity-gender-equality"], + "housing": ["housing", "building-housing"], + "human-rights": ["human-rights", "democracy-human-rights"], + "lgbtqia-plus": ["lgbtqia-plus"], + "lifestyle-consumption": ["lifestyle-consumption", "consumption-lifestyle", "eco-tourism"], + "literacy-education": ["literacy-education", "education-science"], + "mental-physical-health": ["mental-physical-health", "physical-mental-health"], + "migration-refugees": ["migration-refugees", "migration-integration"], + "mobility-transport": ["mobility-transport", "mobility-infrastructure"], + "other": ["other", "alternative-economy", "urban-rural-development"], + "peace-security": ["peace-security"], + "poverty-homelessness": ["poverty-homelessness", "poverty-homelesness"], + "sports-recreation": ["sports-recreation"], + "technology-innovation": ["technology-innovation", "technological-innovation"], + "youth-development": ["youth-development", "children-youth"], +} + +TopicV2 = Literal[ + "animal-welfare", + "arts-culture", + "climate-environment", + "democracy-politics", + "disability-inclusion", + "disaster-relief", + "elderly-care", + "family-kids", + "food-hunger-relief", + "gender-equality", + "housing", + "human-rights", + "lgbtqia-plus", + "lifestyle-consumption", + "literacy-education", + "mental-physical-health", + "migration-refugees", + "mobility-transport", + "other", + "peace-security", + "poverty-homelessness", + "sports-recreation", + "technology-innovation", + "youth-development", +] + + +def slug_to_topic_v2(slug: str) -> Optional[str]: + """ + Convert a slug to its corresponding V2 topic. + Returns None if no match is found. + """ + if slug in topic_v2_slugs: + return slug + + for topic, slugs in topic_v2_slugs.items(): + if slug in slugs: + return topic + + return None + + +def slugs_to_topic_v2_slugs(slugs: List[str]) -> List[str]: + return list(set(filter(None, map(slug_to_topic_v2, slugs)))) diff --git a/openbook_common/validators.py b/openbook_common/validators.py index 160da61bff027705ddbb9002daf80b0d2cb7d1ee..53016a33b0fe744321922844bdeb84e370c7ebaa 100644 --- a/openbook_common/validators.py +++ b/openbook_common/validators.py @@ -1,8 +1,8 @@ -# ruff: noqa import re +from typing import get_args from graphql import GraphQLError -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, NotFound from django.utils.translation import gettext_lazy as _ from openbook.graphql_errors import GraphQLErrorCodes @@ -13,6 +13,8 @@ from openbook_common.utils.model_loaders import ( get_emoji_group_model, get_language_model, ) +from openbook_common.topics import TopicV2 +from openbook_common.skills import SkillV2 from openbook import settings @@ -50,7 +52,7 @@ def reaction_type_exists(reaction_type): except KeyError: raise ValidationError( _("No reaction_type with the provided name exists."), - ) + ) from None def emoji_group_id_exists(emoji_group_id): @@ -118,3 +120,13 @@ def sanitize_text(text: str): urls = extract_urls_from_string(text) for url in urls: url_safety_validator(url) + + +def topic_v2_exists(topic_v2_slug): + if topic_v2_slug not in get_args(TopicV2): + raise NotFound(_(f"TopicV2 '{topic_v2_slug}' does not exist.")) + + +def skill_v2_exists(skill_v2_slug): + if skill_v2_slug not in get_args(SkillV2): + raise NotFound(_(f"SkillV2 '{skill_v2_slug}' does not exist.")) diff --git a/openbook_communities/migrations/0043_community_topics_v2_task_skills_v2.py b/openbook_communities/migrations/0043_community_topics_v2_task_skills_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..b41c038033a6df0820cfd86b02a459b22e14632e --- /dev/null +++ b/openbook_communities/migrations/0043_community_topics_v2_task_skills_v2.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.12 on 2025-02-27 14:37 + +import django.contrib.postgres.fields +from django.db import migrations, models + +from openbook_terms.helpers import topics_to_topic_v2_slugs, skills_to_skill_v2_slugs + + +def migrate_task_skills(apps, schema_editor): + Task = apps.get_model("openbook_communities", "Task") + + for task in Task.objects.all(): + old_skills = task.skills.all() + new_skills = skills_to_skill_v2_slugs(old_skills) + + task.skills_v2 = new_skills + task.save() + + +def migrate_space_topics(apps, schema_editor): + Community = apps.get_model("openbook_communities", "Community") + + for space in Community.objects.all(): + old_topics = space.topics.all() + new_topics = topics_to_topic_v2_slugs(old_topics) + + space.topics_v2 = new_topics + space.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("openbook_communities", "0042_alter_collaborationtool_thumbnail"), + ] + + operations = [ + migrations.AddField( + model_name="community", + name="topics_v2", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), blank=True, default=list, size=None + ), + ), + migrations.AddField( + model_name="task", + name="skills_v2", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), blank=True, default=list, size=None + ), + ), + migrations.RunPython(migrate_space_topics), + migrations.RunPython(migrate_task_skills), + ] diff --git a/openbook_communities/migrations/0044_community_openbook_co_topics__765075_gin.py b/openbook_communities/migrations/0044_community_openbook_co_topics__765075_gin.py new file mode 100644 index 0000000000000000000000000000000000000000..1f3de593e8a046c066ea0e2f03b1669cb3967c35 --- /dev/null +++ b/openbook_communities/migrations/0044_community_openbook_co_topics__765075_gin.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.12 on 2025-02-28 15:50 + +import django.contrib.postgres.indexes +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("openbook_communities", "0043_community_topics_v2_task_skills_v2"), + ("openbook_terms", "0010_delete_inspirationpostcategory"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="community", + index=django.contrib.postgres.indexes.GinIndex( + fields=["topics_v2"], name="openbook_co_topics__765075_gin" + ), + ), + ] diff --git a/openbook_communities/migrations/0045_task_openbook_co_skills__85edfe_gin.py b/openbook_communities/migrations/0045_task_openbook_co_skills__85edfe_gin.py new file mode 100644 index 0000000000000000000000000000000000000000..912844588b358ba6f2e532e79175f16ff2281719 --- /dev/null +++ b/openbook_communities/migrations/0045_task_openbook_co_skills__85edfe_gin.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.12 on 2025-02-28 15:54 + +import django.contrib.postgres.indexes +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("openbook_communities", "0044_community_openbook_co_topics__765075_gin"), + ("openbook_terms", "0010_delete_inspirationpostcategory"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="task", + index=django.contrib.postgres.indexes.GinIndex( + fields=["skills_v2"], name="openbook_co_skills__85edfe_gin" + ), + ), + ] diff --git a/openbook_communities/migrations/tests/__init__.py b/openbook_communities/migrations/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openbook_communities/migrations/tests/test_0043_community_topics_v2_task_skills_v2.py b/openbook_communities/migrations/tests/test_0043_community_topics_v2_task_skills_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..e3c19fe2f31546fb33b90d22634e26ecd294a629 --- /dev/null +++ b/openbook_communities/migrations/tests/test_0043_community_topics_v2_task_skills_v2.py @@ -0,0 +1,38 @@ +from importlib import import_module +import pytest +from django.apps import apps +from django.db import connection +from openbook_communities.models import Community, Task +from openbook_common.tests.helpers import make_community, make_community_task, make_topic, make_skill + +# Required because of file name starting with a number +migration = import_module("openbook_communities.migrations.0043_community_topics_v2_task_skills_v2") + + +@pytest.mark.django_db +class TestMigrateSpaceTopicsAndTaskSkills: + def test_migrate_space_topics(self): + # GIVEN a space with only V1 topics + topics_v1 = [make_topic(slug="eco-tourism"), make_topic(slug="elderly-people")] + space = make_community(topics=[], topics_v2=[]) + space.topics.set(topics_v1) + + # WHEN migrating space topics from V1 to V2 + migration.migrate_space_topics(apps, connection.schema_editor()) + + # THEN the space has mapped V2 topics + updated_space = Community.objects.get(id=space.id) + assert sorted(updated_space.topics_v2) == sorted(["lifestyle-consumption", "elderly-care"]) + + def test_migrate_task_skills(self): + # GIVEN a task with only V1 skills + skills_v1 = [make_skill(slug="writing-translation"), make_skill(slug="assertiveness")] + task = make_community_task(community=make_community(), skills=[], skills_v2=[]) + task.skills.set(skills_v1) + + # WHEN migrating task skills from V1 to V2 + migration.migrate_task_skills(apps, connection.schema_editor()) + + # THEN the task has mapped V2 skills + updated_task = Task.objects.get(id=task.id) + assert sorted(updated_task.skills_v2) == sorted(["translation", "other"]) diff --git a/openbook_communities/models/__init__.py b/openbook_communities/models/__init__.py index 1806bcce0390adc4db7bbe5af42452278b22cce9..f3cb162bb76b041624a8e9e17f90cfab31d118e3 100644 --- a/openbook_communities/models/__init__.py +++ b/openbook_communities/models/__init__.py @@ -10,6 +10,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.contrib.gis.db import models from django.contrib.gis.db.models import Count from django.contrib.gis.db.models import Q +from django.contrib.postgres.indexes import GinIndex from django.core.files.storage import default_storage from django.db import transaction from django.db.models import QuerySet @@ -53,6 +54,8 @@ from openbook_communities.queries import ( from openbook_moderation.models import ModeratedObject from openbook_notifications.api import NotificationApi from openbook_terms.models import SDG, Topic, Skill +from openbook_terms.helpers import topics_to_topic_v2_slugs, skills_to_skill_v2_slugs +from django.contrib.postgres.fields import ArrayField community_image_storage = default_storage community_task_image_storage = default_storage @@ -187,6 +190,7 @@ class Community(ModelWithUUID): db_index=True, ) topics = models.ManyToManyField(Topic, related_name="communities", blank=False, db_index=True) + topics_v2 = ArrayField(models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH), blank=True, default=list) sdgs = models.ManyToManyField(SDG, related_name="communities", blank=True, db_index=True) geolocation = models.PointField(blank=True, null=True, spatial_index=True) contact_description = models.CharField( @@ -210,6 +214,7 @@ class Community(ModelWithUUID): class Meta: verbose_name_plural = "communities" + indexes = [GinIndex(fields=["topics_v2"])] @property def avatar_default_color(self): @@ -352,6 +357,7 @@ class Community(ModelWithUUID): invites_enabled=None, location=None, topic_ids=None, + topics_v2=None, ): # If it's a private community and no invites_enabled if type == Community.COMMUNITY_TYPE_PRIVATE and invites_enabled is None: @@ -360,6 +366,9 @@ class Community(ModelWithUUID): # The default for this field is not working when passed None? invites_enabled = True + if topics_v2 is None: + topics_v2 = [] + community = cls.objects.create( title=title, creator=creator, @@ -375,6 +384,7 @@ class Community(ModelWithUUID): rules=rules, invites_enabled=invites_enabled, location=location, + topics_v2=topics_v2, ) CommunityMembership.create_membership( @@ -545,6 +555,7 @@ class Community(ModelWithUUID): categories_names=None, invites_enabled=None, topic_ids=None, + topics_v2=None, sdg_ids=None, cover=None, remove_cover=False, @@ -584,6 +595,9 @@ class Community(ModelWithUUID): if categories_names is not None: self.set_categories_with_names(categories_names=categories_names) + if topics_v2 is not None: + self.topics_v2 = topics_v2 + if topic_ids is not None: self.set_topics_with_ids(topic_ids=topic_ids) @@ -679,14 +693,12 @@ class Community(ModelWithUUID): self.categories.clear() def set_topics_with_ids(self, topic_ids): - self.clear_topics() + self.topics.clear() Topic = get_topic_model() topics = Topic.objects.filter(id__in=topic_ids) + self.topics_v2 = topics_to_topic_v2_slugs(topics) self.topics.set(topics) - def clear_topics(self): - self.topics.clear() - def set_sdgs_with_ids(self, sdg_ids): self.clear_sdgs() Sdg = get_sdg_model() @@ -1272,6 +1284,7 @@ class Task(ModelWithUUID): choices=Regularity.choices, ) skills = models.ManyToManyField(Skill, related_name="tasks", blank=True, db_index=True) + skills_v2 = ArrayField(models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH), blank=True, default=list) thumbnail = ProcessedImageField( storage=community_task_image_storage, verbose_name=_("thumbnail"), @@ -1302,6 +1315,9 @@ class Task(ModelWithUUID): deleted_at = models.DateTimeField(blank=True, null=True, default=None) + class Meta: + indexes = [GinIndex(fields=["skills_v2"])] + @property def is_deleted(self) -> bool: return self.deleted_at is not None @@ -1321,13 +1337,11 @@ class Task(ModelWithUUID): & (pQ(visibility=VisibilityType.PUBLIC) | pQ(community__memberships__user__id=user.id)) ) - def clear_skills(self): - self.skills.clear() - def set_skills_with_ids(self, skill_ids): - self.clear_skills() + self.skills.clear() Skill = get_skill_model() skills = Skill.objects.filter(id__in=skill_ids) + self.skills_v2 = skills_to_skill_v2_slugs(skills) self.skills.set(skills) @classmethod @@ -1346,8 +1360,12 @@ class Task(ModelWithUUID): regularity=None, contact_phone=None, skills=None, + skills_v2=None, thumbnail=None, ): + if skills_v2 is None: + skills_v2 = [] + task = cls.objects.create( creator=creator, community=community, @@ -1363,6 +1381,7 @@ class Task(ModelWithUUID): contact_phone=contact_phone, thumbnail=thumbnail, thumbnail_blurhash=generate_blurhash(thumbnail), + skills_v2=skills_v2, ) if skills: @@ -1383,7 +1402,8 @@ class Task(ModelWithUUID): geolocation=None, regularity=None, contact_phone=None, - skills=None, + skill_ids=None, + skills_v2=None, thumbnail=None, remove_thumbnail=False, ): @@ -1401,8 +1421,13 @@ class Task(ModelWithUUID): self.geolocation = geolocation if contact_phone is not None: self.contact_phone = contact_phone - if skills is not None: - self.skills.set(skills) + if skills_v2 is not None: + self.skills_v2 = skills_v2 + if skill_ids is not None: + Skill = get_skill_model() + updated_skills = Skill.objects.filter(id__in=skill_ids) + self.skills_v2 = skills_to_skill_v2_slugs(updated_skills) + self.skills.set(updated_skills) if remove_thumbnail: self._delete_thumbnail(save=False) diff --git a/openbook_communities/schema/helpers.py b/openbook_communities/schema/helpers.py index 2c23961edff0b3e64ed74fb36eac6d89b068e6ec..7b01c6b44260800bedce04d94305255163925aed 100644 --- a/openbook_communities/schema/helpers.py +++ b/openbook_communities/schema/helpers.py @@ -9,6 +9,17 @@ from openbook_common.types import GeolocationFeature from openbook_communities.models import Community as CommunityModel +DISCOVER_DEFAULT_SPACES_V2 = [ + "human-rights", + "animal-welfare", + "climate-environment", + "arts-culture", + "literacy-education", + "mental-physical-health", + "democracy-politics", +] + + @sync_to_async def filtered_spaces( topic_id: Optional[uuid.UUID] = None, @@ -18,10 +29,21 @@ def filtered_spaces( topic_slug: Optional[str] = None, topic_slugs: Optional[List[Optional[str]]] = None, skill_slugs: Optional[List[Optional[str]]] = None, + topics_v2: Optional[List[str]] = None, + skills_v2: Optional[List[str]] = None, member_id: Optional[uuid.UUID] = None, ): query = create_filtered_spaces_query( - topic_id, topic_ids, geolocation, skill_ids, topic_slug, topic_slugs, skill_slugs, member_id + topic_id, + topic_ids, + geolocation, + skill_ids, + topic_slug, + topic_slugs, + skill_slugs, + topics_v2, + skills_v2, + member_id, ) return create_spaces_queryset(query) @@ -34,6 +56,8 @@ def create_filtered_spaces_query( topic_slug: Optional[str] = None, topic_slugs: Optional[List[Optional[str]]] = None, skill_slugs: Optional[List[Optional[str]]] = None, + topics_v2: Optional[List[str]] = None, + skills_v2: Optional[List[str]] = None, member_id: Optional[uuid.UUID] = None, ): query = Q() @@ -55,6 +79,10 @@ def create_filtered_spaces_query( query.add(Q(topics__slug__contains=topic_slug), Q.AND) if skill_slugs: query.add(Q(tasks__skills__slug__in=skill_slugs), Q.AND) + if topics_v2: + query.add(Q(topics_v2__overlap=topics_v2), Q.AND) + if skills_v2: + query.add(Q(tasks__skills_v2__overlap=skills_v2), Q.AND) if member_id: user = UserModel.objects.get(username=member_id) query.add(Q(memberships__user=user), Q.AND) diff --git a/openbook_communities/schema/mutations.py b/openbook_communities/schema/mutations.py index 2c1be6a8b289ccbeda9c6125df4dc4d0d8e07f26..c71dafd6dc8c36bcaa3d1d3fb269d4f9d40f9d31 100644 --- a/openbook_communities/schema/mutations.py +++ b/openbook_communities/schema/mutations.py @@ -177,6 +177,15 @@ class Mutation: serializer = CreateCommunitySerializer(data=to_input_dict(input)) serializer.is_valid(raise_exception=True) data = serializer.validated_data + if not data.get("topics") and not data.get("topics_v2"): + raise GraphQLError( + "Either topics or topics_v2 must be specified", + extensions={ + "code": GraphQLErrorCodes.BAD_USER_INPUT, + "errors": [], + }, + ) from None + with transaction.atomic(): space = current_user.create_community( title=data.get("title"), @@ -185,6 +194,7 @@ class Mutation: cover=data.get("cover"), invites_enabled=True, topic_ids=data.get("topics"), + topics_v2=data.get("topics_v2"), ) publish_space_created_event(community=space) @@ -207,6 +217,7 @@ class Mutation: title=data.get("title"), description=data.get("description"), topic_ids=data.get("topics"), + topics_v2=data.get("topics_v2"), cover=data.get("cover"), remove_cover=is_parameter_null(data, "cover"), avatar=data.get("avatar"), @@ -288,6 +299,7 @@ class Mutation: contact_email=data.get("contact_email"), contact_phone=data.get("contact_phone"), skills=data.get("skills"), + skills_v2=data.get("skills_v2"), thumbnail=data.get("thumbnail"), ) @@ -323,7 +335,8 @@ class Mutation: regularity=data.get("regularity"), contact_email=data.get("contact_email"), contact_phone=data.get("contact_phone"), - skills=data.get("skills"), + skill_ids=data.get("skills"), + skills_v2=data.get("skills_v2"), thumbnail=data.get("thumbnail"), remove_thumbnail=is_parameter_null(data, "thumbnail"), ) diff --git a/openbook_communities/schema/queries.py b/openbook_communities/schema/queries.py index 0c9965532924a239e6fc16d364e7b951103a222f..6e9a22a1277e2cff825bcb4b9ff6bb64c16b9f21 100644 --- a/openbook_communities/schema/queries.py +++ b/openbook_communities/schema/queries.py @@ -5,7 +5,7 @@ import strawberry import strawberry_django from asgiref.sync import async_to_sync from django.core.exceptions import PermissionDenied -from django.db.models import Count, Prefetch, F, Q, Case, When, Value, IntegerField, OuterRef, Exists +from django.db.models import Count, Prefetch, F, Q, Case, When, Value, IntegerField from django.db.models.lookups import IContains from django.utils.translation import gettext_lazy as _ from strawberry.django.context import StrawberryDjangoContext @@ -20,10 +20,10 @@ from openbook_communities.models import CollaborationTool as CollaborationToolMo from openbook_communities.models import Community as CommunityModel from openbook_communities.models import SpaceUserConnectionType from openbook_communities.models import Task as TaskModel -from openbook_communities.models import Topic as TopicModel from openbook_communities.schema.helpers import ( filtered_spaces, create_filtered_spaces_query, + DISCOVER_DEFAULT_SPACES_V2, ) from openbook_communities.schema.types import ( CollaborationTool, @@ -32,17 +32,25 @@ from openbook_communities.schema.types import ( Space, Task, ) -from openbook_terms.models import Skill as SkillModel Info: TypeAlias = StrawberryInfo[StrawberryDjangoContext, Any] @strawberry.type class Query: + @strawberry_django.field() + def discover_spaces_default_topics_v2(self) -> List[str]: + return DISCOVER_DEFAULT_SPACES_V2 + @strawberry_django.field() def spaces( self, - topic_id: Optional[uuid.UUID] = None, + topic_id: Annotated[ + Optional[uuid.UUID], + strawberry.argument( + description="Deprecated since version 1.52, use topics_v2 instead", + ), + ] = None, location: Annotated[ Optional[str], strawberry.argument( @@ -51,10 +59,32 @@ class Query: ), ] = None, geolocation: Optional[GeoJSON] = None, - skill_ids: Optional[List[Optional[uuid.UUID]]] = None, - topic_slug: Optional[str] = None, - topic_slugs: Optional[List[Optional[str]]] = None, - skill_slugs: Optional[List[Optional[str]]] = None, + skill_ids: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument( + description="Deprecated since version 1.52, use skills_v2 instead", + ), + ] = None, + topic_slug: Annotated[ + Optional[str], + strawberry.argument( + description="Deprecated since version 1.52, use topics_v2 instead", + ), + ] = None, + topic_slugs: Annotated[ + Optional[List[Optional[str]]], + strawberry.argument( + description="Deprecated since version 1.52, use topics_v2 instead", + ), + ] = None, + skill_slugs: Annotated[ + Optional[List[Optional[str]]], + strawberry.argument( + description="Deprecated since version 1.52, use skills_v2 instead", + ), + ] = None, + topics_v2: Optional[List[str]] = None, + skills_v2: Optional[List[str]] = None, member_id: Optional[uuid.UUID] = None, offset: int = 0, limit: int = 10, @@ -66,6 +96,8 @@ class Query: topic_slug=topic_slug, topic_slugs=topic_slugs, skill_slugs=skill_slugs, + topics_v2=topics_v2, + skills_v2=skills_v2, member_id=member_id, ) @@ -83,20 +115,18 @@ class Query: whens = [] if current_user and not current_user.is_anonymous: - topic_ids = list(current_user.profile.interests.values_list("id", flat=True).all()) + topics_v2 = list(current_user.profile.interests_v2) geolocation = current_user.geolocation - if topic_ids: + if topics_v2: whens.append( - When(create_filtered_spaces_query(topic_ids=topic_ids, geolocation=geolocation), then=Value(10)) + When(create_filtered_spaces_query(topics_v2=topics_v2, geolocation=geolocation), then=Value(10)) ) if geolocation: whens.append(When(create_filtered_spaces_query(geolocation=geolocation), then=Value(20))) - default_topics = list(TopicModel.objects.filter(is_discover_spaces_default=True).values_list("id", flat=True)) - if default_topics: - whens.append(When(create_filtered_spaces_query(topic_ids=default_topics), then=Value(30))) + whens.append(When(create_filtered_spaces_query(topics_v2=DISCOVER_DEFAULT_SPACES_V2), then=Value(30))) return Paged.of( CommunityModel.objects.prefetch_related("links", "memberships__user__profile") @@ -119,14 +149,26 @@ class Query: def discover_spaces( self, info, - topic_ids: Optional[List[Optional[uuid.UUID]]] = None, + topic_ids: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument( + description="Deprecated since version 1.52, use topics_v2 instead", + ), + ] = None, + topics_v2: Optional[List[str]] = None, location: Annotated[ Optional[str], # Using description instead of deprecation_reason as deprecated arguments are removed in GraphQL Mesh strawberry.argument(description="Unsupported since version 1.0.16, use geolocation instead"), ] = None, geolocation: Optional[GeoJSON] = None, - skill_ids: Optional[List[Optional[uuid.UUID]]] = None, + skill_ids: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument( + description="Deprecated since version 1.52, use skills_v2 instead", + ), + ] = None, + skills_v2: Optional[List[str]] = None, offset: int = 0, limit: int = 10, ) -> List[Optional[DiscoverSpaces]]: @@ -153,6 +195,16 @@ class Query: ) ) + if topics_v2 is not None: + for topic_v2 in topics_v2: + spaces = async_to_sync(filtered_spaces)(topics_v2=[topic_v2]) + result.append( + DiscoverSpaces( + topic_v2=topic_v2, + spaces=Paged.of(spaces, offset, limit), + ) + ) + # get all spaces by skill_id (including geolocation filter) if skill_ids is not None: for skill_id in skill_ids: @@ -164,6 +216,18 @@ class Query: spaces=Paged.of(spaces, offset, limit), ) ) + + if skills_v2 is not None: + for skill_v2 in skills_v2: + spaces = async_to_sync(filtered_spaces)(skills_v2=[skill_v2], geolocation=geolocation) + result.append( + DiscoverSpaces( + skill_v2=skill_v2, + location_id=geolocation.id if geolocation else None, + spaces=Paged.of(spaces, offset, limit), + ) + ) + return result @strawberry_django.field() @@ -300,19 +364,17 @@ class Query: limit, ) - skills = list(current_user.profile.skills.all()) + skills_v2 = list(current_user.profile.skills_v2) whens = [] locations_match = Q(geolocation__within=current_user.profile.geolocation) - # We need to use a skill exists query, because we want to check that at least one skill from the user profile exists for the task. - skill_exists_subquery = SkillModel.objects.filter(id__in=[skill.id for skill in skills], tasks=OuterRef("id")) - if skills and current_user.profile.geolocation: + if skills_v2 and current_user.profile.geolocation: whens.append( - When(Q(Exists(skill_exists_subquery)) & (locations_match | Q(location_type="REMOTE")), then=Value(10)) + When(Q(skills_v2__overlap=skills_v2) & (locations_match | Q(location_type="REMOTE")), then=Value(10)) ) - if skills: - whens.append(When(skills__in=skills, then=Value(20))) + if skills_v2: + whens.append(When(skills_v2__overlap=skills_v2, then=Value(20))) if current_user.profile.geolocation: whens.append(When(locations_match, then=Value(30))) diff --git a/openbook_communities/schema/types.py b/openbook_communities/schema/types.py index 25e8f4be862876174638bbbfda531faed411d5eb..eb3b0f1f42367d8a16be35f79c57cba1eee19e0c 100644 --- a/openbook_communities/schema/types.py +++ b/openbook_communities/schema/types.py @@ -48,7 +48,11 @@ Info: TypeAlias = StrawberryInfo[StrawberryDjangoContext, Any] class ValidateSpaceInput: title: Optional[str] = strawberry.UNSET description: Optional[str] = strawberry.UNSET - topics: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + topics: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] = strawberry.UNSET + topics_v2: Optional[List[str]] = strawberry.UNSET cover: Optional[Upload] = strawberry.UNSET sdgs: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET location: Optional[str] = strawberry.UNSET @@ -58,7 +62,11 @@ class ValidateSpaceInput: class CreateSpaceInput: title: str description: str - topics: List[Optional[uuid.UUID]] + topics: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] = strawberry.UNSET + topics_v2: Optional[List[str]] = strawberry.UNSET cover: Upload @@ -73,7 +81,11 @@ class CreateTaskInput: contact_email: str slots_available: Optional[int] = strawberry.UNSET thumbnail: Optional[Upload] = strawberry.UNSET - skills: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + skills: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use skills_v2 instead."), + ] = strawberry.UNSET + skills_v2: Optional[List[str]] = strawberry.UNSET location: Optional[str] = strawberry.UNSET geolocation: Optional[GeolocationPointInput] = strawberry.UNSET contact_phone: Optional[str] = strawberry.UNSET @@ -91,7 +103,11 @@ class UpdateTaskInput: contact_email: str slots_available: Optional[int] = strawberry.UNSET thumbnail: Optional[Upload] = strawberry.UNSET - skills: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + skills: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use skills_v2 instead."), + ] = strawberry.UNSET + skills_v2: Optional[List[str]] = strawberry.UNSET location: Optional[str] = strawberry.UNSET geolocation: Optional[GeolocationPointInput] = strawberry.UNSET contact_phone: Optional[str] = strawberry.UNSET @@ -151,7 +167,11 @@ class UpdateSpaceInput: id: uuid.UUID title: Optional[str] = strawberry.UNSET description: Optional[str] = strawberry.UNSET - topics: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET + topics: Annotated[ + Optional[List[Optional[uuid.UUID]]], + strawberry.argument(description="Deprecated since 1.52. Use topics_v2 instead."), + ] = strawberry.UNSET + topics_v2: Optional[List[str]] = strawberry.UNSET cover: Optional[Upload] = strawberry.UNSET avatar: Optional[Upload] = strawberry.UNSET sdgs: Optional[List[Optional[uuid.UUID]]] = strawberry.UNSET @@ -189,7 +209,10 @@ class Task: location_type: TaskModel.LocationType location: strawberry.auto regularity: TaskModel.Regularity - skills: List[Skill] + skills: List[Skill] = strawberry_django.field( + deprecation_reason="Deprecated since version 1.52, use skills_v2 instead.", default=None + ) + skills_v2: List[str] thumbnail: Optional[str] thumbnail_blurhash: Optional[str] contact_email: str @@ -258,7 +281,10 @@ class Space: cover_blurhash: Optional[str] location: strawberry.auto links: List[Optional[ExternalLink]] - topics: List[Optional[Topic]] + topics: List[Optional[Topic]] = strawberry_django.field( + deprecation_reason="Deprecated since version 1.52, use topics_v2 instead.", default=None + ) + topics_v2: List[str] sdgs: List[Optional[SDG]] contact_description: Optional[str] contact_email: Optional[str] @@ -430,13 +456,20 @@ class Space: @strawberry.type class DiscoverSpaces: spaces: Paged[Space] - topic_id: Optional[uuid.UUID] = None + topic_id: Optional[uuid.UUID] = strawberry.field( + deprecation_reason="Deprecated since version 1.52, use topic_v2 instead", default=None + ) + topic_v2: Optional[str] = None location: Optional[str] = strawberry.field( - deprecation_reason="Unsupported since version 1.0.16, use location_id instead", + deprecation_reason="Deprecated since version 1.0.16, use location_id instead", default=None, ) location_id: Optional[str] = None - skill_id: Optional[uuid.UUID] = None + skill_id: Optional[uuid.UUID] = strawberry.field( + deprecation_reason="Deprecated since version 1.52, use skill_v2 instead", + default=None, + ) + skill_v2: Optional[str] = None @strawberry.type diff --git a/openbook_communities/serializers/communities_serializers.py b/openbook_communities/serializers/communities_serializers.py index 7300ffec50a8d9fa162f806f9f670ff45d2872ba..e4c2ce4832aa2c88c93a2f7997a479c4a6e067c4 100644 --- a/openbook_communities/serializers/communities_serializers.py +++ b/openbook_communities/serializers/communities_serializers.py @@ -7,7 +7,13 @@ from openbook_categories.validators import category_name_exists from openbook_common.serializers import CommonCommunityMembershipSerializer, GeolocationPointSerializer from openbook_common.serializers_fields.community import CommunityPostsCountField from openbook_common.serializers_fields.request import RestrictedImageFileSizeField -from openbook_common.validators import hex_color_validator, url_safety_validator, sanitize_text +from openbook_common.validators import ( + hex_color_validator, + url_safety_validator, + sanitize_text, + topic_v2_exists, + skill_v2_exists, +) from openbook_communities.models import CollaborationTool, Community, ExternalLinkType, Task, VisibilityType from openbook_communities.serializers_fields import ( IsInvitedField, @@ -78,9 +84,15 @@ class CreateCommunitySerializer(serializers.Serializer): topics = serializers.ListField( min_length=settings.COMMUNITY_TOPICS_MIN_AMOUNT, max_length=settings.COMMUNITY_TOPICS_MAX_AMOUNT, - required=True, + required=False, child=serializers.UUIDField(validators=[topic_with_id_exists]), ) + topics_v2 = serializers.ListField( + min_length=settings.COMMUNITY_TOPICS_MIN_AMOUNT, + max_length=settings.COMMUNITY_TOPICS_MAX_AMOUNT, + required=False, + child=serializers.CharField(validators=[topic_v2_exists]), + ) class ValidateCommunitySerializer(serializers.Serializer): @@ -98,6 +110,12 @@ class ValidateCommunitySerializer(serializers.Serializer): required=False, child=serializers.UUIDField(validators=[topic_with_id_exists]), ) + topics_v2 = serializers.ListField( + min_length=settings.COMMUNITY_TOPICS_MIN_AMOUNT, + max_length=settings.COMMUNITY_TOPICS_MAX_AMOUNT, + required=False, + child=serializers.CharField(validators=[topic_v2_exists]), + ) cover = RestrictedImageFileSizeField( allow_empty_file=False, required=False, @@ -141,6 +159,12 @@ class UpdateCommunitySerializer(serializers.Serializer): required=False, child=serializers.UUIDField(validators=[topic_with_id_exists]), ) + topics_v2 = serializers.ListField( + min_length=settings.COMMUNITY_TOPICS_MIN_AMOUNT, + max_length=settings.COMMUNITY_TOPICS_MAX_AMOUNT, + required=False, + child=serializers.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, validators=[topic_v2_exists]), + ) cover = RestrictedImageFileSizeField( allow_empty_file=False, allow_null=True, @@ -406,6 +430,12 @@ class CreateTaskSerializer(serializers.Serializer): required=False, child=serializers.UUIDField(validators=[skill_with_id_exists]), ) + skills_v2 = serializers.ListField( + min_length=settings.TASK_SKILLS_MIN_AMOUNT, + max_length=settings.TASK_SKILLS_MAX_AMOUNT, + required=False, + child=serializers.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, validators=[skill_v2_exists]), + ) thumbnail = RestrictedImageFileSizeField( allow_empty_file=False, required=False, @@ -455,6 +485,13 @@ class UpdateTaskSerializer(serializers.Serializer): required=False, child=serializers.UUIDField(validators=[skill_with_id_exists]), ) + skills_v2 = serializers.ListField( + min_length=settings.TASK_SKILLS_MIN_AMOUNT, + max_length=settings.TASK_SKILLS_MAX_AMOUNT, + required=False, + allow_empty=True, + child=serializers.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, validators=[skill_v2_exists]), + ) thumbnail = RestrictedImageFileSizeField( allow_empty_file=False, allow_null=True, diff --git a/openbook_communities/tests/test_graphql_communities.py b/openbook_communities/tests/test_graphql_communities.py index 834ff8b02aee303257be26e47c935e1303f0101e..32bdb2868d8a3742411b0ec13534f2cd14979787 100644 --- a/openbook_communities/tests/test_graphql_communities.py +++ b/openbook_communities/tests/test_graphql_communities.py @@ -30,10 +30,10 @@ from openbook_common.tests.helpers import ( make_fake_post_text, make_fake_post_title, make_sdg, - make_skill, - make_topic, + make_skills, make_user, simple_jpeg_bytes, + make_topics, ) from openbook_common.utils.model_loaders import ( get_community_admin_privileges_granted_notification_model, @@ -58,6 +58,7 @@ from openbook_communities.tests.helpers import ( from openbook_notifications.tests.helpers import get_notifications from openbook_posts.enums import VisibilityFilter from openbook_posts.tests.helpers import private_post_ids, public_post_ids +from openbook_terms.helpers import topics_to_topic_v2_slugs logger = logging.getLogger(__name__) @@ -77,8 +78,8 @@ def django_db_setup(django_db_setup, django_db_blocker): class TestCommunities(TestCase): def test_filters_for_spaces(self): creator = make_user() - topic1 = make_topic() - topic2 = make_topic() + [topic1, topic2] = make_topics(2) + topic1_v2 = topic1.slug # locations inside geolocation filter geolocation1 = geos.Point(8.5541567, 53.5101827, srid=4326) geolocation2 = geos.Point(8.5798387, 53.5590841, srid=4326) @@ -87,16 +88,15 @@ class TestCommunities(TestCase): space1 = make_community(creator=creator, topics=[topic1, topic2], geolocation=geolocation1) space2 = make_community(topics=[topic1], geolocation=geolocation2) space3 = make_community(topics=[topic2], geolocation=geolocation3) - skill1 = make_skill() - skill2 = make_skill() - skill3 = make_skill() + [skill1, skill2, skill3] = make_skills(3) + [skill1_v2, skill2_v2] = [skill1.slug, skill2.slug] make_community_task(space1, skills=[skill1, skill2]) make_community_task(space2, skills=[skill2, skill3]) make_community_task(space3, skills=[skill3]) filter_query = """ - query($topicId: UUID, $geolocation: GeoJSON, $skillIds: [UUID], $memberId: UUID, $topicSlug: String, $topicSlugs: [String]) { - spaces(topicId: $topicId, geolocation: $geolocation, skillIds: $skillIds, memberId: $memberId, topicSlug: $topicSlug, topicSlugs: $topicSlugs) { + query($topicId: UUID, $geolocation: GeoJSON, $skillIds: [UUID], $memberId: UUID, $topicSlug: String, $topicSlugs: [String], $topicsV2: [String!], $skillsV2: [String!]) { + spaces(topicId: $topicId, geolocation: $geolocation, skillIds: $skillIds, memberId: $memberId, topicSlug: $topicSlug, topicSlugs: $topicSlugs, topicsV2: $topicsV2, skillsV2: $skillsV2) { totalResults data { id @@ -120,6 +120,7 @@ class TestCommunities(TestCase): assert_filtered_spaces({"topicId": str(topic1.id)}, [space1, space2]) assert_filtered_spaces({"topicSlug": topic1.slug}, [space1, space2]) assert_filtered_spaces({"topicSlugs": [topic1.slug]}, [space1, space2]) + assert_filtered_spaces({"topicsV2": [topic1_v2]}, [space1, space2]) # topicSlug and topicSlugs can be combined assert_filtered_spaces({"topicSlugs": [topic1.slug], "topicSlug": topic2.slug}, [space1, space2, space3]) assert_filtered_spaces({"topicSlugs": [topic1.slug, topic2.slug]}, [space1, space2, space3]) @@ -138,6 +139,7 @@ class TestCommunities(TestCase): # Filter by multiple skills assert_filtered_spaces({"skillIds": [str(skill1.id), str(skill2.id)]}, [space1, space2]) + assert_filtered_spaces({"skillsV2": [skill1_v2, skill2_v2]}, [space1, space2]) # Filter by skills and location assert_filtered_spaces({"skillIds": [str(skill3.id)], "geolocation": geolocation}, [space2]) @@ -159,11 +161,20 @@ class TestCommunities(TestCase): [], ) + # Filter by skills V2, topics V2 and location + assert_filtered_spaces( + { + "skillsV2": [skill2_v2], + "topicsV2": [topic1_v2], + }, + [space1, space2], + ) + def test_filters_for_recommended_spaces(self): - topic1 = make_topic() - topic2 = make_topic() - default_topic = make_topic(is_discover_spaces_default=True) - uninteresting_topic = make_topic() + # skipping some default topics V2 + [default_topic, _, _, _, topic1, topic2, uninteresting_topic] = make_topics(7) + default_topic.is_discover_spaces_default = True + default_topic.save() # locations inside geolocation filter geolocation1 = geos.Point(8.5541567, 53.5101827, srid=4326) @@ -206,6 +217,7 @@ class TestCommunities(TestCase): user = make_user() user.profile.geolocation = geos.GEOSGeometry(json.dumps(geolocation_geometry)) user.profile.interests.add(topic1, topic2) + user.profile.interests_v2 = [t.slug for t in [topic1, topic2]] assert_filtered_spaces( user, [ @@ -217,6 +229,7 @@ class TestCommunities(TestCase): ) user = make_user() user.profile.interests.add(topic1) + user.profile.interests_v2 = [topic1.slug] assert_filtered_spaces( user, [ @@ -245,12 +258,15 @@ class TestCommunities(TestCase): ) def test_filters_for_recommended_spaces_paging(self): - topic1 = make_topic() - default_topic = make_topic(is_discover_spaces_default=True) - uninteresting_topic = make_topic() + # skipping some default topics V2 + [default_topic, _, _, _, _, topic1, uninteresting_topic] = make_topics(7) + default_topic.is_discover_spaces_default = True + default_topic.save() + user = make_user() user.profile.geolocation = geos.GEOSGeometry(json.dumps(geolocation_geometry)) user.profile.interests.add(topic1) + user.profile.interests_v2 = [topic1.slug] # locations inside geolocation filter geolocation1 = geos.Point(8.5541567, 53.5101827, srid=4326) @@ -282,10 +298,10 @@ class TestCommunities(TestCase): def transform_space(space): return {"id": str(space.id)} - def assert_filtered_spaces(user, filter_options, expected_spaces, total_results): + def assert_recommendended_spaces(user, paging_options, expected_spaces, total_results): filter_response = async_to_sync(schema.execute)( recommended_spaces_query, - variable_values=filter_options, + variable_values=paging_options, context_value=benedict({"request": {"user": user}}), ) assert filter_response.errors is None @@ -293,9 +309,9 @@ class TestCommunities(TestCase): assert filter_response.data["recommendedSpaces"]["data"] == expected_data assert filter_response.data["recommendedSpaces"]["totalResults"] == total_results - assert_filtered_spaces( + assert_recommendended_spaces( user, - {"topicIds": [str(topic1.id)], "geolocation": geolocation, "offset": 2, "limit": 3}, + {"offset": 2, "limit": 3}, [ topic1_inside_space1, uninteresting_topic_inside_space3, @@ -303,9 +319,9 @@ class TestCommunities(TestCase): ], 9, ) - assert_filtered_spaces( + assert_recommendended_spaces( user, - {"topicIds": [str(topic1.id)], "geolocation": geolocation, "offset": 2, "limit": 6}, + {"offset": 2, "limit": 6}, [ topic1_inside_space1, uninteresting_topic_inside_space3, @@ -316,9 +332,9 @@ class TestCommunities(TestCase): ], 9, ) - assert_filtered_spaces( + assert_recommendended_spaces( user, - {"topicIds": [str(topic1.id)], "geolocation": geolocation, "offset": 4, "limit": 3}, + {"offset": 4, "limit": 3}, [ uninteresting_topic_inside_space2, uninteresting_topic_inside_space1, @@ -326,9 +342,9 @@ class TestCommunities(TestCase): ], 9, ) - assert_filtered_spaces( + assert_recommendended_spaces( user, - {"topicIds": [str(topic1.id)], "geolocation": geolocation, "offset": 0, "limit": 20}, + {"offset": 0, "limit": 20}, [ topic1_inside_space3, topic1_inside_space2, @@ -342,16 +358,16 @@ class TestCommunities(TestCase): ], 9, ) - assert_filtered_spaces( + assert_recommendended_spaces( user, - {"topicIds": [str(topic1.id)], "geolocation": geolocation, "offset": 10, "limit": 20}, + {"offset": 10, "limit": 20}, [], 9, ) def test_filters_for_discover_spaces(self): - topic1 = make_topic() - topic2 = make_topic() + [topic1, topic2] = make_topics(2) + [topic1_v2, topic2_v2] = [topic1.slug, topic2.slug] # locations inside geolocation filter geolocation1 = geos.Point(8.5541567, 53.5101827, srid=4326) geolocation2 = geos.Point(8.5798387, 53.5590841, srid=4326) @@ -360,18 +376,19 @@ class TestCommunities(TestCase): space1 = make_community(topics=[topic1, topic2], geolocation=geolocation1) space2 = make_community(topics=[topic1], geolocation=geolocation2) space3 = make_community(topics=[topic2], geolocation=geolocation3) - make_community(topics=[topic1], geolocation=geolocation2, is_deleted=True) # deleted spaces are not be shown - skill1 = make_skill() - skill2 = make_skill() - skill3 = make_skill() + make_community(topics=[topic1], geolocation=geolocation2, is_deleted=True) # deleted spaces are not shown + [skill1, skill2, skill3] = make_skills(3) + skill3_v2 = skill3.slug make_community_task(space1, skills=[skill1, skill2]) make_community_task(space2, skills=[skill2, skill3]) filter_query = """ - query($topicIds: [UUID], $geolocation: GeoJSON, $skillIds: [UUID]) { - discoverSpaces(topicIds: $topicIds, geolocation: $geolocation, skillIds: $skillIds) { + query($topicIds: [UUID], $geolocation: GeoJSON, $skillIds: [UUID], $topicsV2: [String!], $skillsV2: [String!]) { + discoverSpaces(topicIds: $topicIds, geolocation: $geolocation, skillIds: $skillIds, topicsV2: $topicsV2, skillsV2: $skillsV2) { topicId + topicV2 skillId + skillV2 locationId spaces { totalResults @@ -400,10 +417,28 @@ class TestCommunities(TestCase): # Filter by common topic assert_filtered_spaces({"topicIds": [str(topic1.id)]}, [space1, space2]) + assert_filtered_spaces({"topicsV2": [topic1_v2]}, [space1, space2]) # Filter by unique topic assert_filtered_spaces({"topicIds": [str(topic2.id)]}, [space1, space3]) + assert_filtered_spaces({"topicsV2": [topic2_v2]}, [space1, space3]) + + # Filter by topics, skills and geolocation + filter_response = async_to_sync(schema.execute)( + filter_query, + variable_values={ + "topicIds": [str(topic1.id), str(topic2.id)], + "skillIds": [str(skill3.id)], + "geolocation": geolocation, + }, + context_value=benedict({"request": {"user": AnonymousUser}}), + ) + assert len(filter_response.data["discoverSpaces"]) == 4 + location, t1, t2, skill = ( + (0, 1, 2, 3) if filter_response.data["discoverSpaces"][1]["topicId"] == str(topic1.id) else (0, 2, 1, 3) + ) + # Filter by topics, skills and geolocation filter_response = async_to_sync(schema.execute)( filter_query, variable_values={ @@ -435,6 +470,38 @@ class TestCommunities(TestCase): list(map(transform_space, [space2])), ) + # Filter by topics V2, skills V2 and geolocation + filter_response = async_to_sync(schema.execute)( + filter_query, + variable_values={ + "topicsV2": [topic1_v2, topic2_v2], + "skillsV2": [skill3_v2], + "geolocation": geolocation, + }, + context_value=benedict({"request": {"user": AnonymousUser}}), + ) + assert len(filter_response.data["discoverSpaces"]) == 4 + location, t1, t2, skill = ( + (0, 1, 2, 3) if filter_response.data["discoverSpaces"][1]["topicV2"] == topic1_v2 else (0, 2, 1, 3) + ) + + assert_paged_results_equal( + filter_response.data["discoverSpaces"][location]["spaces"], + list(map(transform_space, [space1, space2])), + ) + assert_paged_results_equal( + filter_response.data["discoverSpaces"][t1]["spaces"], + list(map(transform_space, [space1, space2])), + ) + assert_paged_results_equal( + filter_response.data["discoverSpaces"][t2]["spaces"], + list(map(transform_space, [space1, space3])), + ) + assert_paged_results_equal( + filter_response.data["discoverSpaces"][skill]["spaces"], + list(map(transform_space, [space2])), + ) + def test_my_spaces(self): creator1 = make_user() creator2 = make_user() @@ -2532,7 +2599,9 @@ class TestCommunities(TestCase): def test_invalid_spaces_do_not_pass_validation(self): user = make_user() - topic_ids = [str(make_topic().id) for _ in range(7)] + topics = make_topics(7) + topic_ids = [str(t.id) for t in topics] + topics_v2 = topics_to_topic_v2_slugs(topics) empty_image = SimpleUploadedFile(name="cover.jpg", content="") validate_space_mutation = """ @@ -2566,6 +2635,14 @@ class TestCommunities(TestCase): "value": [invalid_uuid], "expected_error": f"No topic with the id '{invalid_uuid}' found.", }, + {"field": "topicsV2", "value": None, "expected_code": "null"}, + {"field": "topicsV2", "value": [], "expected_code": "min_length"}, + {"field": "topicsV2", "value": topics_v2, "expected_code": "max_length"}, + { + "field": "topicsV2", + "value": ["unknown"], + "expected_error": "TopicV2 'unknown' does not exist.", + }, {"field": "cover", "value": None, "expected_code": "null"}, {"field": "cover", "value": {}, "expected_code": "invalid"}, {"field": "cover", "value": "", "expected_code": "invalid"}, @@ -2582,18 +2659,25 @@ class TestCommunities(TestCase): variable_values={"input": valid_input | {testCase["field"]: testCase["value"]}}, ) - assert (response.errors, testCase) is not None + assert (response.errors, testCase) is not None, "Expected error" if "expected_code" in testCase: field_errors = json.loads(response.errors[0].extensions["errors"]) - assert testCase["expected_code"] == field_errors[testCase["field"]][0]["code"] + actual_code = field_errors[testCase["field"]][0]["code"] + assert testCase["expected_code"] == actual_code, ( + f"Expected error code {testCase['expected_code']} but got {actual_code} for input {testCase}" + ) else: - assert testCase["expected_error"] in str(response.errors[0].message) + assert testCase["expected_error"] in str(response.errors[0].message), ( + f"Expected error {testCase['expected_error']} but got {str(response.errors[0].message)} for input {testCase}" + ) assert response.data is None def test_valid_partial_spaces_pass_validation(self): user = make_user() valid_input = {"title": "valid", "description": "valid"} - topic_ids = [str(make_topic().id) for _ in range(2)] + topics = make_topics(2) + topic_ids = [str(t.id) for t in topics] + topics_v2 = topics_to_topic_v2_slugs(topics) cover = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) sdg_ids = [str(make_sdg().id) for _ in range(2)] location = "Hamburg, Germany" @@ -2613,6 +2697,8 @@ class TestCommunities(TestCase): valid_input | {"location": location}, valid_input | {"topics": topic_ids, "cover": cover, "sdgs": sdg_ids, "location": location}, valid_input | {"topics": topic_ids, "cover": cover, "sdgs": []}, + valid_input | {"topicsV2": topics_v2}, + valid_input | {"topicsV2": topics_v2, "cover": cover, "sdgs": []}, ] for input in valid_inputs: @@ -2649,8 +2735,10 @@ class TestCommunities(TestCase): self, mock_publish_space_created_event, mock_invalidate_discover_spaces_query_cache ): user = make_user() - topic_ids = [str(make_topic().id) for _ in range(7)] + topics = make_topics(7) + topic_ids = [str(t.id) for t in topics] valid_topics = topic_ids[:2] + valid_topics_v2 = topics_to_topic_v2_slugs(topics[:2]) valid_image = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) create_space_mutation = """ @@ -2668,7 +2756,7 @@ class TestCommunities(TestCase): }, { "input": {"title": "valid", "description": "valid", "cover": valid_image}, - "expected_error": "was not provided", + "expected_error": "Either topics or topics_v2 must be specified", }, { "input": {"title": "valid", "topics": valid_topics, "cover": valid_image}, @@ -2678,6 +2766,10 @@ class TestCommunities(TestCase): "input": {"description": "valid", "topics": valid_topics, "cover": valid_image}, "expected_error": "was not provided", }, + { + "input": {"description": "valid", "topicsV2": valid_topics_v2, "cover": valid_image}, + "expected_error": "was not provided", + }, ] for testCase in expectedErrors: @@ -2695,7 +2787,9 @@ class TestCommunities(TestCase): def test_validates_space_before_creation(self): user = make_user() - topic_ids = [str(make_topic().id) for _ in range(7)] + topics = make_topics(7) + topic_ids = [str(t.id) for t in topics] + topics_v2 = topics_to_topic_v2_slugs(topics) valid_topics = topic_ids[:2] valid_image = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) empty_image = SimpleUploadedFile(name="cover.jpg", content="") @@ -2722,7 +2816,6 @@ class TestCommunities(TestCase): "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1), "expected_code": "max_length", }, - {"field": "topics", "value": None, "expected_error": "Expected non-nullable type"}, {"field": "topics", "value": [], "expected_code": "min_length"}, {"field": "topics", "value": topic_ids, "expected_code": "max_length"}, { @@ -2730,6 +2823,13 @@ class TestCommunities(TestCase): "value": [invalid_uuid], "expected_error": f"No topic with the id '{invalid_uuid}' found.", }, + {"field": "topicsV2", "value": [], "expected_code": "min_length"}, + {"field": "topicsV2", "value": topics_v2, "expected_code": "max_length"}, + { + "field": "topicsV2", + "value": ["unknown"], + "expected_error": "TopicV2 'unknown' does not exist.", + }, {"field": "cover", "value": None, "expected_error": "Expected non-nullable type"}, {"field": "cover", "value": {}, "expected_code": "invalid"}, {"field": "cover", "value": "", "expected_code": "invalid"}, @@ -2757,7 +2857,9 @@ class TestCommunities(TestCase): @mock.patch("openbook_communities.schema.mutations.publish_space_created_event") def test_can_create_space(self, mock_publish_space_created_event, mock_invalidate_discover_spaces_query_cache): user = make_user() - topic_ids = [str(make_topic().id) for _ in range(2)] + topics = make_topics(2) + topic_ids = [str(t.id) for t in topics] + expected_topics_v2 = topics_to_topic_v2_slugs(topics) valid_image = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) create_space_mutation = """ @@ -2768,6 +2870,7 @@ class TestCommunities(TestCase): title description topics { id } + topicsV2 cover members { data { @@ -2797,6 +2900,7 @@ class TestCommunities(TestCase): for topic in created_space["topics"]: assert topic["id"] in topic_ids assert "cover.jpg" in created_space["cover"] + assert expected_topics_v2 == created_space["topicsV2"] # Verify that creator is admin assert 1 == len(created_space["members"]["data"]) @@ -2808,12 +2912,42 @@ class TestCommunities(TestCase): mock_publish_space_created_event.assert_called_once_with(community=community) mock_invalidate_discover_spaces_query_cache.assert_called_once() + @mock.patch("openbook_communities.schema.mutations.invalidate_discover_spaces_query_cache") + @mock.patch("openbook_communities.schema.mutations.publish_space_created_event") + def test_can_create_space_with_topics_v2( + self, mock_publish_space_created_event, mock_invalidate_discover_spaces_query_cache + ): + user = make_user() + topics_v2 = topics_to_topic_v2_slugs(make_topics(2)) + valid_image = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) + + create_space_mutation = """ + mutation test($input: CreateSpaceInput!) { + createSpace(input: $input) { + id + topicsV2 + } + } + """ + valid_input = {"title": "valid", "description": "valid", "topicsV2": topics_v2, "cover": valid_image} + + response = async_to_sync(schema.execute)( + create_space_mutation, + context_value=benedict({"request": {"user": user}}), + variable_values={"input": valid_input}, + ) + + assert response.errors is None + + created_space = response.data["createSpace"] + assert sorted(topics_v2) == sorted(created_space["topicsV2"]) + @mock.patch("openbook_communities.schema.mutations.invalidate_discover_spaces_query_cache") @mock.patch("openbook_communities.schema.mutations.publish_space_created_event") def test_can_not_create_space_if_not_logged_in( self, mock_publish_space_created_event, mock_invalidate_discover_spaces_query_cache ): - topic_ids = [str(make_topic().id) for _ in range(7)] + topic_ids = [str(t.id) for t in make_topics(7)] valid_topics = topic_ids[:2] valid_image = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) @@ -2839,7 +2973,9 @@ class TestCommunities(TestCase): def test_validates_space_update(self): user = make_user() community = make_community() - topic_ids = [str(make_topic().id) for _ in range(7)] + topics = make_topics(7) + topic_ids = [str(t.id) for t in topics] + topics_v2 = topics_to_topic_v2_slugs(topics) empty_image = SimpleUploadedFile(name="cover.jpg", content="") update_space_mutation = """ @@ -2871,6 +3007,13 @@ class TestCommunities(TestCase): "value": [invalid_uuid], "expected_error": f"No topic with the id '{invalid_uuid}' found.", }, + {"field": "topicsV2", "value": [], "expected_code": "min_length"}, + {"field": "topicsV2", "value": topics_v2, "expected_code": "max_length"}, + { + "field": "topicsV2", + "value": ["unknown"], + "expected_error": "TopicV2 'unknown' does not exist", + }, {"field": "cover", "value": {}, "expected_code": "invalid"}, {"field": "cover", "value": "", "expected_code": "invalid"}, {"field": "cover", "value": "foobar", "expected_code": "invalid"}, @@ -2954,7 +3097,9 @@ class TestCommunities(TestCase): def test_can_update_space(self, mock_invalidate_discover_spaces_query_cache): user = make_user() community = make_community(creator=user) - topic_ids = [str(make_topic().id) for _ in range(3)] + topics = make_topics(3) + topic_ids = [str(t.id) for t in topics] + topics_v2 = topics_to_topic_v2_slugs(topics) cover = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) cover2 = SimpleUploadedFile(name="cover2.jpg", content=simple_jpeg_bytes) avatar = SimpleUploadedFile(name="avatar.jpg", content=simple_jpeg_bytes) @@ -2973,6 +3118,7 @@ class TestCommunities(TestCase): {"title": "new title"}, {"description": "new description"}, {"topics": topic_ids[:2]}, + {"topicsV2": topics_v2[2:]}, {"cover": cover}, {"avatar": avatar}, {"sdgs": sdg_ids[:2]}, @@ -3008,7 +3154,9 @@ class TestCommunities(TestCase): def test_updated_space_has_new_values(self): user = make_user() community = make_community(creator=user) - topic_ids = [str(make_topic().id) for _ in range(2)] + topics = make_topics(2) + topic_ids = [str(t.id) for t in topics] + expected_topics_v2 = topics_to_topic_v2_slugs(topics) cover = SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes) avatar = SimpleUploadedFile(name="avatar.jpg", content=simple_jpeg_bytes) @@ -3025,6 +3173,7 @@ class TestCommunities(TestCase): title description topics { id } + topicsV2 cover coverBlurhash avatar @@ -3066,6 +3215,7 @@ class TestCommunities(TestCase): assert len(topic_ids) == len(updated_space["topics"]) for topic in updated_space["topics"]: assert topic["id"] in topic_ids + assert sorted(updated_space["topicsV2"]) == sorted(expected_topics_v2) assert "cover.jpg" in updated_space["cover"] assert "avatar.jpg" in updated_space["avatar"] assert blurhash_img == updated_space["coverBlurhash"] diff --git a/openbook_communities/tests/test_graphql_tasks.py b/openbook_communities/tests/test_graphql_tasks.py index 8d741fd20f9c1479aed67b4f5754c219c35e5fea..dd9dc871dea38bb308f9fd9ff849dc8467ec9c47 100644 --- a/openbook_communities/tests/test_graphql_tasks.py +++ b/openbook_communities/tests/test_graphql_tasks.py @@ -21,6 +21,7 @@ from openbook_common.tests.helpers import ( make_community, make_community_task, make_skill, + make_skills, make_user, simple_jpeg_bytes, assert_is_forbidden, @@ -217,6 +218,11 @@ class TestTasks: "value": [non_existent_skill_id], "expected_error": f"No skill with the id '{non_existent_skill_id}' found.", }, + { + "field": "skillsV2", + "value": ["unknown"], + "expected_error": "SkillV2 'unknown' does not exist.", + }, ] for testCase in expectedErrors: @@ -238,7 +244,8 @@ class TestTasks: community = make_community(creator=user) task = make_community_task(community) - skill = make_skill() + [skill, skill2] = make_skills(2) + expected_skills_v2 = [skill.slug] update_task_mutation = """ mutation test($input: UpdateTaskInput!) { @@ -249,6 +256,7 @@ class TestTasks: visibility thumbnail thumbnailBlurhash + skillsV2 } } """ @@ -289,6 +297,7 @@ class TestTasks: assert valid_visibility == updateTask["visibility"] assert "thumbnail.jpg" in updateTask["thumbnail"] assert updateTask["thumbnailBlurhash"] is not None + assert expected_skills_v2 == updateTask["skillsV2"] valid_name = "New valid name" valid_description = "New valid description" @@ -305,6 +314,7 @@ class TestTasks: "contactEmail": "email@email.com", "thumbnail": None, "slotsAvailable": 1, + "skillsV2": [skill2.slug], } response = async_to_sync(schema.execute)( @@ -322,6 +332,7 @@ class TestTasks: assert valid_visibility == updateTask["visibility"] assert updateTask["thumbnail"] is None assert updateTask["thumbnailBlurhash"] is None + assert [skill2.slug] == updateTask["skillsV2"] def test_can_update_members_only_task_if_collaborator(self): user = make_user() @@ -692,6 +703,11 @@ class TestTasks: "value": [non_existent_skill_id], "expected_error": f"No skill with the id '{non_existent_skill_id}' found.", }, + { + "field": "skillsV2", + "value": ["unknown"], + "expected_error": "SkillV2 'unknown' does not exist.", + }, ] for testCase in expectedErrors: @@ -713,6 +729,7 @@ class TestTasks: community = make_community(creator=user) skill = make_skill() + expected_skills_v2 = [skill.slug] create_task_mutation = """ mutation test($input: CreateTaskInput!) { @@ -721,6 +738,7 @@ class TestTasks: name description visibility + skillsV2 } } """ @@ -751,11 +769,56 @@ class TestTasks: assert response.errors is None - added_collaboration_tool = response.data["createTask"] - assert "id" in added_collaboration_tool - assert valid_name == added_collaboration_tool["name"] - assert valid_description == added_collaboration_tool["description"] - assert valid_visibility == added_collaboration_tool["visibility"] + created_task = response.data["createTask"] + assert "id" in created_task + assert valid_name == created_task["name"] + assert valid_description == created_task["description"] + assert valid_visibility == created_task["visibility"] + assert expected_skills_v2 == created_task["skillsV2"] + + def test_can_create_task_with_skills_v2(self): + user = make_user() + + community = make_community(creator=user) + skills_v2 = [skill.slug for skill in make_skills(2)] + + create_task_mutation = """ + mutation test($input: CreateTaskInput!) { + createTask(input: $input) { + id + skillsV2 + } + } + """ + + valid_name = "Valid name" + valid_description = "Valid description" + valid_visibility = "MEMBERS" + + valid_input = { + "spaceId": str(community.id), + "name": valid_name, + "description": valid_description, + "visibility": valid_visibility, + "locationType": "HYBRID", + "regularity": "REGULARLY", + "contactEmail": "email@email.com", + "skillsV2": skills_v2, + "slotsAvailable": 1, + "location": "Hamburg, Germany", + "contactPhone": "+49176176176176", + } + + response = async_to_sync(schema.execute)( + create_task_mutation, + context_value=benedict({"request": {"user": user}}), + variable_values={"input": valid_input}, + ) + + assert response.errors is None + + created_task = response.data["createTask"] + assert skills_v2 == created_task["skillsV2"] def test_can_create_members_only_task_if_collaborator(self): user = make_user() @@ -1171,9 +1234,7 @@ class TestTaskRecommendations: assert filter_response.data["recommendedTasks"]["totalResults"] == total_results def test_filters_for_recommended_tasks(self): - skill1 = make_skill() - skill2 = make_skill() - other_skill = make_skill() + [skill1, skill2, other_skill] = make_skills(3) # locations inside geolocation filter geolocation1 = geos.Point(8.5541567, 53.5101827, srid=4326) @@ -1224,6 +1285,7 @@ class TestTaskRecommendations: self.user.profile.skills.add(skill1) self.user.profile.skills.add(skill2) + self.user.profile.skills_v2 = [skill1.slug, skill2.slug] self.user.profile.geolocation = geos.GEOSGeometry(json.dumps(geolocation_geometry)) self.assert_filtered_tasks( @@ -1247,9 +1309,9 @@ class TestTaskRecommendations: ) def test_filters_for_recommended_tasks_paging(self): - skill1 = make_skill() - other_skill = make_skill() + [skill1, other_skill] = make_skills(2) self.user.profile.skills.add(skill1) + self.user.profile.skills_v2 = [skill1.slug] self.user.profile.geolocation = geos.GEOSGeometry(json.dumps(geolocation_geometry)) # locations inside geolocation filter diff --git a/openbook_communities/tests/test_space_event_tracking.py b/openbook_communities/tests/test_space_event_tracking.py index bb66df21881fe6ae5cfb662a9db378afa0928c0c..2786a8533831de03f705dc74bab70ddc945cbf1f 100644 --- a/openbook_communities/tests/test_space_event_tracking.py +++ b/openbook_communities/tests/test_space_event_tracking.py @@ -12,11 +12,11 @@ from openbook.graphql_schema import schema from openbook_common.tracking import initialize_tracking, PostHogConfig, TrackingEvent from openbook_common.tests.helpers import ( make_user, - make_topic, make_community as make_space, make_skill, make_community_task, simple_jpeg_bytes, + make_topics, ) from openbook_communities.tests.helpers import mutate_update_space_user_connection_data from openbook_communities.models import SpaceUserConnectionType @@ -194,7 +194,7 @@ class TestSpaceEvents(TestCase): input_create_space = { "title": "valid", "description": "valid", - "topics": [str(make_topic().id) for _ in range(2)], + "topics": [str(t.id) for t in make_topics(2)], "cover": (SimpleUploadedFile(name="cover.jpg", content=simple_jpeg_bytes)), } mock_track.reset_mock() diff --git a/openbook_insights/admin.py b/openbook_insights/admin.py index 2a5b70a7d91dfbd540ff7a9ad1e657823f28e4df..e516f05e4389ae5dd0587debdd733b7dc540973e 100644 --- a/openbook_insights/admin.py +++ b/openbook_insights/admin.py @@ -1,4 +1,7 @@ from django.contrib import admin +from django.forms import Select +from typing import get_args +from openbook_common.topics import TopicV2 from openbook_insights.models import Discussion, Initiative, Insight, Quote, Recommendation @@ -41,6 +44,13 @@ class InsightAdmin(admin.ModelAdmin): inlines = [InsightRecommendationInline, InsightQuoteInline, InsightInitiativeInline, DiscussionInline] + # Override the formfield_for_dbfield to customize the topic_v2 field widget + def formfield_for_dbfield(self, db_field, request, **kwargs): + if db_field.name == "topic_v2": + kwargs["widget"] = Select(choices=[("", "---------")] + list(zip(get_args(TopicV2), get_args(TopicV2)))) + kwargs["required"] = False + return super().formfield_for_dbfield(db_field, request, **kwargs) + @admin.display(description="Description Preview") def description_preview(self, obj): if (len(obj.description)) > 100: diff --git a/openbook_insights/migrations/0020_insight_topic_v2.py b/openbook_insights/migrations/0020_insight_topic_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..868d3dc0fcac5530ad405b322da4103c6c03a2e7 --- /dev/null +++ b/openbook_insights/migrations/0020_insight_topic_v2.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.12 on 2025-02-28 09:08 +from django.db import migrations, models + +from openbook_common.topics import slug_to_topic_v2 + + +def migrate_topics(apps, schema_editor): + Insight = apps.get_model("openbook_insights", "Insight") + + for insight in Insight.objects.all(): + if insight.topic is not None: + insight.topic_v2 = slug_to_topic_v2(insight.topic.slug) + insight.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("openbook_insights", "0019_insight_polls"), + ] + + operations = [ + migrations.AddField( + model_name="insight", + name="topic_v2", + field=models.CharField(blank=True, max_length=128, null=True), + ), + migrations.RunPython(migrate_topics), + ] diff --git a/openbook_insights/migrations/0021_alter_insight_topic_v2.py b/openbook_insights/migrations/0021_alter_insight_topic_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..5949f131fbd39c9ecb1dbb3706bcb206f492f277 --- /dev/null +++ b/openbook_insights/migrations/0021_alter_insight_topic_v2.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.12 on 2025-02-28 15:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("openbook_insights", "0020_insight_topic_v2"), + ] + + operations = [ + migrations.AlterField( + model_name="insight", + name="topic_v2", + field=models.CharField(blank=True, db_index=True, max_length=128, null=True), + ), + ] diff --git a/openbook_insights/migrations/tests/test_0020_insight_topic_v2.py b/openbook_insights/migrations/tests/test_0020_insight_topic_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..8eeaac48dbdee285c8e23bc7b716df51a163ba37 --- /dev/null +++ b/openbook_insights/migrations/tests/test_0020_insight_topic_v2.py @@ -0,0 +1,26 @@ +from importlib import import_module +import pytest +from django.apps import apps +from django.db import connection +from openbook_insights.models import Insight +from openbook_common.tests.helpers import make_insight, make_topic + +# Required because of file name starting with a number +migration = import_module("openbook_insights.migrations.0020_insight_topic_v2") + + +@pytest.mark.django_db +class TestMigrateInsightTopic: + def test_migrate_insight_topics(self): + # GIVEN an insight with only V1 topic + topic_v1 = make_topic(slug="eco-tourism") + insight = make_insight() + insight.topic = topic_v1 + insight.save() + + # WHEN migrating insight topics from V1 to V2 + migration.migrate_topics(apps, connection.schema_editor()) + + # THEN the insight has a mapped V2 topic + updated_insight = Insight.objects.get(id=insight.id) + assert updated_insight.topic_v2 == "lifestyle-consumption" diff --git a/openbook_insights/models/insight.py b/openbook_insights/models/insight.py index cfcbb4633aa079ac331816645914485961f1e158..c152d12dffcc2f8b361e433d574283b9eddebcc2 100644 --- a/openbook_insights/models/insight.py +++ b/openbook_insights/models/insight.py @@ -59,11 +59,10 @@ class Insight(ModelWithUUID): blank=True, null=True, ) + topic_v2 = models.CharField(max_length=settings.TERM_SLUG_MAX_LENGTH, blank=True, null=True, db_index=True) class Meta: - indexes = [ - models.Index(fields=["-date_published"]), - ] + indexes = [models.Index(fields=["-date_published"])] def save(self, *args, **kwargs): # Attempt to delete the cache matching the pattern diff --git a/openbook_insights/schema/types/insight.py b/openbook_insights/schema/types/insight.py index 156868a271b4f8d82f67398c0e75ad9faf859326..e05b656b8bf3550d13ccb06cbb0ee608bc232d43 100644 --- a/openbook_insights/schema/types/insight.py +++ b/openbook_insights/schema/types/insight.py @@ -35,7 +35,10 @@ class Insight: date_published: strawberry.auto header_image: Optional[str] header_image_blurhash: Optional[str] - topic: Optional[Topic] + topic: Optional[Topic] = strawberry_django.field( + deprecation_reason="Deprecated since version 1.52, use topic_v2 instead.", default=None + ) + topic_v2: Optional[str] = None @strawberry_django.field() def posts(self, info) -> List[Post]: diff --git a/openbook_terms/helpers.py b/openbook_terms/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..056d21e8a1ab05ac0dda0a6346a6453e28b214e3 --- /dev/null +++ b/openbook_terms/helpers.py @@ -0,0 +1,12 @@ +from typing import Iterable, List +from openbook_terms.models import Topic, Skill +from openbook_common.topics import slugs_to_topic_v2_slugs +from openbook_common.skills import slugs_to_skill_v2_slugs + + +def topics_to_topic_v2_slugs(topics: Iterable[Topic]) -> List[str]: + return slugs_to_topic_v2_slugs([topic.slug for topic in topics]) + + +def skills_to_skill_v2_slugs(skills: Iterable[Skill]) -> List[str]: + return slugs_to_skill_v2_slugs([skill.slug for skill in skills]) diff --git a/openbook_terms/schema/queries.py b/openbook_terms/schema/queries.py index 5268fa65ca2abad9f4460f45d398b11e330afb6a..727ac9fd8cd9b4ae6b099aedc998b5fbef065e1d 100644 --- a/openbook_terms/schema/queries.py +++ b/openbook_terms/schema/queries.py @@ -13,7 +13,9 @@ from openbook_terms.schema.types import SDG, Skill, Topic, FeedPostCategory @strawberry.type class Query: - @strawberry_django.field() + @strawberry_django.field( + deprecation_reason="Deprecated since 1.52. Use V2 instead, which is now a static hard coded set of slugs." + ) def topics(self, offset: int = 0, limit: int = 50) -> Paged[Topic]: return Paged.of(TopicModel.objects.all().order_by("title"), offset, limit) @@ -21,7 +23,9 @@ class Query: def sdgs(self, offset: int = 0, limit: int = 50) -> Paged[SDG]: return Paged.of(SDG_MODEL.objects.all().order_by("number"), offset, limit) - @strawberry_django.field() + @strawberry_django.field( + deprecation_reason="Deprecated since 1.52. Use V2 instead, which is now a static hard coded set of slugs." + ) def skills(self, offset: int = 0, limit: int = 50) -> Paged[Skill]: return Paged.of(SkillModel.objects.all().order_by("title"), offset, limit) diff --git a/requirements.txt b/requirements.txt index 47cb699b27f70a9515b2ddaaef73291067815d8b..42d0534515ebc0a31a519ddbf4a3bd6d1987ca14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,13 +7,13 @@ # Afterwards, some packages might need a downgrade or version tweak for the bundle fitting together and fitting the code adrf==0.1.9 aiofiles==24.1.0 -aiohappyeyeballs==2.5.0 +aiohappyeyeballs==2.6.1 aiohttp==3.11.13 aiosignal==1.3.2 ASGIMiddlewareStaticFile==0.6.1 asgiref==3.8.1 async-property==0.2.2 -attrs==25.1.0 +attrs==25.2.0 backoff==2.2.1 beautifulsoup4==4.13.3 black==25.1.0 @@ -25,7 +25,7 @@ charset-normalizer==3.4.1 colorama==0.4.6 coverage==7.6.12 Deprecated==1.2.18 -Django==5.0.12 +Django==5.0.13 django-admin-rangefilter==0.13.2 django-appconf==1.1.0 django-cacheops==7.1 @@ -49,15 +49,15 @@ Faker==12.0.1 # mixer 7.2.2 depends on Faker<12.1 and >=5.4.0 filelock==3.17.0 frozenlist==1.5.0 funcy==2.0 -google-api-core==2.24.1 +google-api-core==2.24.2 google-auth==2.38.0 google-cloud-pubsub==2.28.0 google-cloud-webrisk==1.17.0 -googleapis-common-protos==1.69.0 +googleapis-common-protos==1.69.1 graphql-core==3.2.6 grpc-google-iam-v1==0.14.1 -grpcio==1.70.0 -grpcio-status==1.70.0 +grpcio==1.71.0 +grpcio-status==1.71.0 h11==0.14.0 hiredis==3.1.0 icalendar==6.1.1 @@ -83,9 +83,9 @@ pilkit==3.0 pillow==11.1.0 platformdirs==4.3.6 pluggy==1.5.0 -posthog==3.18.1 +posthog==3.19.1 propcache==0.3.0 -proto-plus==1.26.0 +proto-plus==1.26.1 protobuf==5.29.3 psycopg==3.2.5 psycopg-binary==3.2.5 @@ -115,7 +115,7 @@ rq==2.1.0 rsa==4.9 ruamel.yaml==0.18.10 ruamel.yaml.clib==0.2.12 -ruff==0.9.9 +ruff==0.9.10 sentry-sdk==2.22.0 six==1.17.0 soupsieve==2.6 @@ -123,7 +123,7 @@ spinners==0.0.24 sqlparse==0.5.3 strawberry-graphql==0.248.1 strawberry-graphql-django==0.53.3 -structlog==25.1.0 +structlog==25.2.0 termcolor==2.5.0 text-unidecode==1.3 tldextract==5.1.3 diff --git a/smoketest/main.js b/smoketest/main.js index eb881fdb4ae7e1adfae3d246348f48f4248b133f..bbabce76b8eea96d5e75911dca86c891ae9040db 100644 --- a/smoketest/main.js +++ b/smoketest/main.js @@ -27,14 +27,14 @@ function forQuery(query, checkFunction) { // Define your smoketest(s) here. export default () => { - forQuery(`query{topics{totalResults}}`, (response) => { + forQuery('query{discoverSpacesDefaultTopicsV2}', (response) => { check(response, { 'is status 200': (r) => r.status === 200, }) check(JSON.parse(response.body), { // there can be multiple tests here, e.g. //"contains topics object": (r) => typeof r.data.topics != null, - 'contains totalResults count': (r) => typeof r.data.topics.totalResults == 'number', + 'contains topics V2': (r) => r.data.discoverSpacesDefaultTopicsV2.length > 1, }) }) } diff --git a/terraform/common/init.tf b/terraform/common/init.tf index 1c76b767fcd176e75b421ac88b0aaf5f003f4064..7b0a76b7346d88955a05608344f0bf193147cee4 100644 --- a/terraform/common/init.tf +++ b/terraform/common/init.tf @@ -4,11 +4,11 @@ terraform { required_providers { google = { source = "hashicorp/google" - version = "6.24.0" + version = "6.25.0" } google-beta = { source = "hashicorp/google-beta" - version = "6.24.0" + version = "6.25.0" } } backend "gcs" { diff --git a/terraform/environments/deployment.tf b/terraform/environments/deployment.tf index 53fd24a23bf8ce52d65df051b1b4650cb0aacb97..fa824a77d0cc5b8eae8031da157d02dca9d367b6 100644 --- a/terraform/environments/deployment.tf +++ b/terraform/environments/deployment.tf @@ -57,6 +57,7 @@ resource "google_cloud_run_v2_job" "okuna_migration" { task_count = 1 template { max_retries = 1 + timeout = "3600s" # 1 hour service_account = data.terraform_remote_state.holi_okuna_common_state.outputs.cloud_run_service_account_email containers { image = "${data.terraform_remote_state.holi_infra_state.outputs.artifact_registry_location}/holi-okuna:${var.image_tag}" diff --git a/terraform/environments/init.tf b/terraform/environments/init.tf index 85e6590c4d2906610cf0fa111d4a73ae5b62962f..c2c50d23912822210aa7fa27be48a4e680671531 100644 --- a/terraform/environments/init.tf +++ b/terraform/environments/init.tf @@ -4,11 +4,11 @@ terraform { required_providers { google = { source = "hashicorp/google" - version = "6.24.0" + version = "6.25.0" } google-beta = { source = "hashicorp/google-beta" - version = "6.24.0" + version = "6.25.0" } } backend "gcs" {