diff --git a/.python-version b/.python-version index 3e72aa69867656147f94eadbb5405bcc33adcc7c..2d4715b6a211083d26526ff357cd6b9a42fe16f7 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11.10 +3.11.11 diff --git a/anon_stats/__init__.py b/anon_stats/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/anon_stats/apps.py b/anon_stats/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..9ec723af6a8aefe6c595df8471f4782413019376 --- /dev/null +++ b/anon_stats/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnonStatsConfig(AppConfig): + name = "anon_stats" diff --git a/anon_stats/migrations/0001_initial.py b/anon_stats/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..9b61162e2394a8699a3dc40f67548260ac05cc3d --- /dev/null +++ b/anon_stats/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.10 on 2025-01-31 08:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Counter", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/anon_stats/migrations/__init__.py b/anon_stats/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/anon_stats/models.py b/anon_stats/models.py new file mode 100644 index 0000000000000000000000000000000000000000..e4ba262e5e9b4f8afa31dee162a308fed22c97da --- /dev/null +++ b/anon_stats/models.py @@ -0,0 +1,10 @@ +from datetime import datetime + +from django.db import models + + +# super simple model for a counter to simply insert a row with a counter name and timestamp +# to evaluate the counters value, a query needs to count the number of rows for the whole or a specific time frame +class Counter(models.Model): + name: str = models.CharField(max_length=255) + timestamp: datetime = models.DateTimeField(auto_now_add=True) diff --git a/anon_stats/schema/__init__.py b/anon_stats/schema/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/anon_stats/schema/mutations.py b/anon_stats/schema/mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..7fbcd27a22554ec2d75cfbce8f8f28a7322ecd3c --- /dev/null +++ b/anon_stats/schema/mutations.py @@ -0,0 +1,26 @@ +from datetime import datetime +from logging import Logger + +import strawberry +import structlog + +from anon_stats.models import Counter + +logger: Logger = structlog.get_logger(__name__) + + +# Create your tests here. +@strawberry.type +class Mutation: + @strawberry.mutation() + async def anon_count(self, counter_name: str) -> None: + # silently ignore if no counter_name was given + if not counter_name or counter_name.strip() == "": + return + # caller does not care about the result so we ignore any potential errors that could occur + # noinspection PyAsyncCall + try: + await Counter.objects.acreate(name=counter_name, timestamp=datetime.now()) + except Exception as e: + logger.warning(f"Silently ignoring exception while creating counter {e}", exc_info=True) + pass diff --git a/anon_stats/tests/__init__.py b/anon_stats/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/anon_stats/tests/test_mutations.py b/anon_stats/tests/test_mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..6e7503272ca44316bbe389d6a6d160eefb195b82 --- /dev/null +++ b/anon_stats/tests/test_mutations.py @@ -0,0 +1,58 @@ +import pytest +from asgiref.sync import async_to_sync + +from anon_stats.models import Counter +from openbook.graphql_schema import schema +from unittest import mock + + +@pytest.mark.django_db +class TestAnonCountMutation: + @staticmethod + def _increment_count(counter_name): + mutation = f""" + mutation {{ + anonCount(counterName: "{counter_name}") + }} + """ + + response = async_to_sync(schema.execute)(mutation) + assert response.errors is None + + @staticmethod + def _validate_counts(counter_name, expected_count): + counters = Counter.objects.filter(name=counter_name) + assert counters.exists() == (expected_count > 0) + assert counters.count() == expected_count, "Unexpected number of counts created" + for counter in counters: + assert counter.name == counter_name, "Counter name mismatch" + + def test_anon_count_creates_counter(self): + self._increment_count("test_counter") + self._validate_counts("test_counter", 1) + + self._increment_count("test_counter") + self._validate_counts("test_counter", 2) + + def test_anon_count_supports_multiple_counters(self): + self._increment_count("test_counter") + self._increment_count("other_test_counter") + self._validate_counts("test_counter", 1) + self._validate_counts("other_test_counter", 1) + + self._increment_count("other_test_counter") + self._validate_counts("other_test_counter", 2) + + def test_anon_count_empty_name(self): + self._increment_count(" ") + self._validate_counts("", 0) + self._validate_counts(" ", 0) + + @mock.patch("anon_stats.models.Counter.objects.acreate") + def test_anon_count_handles_database_exceptions(self, mock_acreate): + mock_acreate.side_effect = Exception("Database error") + + self._increment_count("test_counter") + + counters = Counter.objects.filter(name="test_counter") + assert not counters.exists(), "Counter should not be created when there is a database exception" diff --git a/feed_posts/schema/feed_queries.py b/feed_posts/schema/feed_queries.py index a4cd02e5cc68e74b71d879a7002e4e6d551e4cb9..2a876ab640fdfd7ca993be0aac83cac977db0394 100644 --- a/feed_posts/schema/feed_queries.py +++ b/feed_posts/schema/feed_queries.py @@ -85,6 +85,7 @@ class Query: reaction_count=Count("reactions"), total_comments=Subquery(space_comments_subquery, output_field=models.IntegerField()), ) + .select_related("community") .order_by("-created", "-reaction_count", "-total_comments") .distinct() ) @@ -119,99 +120,163 @@ class Query: reaction_count=Count("reactions"), total_comments=Coalesce(Subquery(feed_post_comments_subquery, output_field=models.IntegerField()), 0), ) + .select_related("creator") + .prefetch_related("topics") .order_by("-created_at", "-reaction_count", "-total_comments") .all() ) - user_interests = list(user.profile.interests.values_list("id", flat=True).all()) - + user_interests = list(user.profile.interests.values_list("id", flat=True)) user_connections = list(user.connections.values_list("target_user_id", flat=True)) - current_time = timezone.now() - # User recently created posts - recently_created_user_feed_posts = feed_posts.filter( - creator=user, created_at__gte=current_time - timedelta(hours=1) - ).all() + # Execute all queries in parallel while preserving order + from concurrent.futures import ThreadPoolExecutor - # User recently created space posts - recently_created_user_space_posts = ( - PostModel.objects.filter( - creator=user, status=PostModel.STATUS_PUBLISHED, created__gte=current_time - timedelta(hours=1) + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [] + + # User recently created posts + futures.append( + executor.submit( + lambda: list( + feed_posts.filter(creator=user, created_at__gte=current_time - timedelta(hours=1)).all() + ) + ) ) - .order_by("-created") - .distinct() - ) - # Co-founders - co_founder_feed_posts = [post for post in feed_posts if post.has_priority] - - # Editors priority posts - editors_priority_feed_posts = [ - post for post in feed_posts if post.created_at >= current_time - timedelta(days=post.priority_duration) - ] - - # User connections posts - user_connections_feed_posts = feed_posts.filter( - creator__id__in=user_connections, created_at__gte=current_time - priority_duration - ).all() - - # Space posts in spaces that the user is collaborator - space_posts_user_is_contributor = space_posts.filter( - community__memberships__user=user, created__gte=current_time - priority_duration - ).all() - - space_posts_user_is_follower = space_posts.filter( - community__notifications_subscriptions__subscriber=user, - community__notifications_subscriptions__new_post_notifications=True, - created__gte=current_time - priority_duration, - ).all() - - # Feed posts matching user's topics - matching_interest_feed_posts = feed_posts.filter( - topics__id__in=user_interests, created_at__gte=current_time - priority_duration - ).all() - - # Space posts matching user's topics - matching_interest_space_posts = space_posts.filter( - community__topics__id__in=user_interests, created__gte=current_time - priority_duration - ).all() - - # Editors posts - editors_feed_posts = feed_posts.filter( - creator__role=UserRole.EDITOR, created_at__gte=current_time - priority_duration - ).all() - - # Latest feed posts - latest_feed_posts = feed_posts.filter(creator=user, created_at__gte=current_time - timedelta(days=1)).all() - - # Latest space posts - latest_space_posts = ( - PostModel.objects.filter( - creator=user, status=PostModel.STATUS_PUBLISHED, created__gte=current_time - timedelta(days=1) + # User recently created space posts + futures.append( + executor.submit( + lambda: list( + PostModel.objects.filter( + creator=user, + status=PostModel.STATUS_PUBLISHED, + created__gte=current_time - timedelta(hours=1), + ) + .order_by("-created") + .distinct() + ) + ) ) - .order_by("-created") - .distinct() - ) - all_posts = OrderedDict() + # Co-founders + futures.append(executor.submit(lambda: [post for post in feed_posts if post.has_priority])) + + # Editors priority posts + futures.append( + executor.submit( + lambda: [ + post + for post in feed_posts + if post.created_at >= current_time - timedelta(days=post.priority_duration) + ] + ) + ) + + # User connections posts + futures.append( + executor.submit( + lambda: list( + feed_posts.filter( + creator__id__in=user_connections, created_at__gte=current_time - priority_duration + ).all() + ) + ) + ) + + # Space posts in spaces that the user is collaborator + futures.append( + executor.submit( + lambda: list( + space_posts.filter( + community__memberships__user=user, created__gte=current_time - priority_duration + ).all() + ) + ) + ) + + # Space posts user is follower of + futures.append( + executor.submit( + lambda: list( + space_posts.filter( + community__notifications_subscriptions__subscriber=user, + community__notifications_subscriptions__new_post_notifications=True, + created__gte=current_time - priority_duration, + ).all() + ) + ) + ) + + # Feed posts matching user's topics + futures.append( + executor.submit( + lambda: list( + feed_posts.filter( + topics__id__in=user_interests, created_at__gte=current_time - priority_duration + ).all() + ) + ) + ) + + # Space posts matching user's topics + futures.append( + executor.submit( + lambda: list( + space_posts.filter( + community__topics__id__in=user_interests, created__gte=current_time - priority_duration + ).all() + ) + ) + ) + + # Editors posts + futures.append( + executor.submit( + lambda: list( + feed_posts.filter( + creator__role=UserRole.EDITOR, created_at__gte=current_time - priority_duration + ).all() + ) + ) + ) - for post_list in [ - recently_created_user_feed_posts, - recently_created_user_space_posts, - co_founder_feed_posts, - editors_priority_feed_posts, - user_connections_feed_posts, - space_posts_user_is_contributor, - space_posts_user_is_follower, - matching_interest_feed_posts, - matching_interest_space_posts, - editors_feed_posts, - latest_feed_posts, - latest_space_posts, - feed_posts, - space_posts, - ]: + # Latest feed posts + futures.append( + executor.submit( + lambda: list( + feed_posts.filter(creator=user, created_at__gte=current_time - timedelta(days=1)).all() + ) + ) + ) + + # Latest space posts + futures.append( + executor.submit( + lambda: list( + PostModel.objects.filter( + creator=user, + status=PostModel.STATUS_PUBLISHED, + created__gte=current_time - timedelta(days=1), + ) + .order_by("-created") + .distinct() + ) + ) + ) + + # All feed posts + futures.append(executor.submit(lambda: list(feed_posts))) + + # All space posts + futures.append(executor.submit(lambda: list(space_posts))) + + # Get results in order + post_lists = [future.result() for future in futures] + + all_posts = OrderedDict() + for post_list in post_lists: for post in post_list: all_posts[post] = None diff --git a/openbook/graphql_schema.py b/openbook/graphql_schema.py index f70dae14f77ebd74d03cf2391cae05adf1589dcf..8ef282116cd0526dea4aa374e3625c883009d77f 100644 --- a/openbook/graphql_schema.py +++ b/openbook/graphql_schema.py @@ -16,6 +16,7 @@ from openbook.middleware.graphql_error_extension import GraphQLErrorExtension from openbook.settings.sentry_config import HoliSentryTracingExtension from openbook_appointments.schema import mutations as appointments_mutations from openbook_appointments.schema import queries as appointments_queries +from anon_stats.schema import mutations as anon_stats from openbook_auth.schema import mutations as auth_mutations from openbook_auth.schema import queries as auth_queries from openbook_common.schema import queries as commons_queries @@ -89,6 +90,7 @@ Query = merge_types( Mutation = merge_types( "Mutation", ( + anon_stats.Mutation, auth_mutations.Mutation, connections_mutations.Mutation, posts_mutations.Mutation, diff --git a/openbook/settings/__init__.py b/openbook/settings/__init__.py index bd847c47fab6977619802824e0121100e5eef786..c96871d447ce9aa12cfb92276fdcee9eadf50355 100644 --- a/openbook/settings/__init__.py +++ b/openbook/settings/__init__.py @@ -89,6 +89,7 @@ INSTALLED_APPS = [ "rangefilter", "django_rq", "django_extensions", + "anon_stats", "openbook_common", "openbook_auth", "openbook_posts", @@ -517,8 +518,6 @@ PROFILE_GEOLOCATION_ID_MAX_LENGTH = 255 PROFILE_ABOUT_ME_MAX_LENGTH = int(os.environ.get("PROFILE_ABOUT_ME_MAX_LENGTH", "3000")) PROFILE_AVATAR_MAX_SIZE = int(os.environ.get("PROFILE_AVATAR_MAX_SIZE", "10485760")) PROFILE_AVATAR_URL_MAX_LENGTH = 500 -PROFILE_COVER_MAX_SIZE = int(os.environ.get("PROFILE_COVER_MAX_SIZE", "10485760")) -PROFILE_COVER_URL_MAX_LENGTH = 500 PASSWORD_RESET_TIMEOUT_DAYS = 1 COMMUNITY_NAME_MAX_LENGTH = 80 COMMUNITY_TITLE_MIN_LENGTH = 2 diff --git a/openbook_auth/forms.py b/openbook_auth/forms.py index 14b537ed64ad341dc77398e4876744b3f75ce401..71f5cd1fd87a741dfa55e96bbd751faee4d8b32f 100644 --- a/openbook_auth/forms.py +++ b/openbook_auth/forms.py @@ -6,7 +6,6 @@ class UserProfileFormSet(BaseInlineFormSet): super(UserProfileFormSet, self).__init__(*args, **kwargs) for form in self.forms: form.fields["avatar"].required = False - form.fields["cover"].required = False form.fields["badges"].required = False form.fields["url"].required = False form.fields["engagement_level"].required = False diff --git a/openbook_auth/helpers.py b/openbook_auth/helpers.py index 86a6d2961509749745b016aab024950b95eee3cb..352bf444eee306cda860833a1b33055bebfd4e8b 100644 --- a/openbook_auth/helpers.py +++ b/openbook_auth/helpers.py @@ -11,9 +11,4 @@ def upload_to_user_avatar_directory(user_profile, filename): def upload_to_user_cover_directory(user_profile, filename): - return create_upload_filename( - username=user_profile.user.username, - object_type="user_cover", - object_id=user_profile.user.username, - filename=filename, - ) + pass # removed functionality still referenced in old migrations diff --git a/openbook_auth/migrations/0034_remove_userprofile_cover_and_more.py b/openbook_auth/migrations/0034_remove_userprofile_cover_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..ba112d8261ed6d90f478fc32fd0bdd79456ea6dc --- /dev/null +++ b/openbook_auth/migrations/0034_remove_userprofile_cover_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.10 on 2025-01-30 09:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("openbook_auth", "0033_alter_user_email"), + ] + + operations = [ + migrations.RemoveField( + model_name="userprofile", + name="cover", + ), + migrations.RemoveField( + model_name="userprofile", + name="cover_blurhash", + ), + ] diff --git a/openbook_auth/models.py b/openbook_auth/models.py index 38584889cd3d2f379c20bd5be89f490a1a9c9eda..905f06fe21099b34a348bc16af56f0fde5c9235a 100644 --- a/openbook_auth/models.py +++ b/openbook_auth/models.py @@ -32,10 +32,7 @@ from openbook_common.tracking import track, TrackingEvent from openbook.settings import USERBLOCK from openbook.utils import DEFAULT_EMAIL_LANGUAGE, get_supported_language_for_email from openbook_auth.checkers import * -from openbook_auth.helpers import ( - upload_to_user_avatar_directory, - upload_to_user_cover_directory, -) +from openbook_auth.helpers import upload_to_user_avatar_directory from openbook_auth.utils import normalize_spaces from openbook_common.enums import VisibilityType from openbook_common.helpers import ExifRotate, generate_blurhash, PrefixedQ @@ -647,22 +644,6 @@ class User(ModelWithUUID, AbstractUser): self.is_deleted = False self.save() - def _update_profile_cover(self, cover, save=True): - if cover is None: - self._delete_profile_cover(save=False) - else: - self.profile.cover = cover - self.profile.cover_blurhash = generate_blurhash(cover) - - if save: - self.profile.save() - - def _delete_profile_cover(self, save=True): - delete_file_field(self.profile.cover) - self.profile.cover = None - self.profile.cover_blurhash = None - self.profile.cover.delete(save=save) - def _update_profile_avatar(self, avatar, save=True): if self.profile.avatar: self._delete_profile_avatar(save=False) @@ -678,11 +659,6 @@ class User(ModelWithUUID, AbstractUser): self.profile.avatar_blurhash = None self.profile.avatar.delete(save=save) - def update_username(self, username): - check_username_not_taken(user=self, username=username) - self.username = username - self.save() - def update_password(self, password): self.set_password(password) self._reset_auth_token() @@ -694,11 +670,6 @@ class User(ModelWithUUID, AbstractUser): verify_token = self._make_email_verification_token_for_email(new_email=email) return verify_token - # engagement_level may only be set once - def update_engagement_level(self, engagement_level): - self.profile.engagement_level = engagement_level - self.profile.save() - def verify_email_with_token(self, token): new_email = check_email_verification_token_is_valid_for_email(user=self, email_verification_token=token) self.email = new_email @@ -805,7 +776,6 @@ class User(ModelWithUUID, AbstractUser): def update( self, - username=None, name=None, location=None, geolocation=None, @@ -815,8 +785,6 @@ class User(ModelWithUUID, AbstractUser): community_posts_visible=None, visibility=None, avatar=None, - cover=None, - remove_cover=False, last_name=None, pronouns=None, engagement_level=None, @@ -828,42 +796,39 @@ class User(ModelWithUUID, AbstractUser): remove_avatar=False, tracking_consent_analytics=None, tracking_consent_personalization=None, - ): - profile = self.profile - - if username: - self.update_username(username) - + ) -> None: if url is not None: if len(url) == 0: - profile.url = None + self.profile.url = None else: - profile.url = url + self.profile.url = url if name: - profile.name = normalize_spaces(name) + self.profile.name = normalize_spaces(name) + if last_name is not None: + self.profile.last_name = normalize_spaces(last_name) if update_location: if geolocation: - profile.location = location or geolocation.name - profile.geolocation = geolocation.geometry - profile.geolocation_id = geolocation.id + self.profile.location = location or geolocation.name + self.profile.geolocation = geolocation.geometry + self.profile.geolocation_id = geolocation.id else: - profile.location = location - profile.geolocation = None - profile.geolocation_id = None + self.profile.location = location + self.profile.geolocation = None + self.profile.geolocation_id = None if about_me is not None: if len(about_me) == 0: - profile.about_me = None + self.profile.about_me = None else: - profile.about_me = about_me + self.profile.about_me = about_me if followers_count_visible is not None: - profile.followers_count_visible = followers_count_visible + self.profile.followers_count_visible = followers_count_visible if community_posts_visible is not None: - profile.community_posts_visible = community_posts_visible + self.profile.community_posts_visible = community_posts_visible if visibility: if self.visibility == User.VISIBILITY_TYPE_PRIVATE and visibility != User.VISIBILITY_TYPE_PRIVATE: @@ -881,31 +846,24 @@ class User(ModelWithUUID, AbstractUser): if tracking_consent_personalization is not None: self.tracking_consent_personalization = tracking_consent_personalization - if last_name is not None: - profile.last_name = normalize_spaces(last_name) if pronouns is not None: - profile.pronouns = pronouns + self.profile.pronouns = pronouns if engagement_level: - self.update_engagement_level(engagement_level) + self.profile.engagement_level = engagement_level if interests is not None: - profile.interests.set(interests) + self.profile.interests.set(interests) if sdgs is not None: - profile.sdgs.set(sdgs) + self.profile.sdgs.set(sdgs) if skills is not None: - profile.skills.set(skills) + self.profile.skills.set(skills) if remove_avatar: self._delete_profile_avatar(save=False) elif avatar: self._update_profile_avatar(avatar, save=False) - if remove_cover: - self._delete_profile_cover(save=False) - elif cover: - self._update_profile_cover(cover, save=False) - if save: - profile.save() + self.profile.save() self.save() def update_notifications_settings( @@ -5259,22 +5217,6 @@ class UserProfile(ModelWithUUID): max_length=settings.IMAGE_URL_MAX_LENGTH, ) avatar_blurhash = models.CharField(max_length=50, blank=True, null=True) - cover = ProcessedImageField( - verbose_name=_("cover"), - blank=False, - null=True, - upload_to=upload_to_user_cover_directory, - processors=[ - ExifRotate(), - ResizeToFit( - width=settings.CONTENT_IMAGE_MAX_DIMENSION, - height=settings.CONTENT_IMAGE_MAX_DIMENSION, - upscale=False, - ), - ], - max_length=settings.IMAGE_URL_MAX_LENGTH, - ) - cover_blurhash = models.CharField(max_length=50, blank=True, null=True) about_me = models.TextField( _("about me"), max_length=settings.PROFILE_ABOUT_ME_MAX_LENGTH, diff --git a/openbook_auth/schema/mutations.py b/openbook_auth/schema/mutations.py index 024c217eb5cde308ae475da17f32aa1d5c30b056..69daf5bd9b40a53b24b164806ba28cd7313f536a 100644 --- a/openbook_auth/schema/mutations.py +++ b/openbook_auth/schema/mutations.py @@ -2,6 +2,7 @@ import uuid from copy import deepcopy from datetime import datetime, timedelta +from typing import Dict, List import requests import strawberry @@ -43,6 +44,28 @@ from openbook_notifications.services import NotificationsService logger = structlog.get_logger(__name__) +def _capture_user_fields(user: UserModel) -> Dict[str, str]: + # for many-to-many relations (interests, sdgs, skills), only capture ids to avoid db round-trips + return { + "email": user.email, + "firstName": user.profile.name, + "lastName": user.profile.last_name, + "location": user.profile.location, + "geolocation": user.profile.geolocation, + "aboutMe": user.profile.about_me, + "pronouns": user.profile.pronouns, + "engagementLevel": user.profile.engagement_level, + "interests": set(user.profile.interests.values_list("id", flat=True)), + "sdgs": set(user.profile.sdgs.values_list("id", flat=True)), + "skills": set(user.profile.skills.values_list("id", flat=True)), + "avatar": user.profile.avatar.url if user.profile.avatar else None, + } + + +def _get_updated_fields(o1: Dict[str, any], o2: Dict[str, any]) -> List[str]: + return [key for key in set(o1.keys()).union(o2.keys()) if o1.get(key) != o2.get(key)] + + @strawberry.type class Mutation: # TODO HOLI-8088 remove once not used by the frontend anymore @@ -71,6 +94,7 @@ class Mutation: data = serializer.validated_data remove_avatar = is_parameter_null(data, "avatar") + current_user_fields_before = _capture_user_fields(current_user) current_user.update( name=data.get("name"), @@ -90,6 +114,7 @@ class Mutation: tracking_consent_personalization=data.get("tracking_consent_personalization"), save=True, ) + current_user_fields_after = _capture_user_fields(current_user) generate_identity_when_needed(current_user) @@ -148,7 +173,9 @@ class Mutation: user=current_user, spaces=spaces, insights=insights ) - track(current_user, TrackingEvent("userUpdated")) + updated_fields = _get_updated_fields(current_user_fields_before, current_user_fields_after) + if updated_fields: + track(current_user, TrackingEvent("userUpdated", {"updatedFields": updated_fields})) cache.set(f"{settings.USER_CACHE_PREFIX}{current_user.username}", current_user, timeout=300) diff --git a/openbook_auth/schema/queries.py b/openbook_auth/schema/queries.py index 3adb8ea4be36151d65566523a39225570003bb84..59953ff6e04da48630997945d41e9cf9da3ac7c0 100644 --- a/openbook_auth/schema/queries.py +++ b/openbook_auth/schema/queries.py @@ -125,7 +125,7 @@ class Query: @strawberry_django.field() def connections_by_user_id(self, info, user_id: uuid.UUID, offset: int = 0, limit: int = 10) -> Paged[User]: verify_authorized_user(info) - user = UserModel.objects.get(id=user_id) + user = UserModel.objects.get(username=user_id) return get_ordered_user_connections(circle_id=user.connections_circle_id, offset=offset, limit=limit) @strawberry_django.field( diff --git a/openbook_auth/serializers/authenticated_user_serializers.py b/openbook_auth/serializers/authenticated_user_serializers.py index 800da24ec1be778c8039dd98c0021f9fb2da20c8..916dca8a17fffd90da1d81ef70e57b57ebe7f73f 100644 --- a/openbook_auth/serializers/authenticated_user_serializers.py +++ b/openbook_auth/serializers/authenticated_user_serializers.py @@ -37,12 +37,6 @@ class UpdateAuthenticatedUserSerializer(serializers.Serializer): allow_null=True, max_upload_size=settings.PROFILE_AVATAR_MAX_SIZE, ) - cover = RestrictedImageFileSizeField( - allow_empty_file=False, - required=False, - allow_null=True, - max_upload_size=settings.PROFILE_COVER_MAX_SIZE, - ) password = serializers.CharField( min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH, diff --git a/openbook_auth/serializers/users_serializers.py b/openbook_auth/serializers/users_serializers.py index 11c4232d4335a910fee93c5f5641dd04e5a5d033..58665b9188c75751e0839739f14ef13f8a4d37cd 100644 --- a/openbook_auth/serializers/users_serializers.py +++ b/openbook_auth/serializers/users_serializers.py @@ -59,7 +59,7 @@ class GetUserUserProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ("name", "avatar", "location", "cover", "about_me", "url", "badges") + fields = ("name", "avatar", "location", "about_me", "url", "badges") class GetUserUserCircleSerializer(serializers.ModelSerializer): diff --git a/openbook_auth/tests/media/.gitignore b/openbook_auth/tests/media/.gitignore deleted file mode 100644 index 86d0cb2726c6c7c179b99520c452dd1b68e7a813..0000000000000000000000000000000000000000 --- a/openbook_auth/tests/media/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/openbook_auth/tests/test_avatar.jpg b/openbook_auth/tests/test_avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9ee90f64c2be850e5ce23020fff92a0e95bfd7e5 Binary files /dev/null and b/openbook_auth/tests/test_avatar.jpg differ diff --git a/openbook_auth/tests/test_graphql.py b/openbook_auth/tests/test_graphql.py index 73c4af7384051766920f9b9d7e8f0bbe20c62edc..df86032a0a14a9009083598f67788808c802ddde 100644 --- a/openbook_auth/tests/test_graphql.py +++ b/openbook_auth/tests/test_graphql.py @@ -1,10 +1,10 @@ # ruff: noqa - +import os import json import logging -import sys import uuid from datetime import datetime, timedelta +from typing import Dict from unittest import mock from unittest.mock import call @@ -15,6 +15,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.contrib.gis import geos from django.core.management import call_command +from django.core.files.uploadedfile import SimpleUploadedFile from django.http import HttpRequest from django.test import AsyncClient from django.urls import reverse @@ -46,6 +47,9 @@ from openbook_common.tests.helpers import ( make_user, assert_paged_results_equal, assert_is_forbidden, + make_topic, + make_sdg, + make_skill, ) from openbook_common.tracking import TrackingEvent from openbook_communities.enums import UpdateConnectionAction @@ -418,7 +422,7 @@ class TestUsers: mock_create_or_update_subscriber.assert_called_once_with( user=user, locale=mock_get_language_from_request.return_value ) - mock_track.assert_called_once_with(user, TrackingEvent("userUpdated")) + 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") @@ -519,7 +523,89 @@ class TestUsers: mock_create_or_update_subscriber.assert_called_once_with( user=user, locale=mock_get_language_from_request.return_value ) - mock_track.assert_called_once_with(user, TrackingEvent("userUpdated")) + 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_user_updated_tracking_event( + 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, + ): + response = self.create_response(200) + mock_patch.return_value = response + topic = make_topic() + skill = make_skill() + 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({}) + 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"}) + mock_track.assert_not_called() + mock_track.reset_mock() + + update_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._validate_tracking_event_user_updated(mock_track, {"location", "geolocation"}) + mock_track.reset_mock() + + update_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._validate_tracking_event_user_updated(mock_track, {"interests", "sdgs", "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._validate_tracking_event_user_updated(mock_track, {"avatar"}) + mock_track.reset_mock() + + @staticmethod + def _validate_tracking_event_user_updated(mock_track, expected_updated_fields=None): + mock_track.assert_called_once() + user_arg, event_arg = mock_track.call_args.args + assert isinstance(event_arg, TrackingEvent) + assert event_arg.name == "userUpdated" + assert isinstance(event_arg.properties, dict) + assert isinstance(event_arg.properties["updatedFields"], list) + if expected_updated_fields: + assert set(event_arg.properties["updatedFields"]) == expected_updated_fields @mock.patch("openbook_auth.tasks.requests.put") @mock.patch("openbook_auth.tasks.requests.patch") diff --git a/openbook_auth/tests/test_user_model.py b/openbook_auth/tests/test_user_model.py index 139aa78393b532a015cd7e26613af36c184a9184..fa2925c7c3785edc9dba7bd1e14bf26194855b05 100644 --- a/openbook_auth/tests/test_user_model.py +++ b/openbook_auth/tests/test_user_model.py @@ -4,7 +4,7 @@ from django.test import TransactionTestCase from rest_framework.exceptions import PermissionDenied, ValidationError from comments.models import Comment -from openbook_auth.models import EngagementLevelChoice, User +from openbook_auth.models import User from openbook_common.enums import VisibilityType from openbook_common.tests.assertions import TestAssertionsMixin from openbook_common.tests.helpers import ( @@ -248,15 +248,6 @@ class UserModelTest(TestAssertionsMixin, TransactionTestCase): self.assertEqual(space.posts.count(), 0) - def test_update_engagement_level(self): - user = make_user() - - user.update_engagement_level(EngagementLevelChoice.INITIATOR) - self.assertEqual(user.profile.engagement_level, EngagementLevelChoice.INITIATOR) - - user.update_engagement_level(EngagementLevelChoice.INTERESTED) - self.assertEqual(user.profile.engagement_level, EngagementLevelChoice.INTERESTED) - def test_update_tracking_consent(self): user = make_user() diff --git a/openbook_common/helpers.py b/openbook_common/helpers.py index c5472a57bd5c7a6c01bad69b593f0b81f756eb5e..2faad837c16bfdcc84c4218452774db0713e4f10 100644 --- a/openbook_common/helpers.py +++ b/openbook_common/helpers.py @@ -6,21 +6,39 @@ from urllib.parse import urlparse import blurhash import requests import strawberry +from PIL import ExifTags, Image +from PIL.Image import Transpose from django.conf import settings from django.db.models import Q from langdetect import DetectorFactory -from langdetect.lang_detect_exception import LangDetectException -from PIL import ExifTags, Image -from PIL.Image import Transpose from urlextract import URLExtract from openbook.settings import ALERT_HOOK_URL -from openbook_common.utils.model_loaders import get_language_model # seed the language detector DetectorFactory.seed = 0 extractor = URLExtract() +stop_left = extractor.get_stop_chars_left() +stop_left.add("\u00a0") +stop_left.add("\u2007") +stop_left.add("\u202f") +stop_left.add("\u2060") +extractor.set_stop_chars_left(stop_left) + +stop_right = extractor.get_stop_chars_right() +stop_right.add("\u00a0") +stop_right.add("\u2007") +stop_right.add("\u202f") +stop_right.add("\u2060") +extractor.set_stop_chars_right(stop_right) + +after_tld = extractor.get_after_tld_chars() +after_tld.append("\u00a0") +after_tld.append("\u2007") +after_tld.append("\u202f") +after_tld.append("\u2060") +extractor.set_after_tld_chars(after_tld) def extract_urls_from_string(text): diff --git a/openbook_common/models.py b/openbook_common/models.py index 0cd1c2f921ab244b03224e6ac5ed0475084b533f..c46c2406696861cb2dbd2975609b89940462bd80 100644 --- a/openbook_common/models.py +++ b/openbook_common/models.py @@ -1,25 +1,26 @@ from __future__ import annotations + import uuid +from typing import List from urllib.parse import urlparse, urljoin, urlsplit +import requests +import tldextract +from bs4 import BeautifulSoup from django.conf import settings -from django.core.validators import RegexValidator from django.contrib.gis.db import models from django.contrib.gis.db.models import Q +from django.core.validators import RegexValidator from django.utils import timezone from django.utils.translation import gettext_lazy as _ from ordered_model.models import OrderedModel from requests import RequestException from openbook.settings import COLOR_ATTR_MAX_LENGTH -from openbook_common.helpers import extract_urls_from_string, attr_filled from openbook_common.enums import NeedUpdateType +from openbook_common.helpers import extract_urls_from_string, attr_filled from openbook_common.utils.helpers import normalize_url from openbook_common.validators import hex_color_validator -import tldextract -import requests -from bs4 import BeautifulSoup -from typing import List class ModelWithUUID(models.Model): @@ -172,7 +173,7 @@ class LinkPreview(ModelWithUUID): if link_urls: for link_url in link_urls: preview = cls.from_url(link_url) - if preview.has_data(): + if preview: previews.append(preview) return previews @@ -185,29 +186,39 @@ class LinkPreview(ModelWithUUID): ): return preview - html = LinkPreview._fetch_html_of_url(normalized_url) - og = cls.get_opengraph(html, url=normalized_url) preview.fetched_at = timezone.now() + html = LinkPreview._fetch_html_of_url(normalized_url) + if html: + og = cls.get_opengraph(html, url=normalized_url) - for field in cls.fields: - value = og.get(field[3:]) - if value is not None: - setattr(preview, field[3:], value) + for field in cls.fields: + value = og.get(field[3:]) + if value is not None: + setattr(preview, field[3:], value) - preview.icon = LinkPreview._fetch_icon(html, url=normalized_url) + preview.icon = LinkPreview._fetch_icon(html, url=normalized_url) + else: + if not created: + for field in cls.fields: + setattr(preview, field[3:], "") preview.save() return preview @staticmethod def _fetch_html_of_url(url: str) -> BeautifulSoup: - response = requests.get(url=url, timeout=10) - if hasattr(response, "apparent_encoding") and response.apparent_encoding: - response.encoding = response.apparent_encoding - else: - response.encoding = "utf-8" # Set to utf-8 by default if apparent_encoding is not found - - return BeautifulSoup(response.text, "html.parser") + try: + response = requests.get(url=url, timeout=10) + if not response.ok: + return None + if hasattr(response, "apparent_encoding") and response.apparent_encoding: + response.encoding = response.apparent_encoding + else: + response.encoding = "utf-8" # Set to utf-8 by default if apparent_encoding is not found + + return BeautifulSoup(response.text, "html.parser") + except requests.exceptions.RequestException: + return None @classmethod def get_opengraph(cls, html: BeautifulSoup, url: str) -> dict[str, str]: diff --git a/openbook_common/tests/helpers.py b/openbook_common/tests/helpers.py index 1949b684f7966d0a09f07b36b26635fa8e0c8acb..426fdef80930649534b55ae1ecaee1004c5ae2d3 100644 --- a/openbook_common/tests/helpers.py +++ b/openbook_common/tests/helpers.py @@ -85,6 +85,7 @@ def make_user( tracking_consent_personalization=False, role: UserRole = UserRole.REGULAR, is_employee: bool = False, + engagement_level=None, ) -> User: defined_arguments = {k: v for k, v in locals().items() if v is not None} defined_arguments.setdefault("username", str(uuid.uuid4())) @@ -184,14 +185,6 @@ def make_user_avatar(): return tmp_file -def make_user_cover(): - image = Image.new("RGB", (100, 100)) - tmp_file = tempfile.NamedTemporaryFile(suffix=".jpg") - image.save(tmp_file) - tmp_file.seek(0) - return tmp_file - - def make_post_image(): image = Image.new("RGB", (100, 100)) tmp_file = tempfile.NamedTemporaryFile(suffix=".jpg") @@ -272,11 +265,11 @@ def make_category(): return mixer.blend(Category) -def make_topic(is_discover_spaces_default=False): +def make_topic(is_discover_spaces_default=False) -> Topic: return mixer.blend(Topic, is_discover_spaces_default=is_discover_spaces_default) -def make_sdg(): +def make_sdg() -> SDG: return mixer.blend(SDG) @@ -290,7 +283,7 @@ def make_community_topics(): return community_topics -def make_skill(): +def make_skill() -> Skill: return mixer.blend(Skill) diff --git a/openbook_common/tests/test_link_preview.py b/openbook_common/tests/test_link_preview.py index 00afb0669fe33b6d4914078b4ad70633e5e826ae..b9fda69ad11b1286e2200f19ae8cb4e7a4a1be64 100644 --- a/openbook_common/tests/test_link_preview.py +++ b/openbook_common/tests/test_link_preview.py @@ -1,7 +1,9 @@ from unittest import mock +from django.conf import settings from django.test import TestCase from django.utils import timezone +from requests import RequestException from openbook_common.models import LinkPreview from openbook_common.utils.test import MockResponse @@ -16,13 +18,7 @@ class LinkPreviewTestCase(TestCase): self.assertIn(mock.call(url="http://www.abcd.de/", timeout=10), mock_get.call_args_list) self.assertEqual(1, len(mock_get.call_args_list)) self.assertEqual(preview, preview2) - self.assertEqual(preview.has_data(), False) - self.assertEqual(preview.requested_url, "http://www.abcd.de/") - self.assertEqual(preview.url, "") - self.assertEqual(preview.title, "") - self.assertEqual(preview.description, "") - self.assertEqual(preview.type, "") - self.assertEqual(preview.image, "") + self.assert_empty_preview(preview, "http://www.abcd.de/") self.assertTrue(timezone.now() - timezone.timedelta(seconds=3) <= preview.fetched_at <= timezone.now()) @mock.patch("requests.get") @@ -43,14 +39,17 @@ class LinkPreviewTestCase(TestCase): self.assertTrue(timezone.now() - timezone.timedelta(seconds=3) <= preview.fetched_at <= timezone.now()) @mock.patch("requests.get") - def test_preview_without_data_is_note_returned(self, mock_get): + def test_preview_without_data_gives_empty_preview(self, mock_get): mock_get.return_value = MockResponse("<html>no opengraph</html>") previews = LinkPreview.parse_text("www.abcd.de") previews2 = LinkPreview.parse_text("www.abcd.de") self.assertIn(mock.call(url="http://www.abcd.de/", timeout=10), mock_get.call_args_list) self.assertEqual(1, len(mock_get.call_args_list)) self.assertEqual(previews, previews2) - self.assertEqual(previews, []) + self.assertEqual(1, len(previews)) + preview = previews[0] + self.assert_empty_preview(preview, "http://www.abcd.de/") + self.assertTrue(timezone.now() - timezone.timedelta(seconds=3) <= preview.fetched_at <= timezone.now()) @mock.patch("requests.get") def test_page_with_opengraph_gives_preview(self, mock_get): @@ -129,3 +128,79 @@ class LinkPreviewTestCase(TestCase): self.assertEqual(preview2.description, "MySecondDescription") self.assertEqual(preview2.type, "other") self.assertEqual(preview2.image, "https://mysecond.url/secondimage.jpg") + + @mock.patch("requests.get") + def test_unsuccessfull_response(self, mock_get): + mock_get.return_value = MockResponse("", False) + self.parse_empty_response("www.my.de", mock_get) + + @mock.patch("requests.get") + def test_no_response(self, mock_get): + mock_get.side_effect = RequestException() + self.parse_empty_response("www.my.de", mock_get) + + def parse_empty_response(self, url, mock_get): + previews = LinkPreview.parse_text(url) + self.assertIn(mock.call(url=f"http://{url}/", timeout=10), mock_get.call_args_list) + self.assertEqual(1, len(previews)) + preview = previews[0] + self.assert_empty_preview(preview, f"http://{url}/") + + def assert_empty_preview(self, preview, url): + self.assertEqual(preview.has_data(), False) + self.assertEqual(preview.requested_url, url) + self.assertEqual(preview.url, "") + self.assertEqual(preview.title, "") + self.assertEqual(preview.description, "") + self.assertEqual(preview.type, "") + self.assertEqual(preview.image, "") + + @mock.patch("requests.get") + def test_previous_preview_no_longer_exists(self, mock_get): + mock_get.return_value = MockResponse( + """ + <html><head> + <meta property="og:url" content="https://my.url/xyz" /> + <meta property="og:type" content="website" /> + <meta property="og:title" content="MyTitle" /> + <meta property="og:site_name" content="MySite" /> + <meta property="og:description" content="MyDescription" /> + <meta property="og:image" content="https://www.my.url/image.jpg" /> + </head></html> + """ + ) + previews = LinkPreview.parse_text("www.my.de") + self.assertIn(mock.call(url="http://www.my.de/", timeout=10), mock_get.call_args_list) + self.assertEqual(1, len(previews)) + preview = previews[0] + preview.fetched_at = timezone.now() - timezone.timedelta(hours=(settings.LINK_PREVIEW_CACHE_TIME_H + 1)) + preview.save() + + mock_get.return_value = MockResponse("", False) + self.parse_empty_response("www.my.de", mock_get) + + @mock.patch("requests.get") + def test_non_breaking_space(self, mock_get): + mock_get.return_value = MockResponse( + """ + <html><head> + <meta property="og:url" content="https://my.url/xyz" /> + <meta property="og:type" content="website" /> + <meta property="og:title" content="MyTitle" /> + <meta property="og:site_name" content="MySite" /> + <meta property="og:description" content="MyDescription" /> + <meta property="og:image" content="https://www.my.url/image.jpg" /> + </head></html> + """ + ) + previews1 = LinkPreview.parse_text("www.my.de/\u00a0x") + previews2 = LinkPreview.parse_text("www.my.de\u00a0x") + self.assertIn(mock.call(url="http://www.my.de/", timeout=10), mock_get.call_args_list) + self.assertEqual(previews1, previews2) + self.assertEqual(1, len(previews1)) + preview = previews1[0] + self.assertEqual(preview.has_data(), True) + self.assertEqual(preview.requested_url, "http://www.my.de/") + self.assertEqual(preview.url, "https://my.url/xyz") + self.assertEqual(preview.title, "MyTitle") + preview.fetched_at = timezone.now() - timezone.timedelta(hours=(settings.LINK_PREVIEW_CACHE_TIME_H + 1)) diff --git a/openbook_insights/schema/queries.py b/openbook_insights/schema/queries.py index f58e6af893a7a26cbd19f0dd7c6dd41111a0e2df..fda3e0fd05c8898ac812c03f2dd7c73f557917fc 100644 --- a/openbook_insights/schema/queries.py +++ b/openbook_insights/schema/queries.py @@ -33,22 +33,20 @@ class Query: @strawberry_django.field() def last_insights(self, info, offset: int = 0, limit: int = 10) -> Paged[Insight]: current_user = info.context.request.user + + base_queryset = InsightModel.objects.filter( + Q(creator__isnull=True) | UserModel.is_visible_ignore_blocked_query("creator"), + date_published__lte=datetime.now(), + ).select_related("creator", "creator__profile", "topic") + + base_queryset = base_queryset.prefetch_related("posts") + + base_queryset = base_queryset.order_by("-date_published", "id") + paged_insights = UserBlock.paged( current_user, lambda insight: None if insight.creator is None else insight.creator.id, - InsightModel.objects.filter( - Q(creator__isnull=True) | UserModel.is_visible_ignore_blocked_query("creator"), - # resolution in cache and DB is day, so result will be cached until midnight - date_published__lte=datetime.now(), # timezone... - ) - .select_related("creator", "creator__profile", "topic") - .prefetch_related( - "posts", - "posts__creator", - "posts__comments__commenter", - ) - .order_by("-date_published", "id") - .cache(ops=["fetch", "count"], timeout=300), + base_queryset, offset, limit, ) diff --git a/openbook_invitations/serializers.py b/openbook_invitations/serializers.py index 0c115a3ba5bdc93b684c92fd56cd93aa4c006f28..b2611f203bc6dce293ad9fdbc225a90908e8dc42 100644 --- a/openbook_invitations/serializers.py +++ b/openbook_invitations/serializers.py @@ -30,7 +30,6 @@ class InvitedUserProfileSerializer(serializers.ModelSerializer): "about_me", "url", "location", - "cover", "is_of_legal_age", "followers_count_visible", "badges", diff --git a/openbook_posts/serializers/post_serializers.py b/openbook_posts/serializers/post_serializers.py index 79e41b64d6b815e55624a8aa436e5b7f6f475f82..8bc2dc6f1dea795e211e05e1e83405d0c904ea5b 100644 --- a/openbook_posts/serializers/post_serializers.py +++ b/openbook_posts/serializers/post_serializers.py @@ -78,7 +78,7 @@ class PostCreatorProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ("avatar", "cover", "badges", "name") + fields = ("avatar", "badges", "name") class PostImageSerializer(serializers.ModelSerializer): @@ -265,7 +265,7 @@ class PostParticipantProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ("avatar", "cover", "badges", "name") + fields = ("avatar", "badges", "name") class PostParticipantSerializer(serializers.ModelSerializer): diff --git a/requirements.txt b/requirements.txt index 5fb48e961eede005163a680613610894fd6ef829..100c827dd8a23265fbc0d75960dc0f0e38f43c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,18 +9,18 @@ adrf~=0.1.8 aiofiles~=24.1.0 aiohappyeyeballs~=2.4.3 -aiohttp~=3.10.10 +aiohttp~=3.11.11 aiosignal~=1.3.1 ASGIMiddlewareStaticFile~=0.6.1 asgiref~=3.8.1 async-property~=0.2.2 -attrs~=24.2.0 +attrs~=24.3.0 backoff~=2.2.1 -beautifulsoup4~=4.12.3 +beautifulsoup4~=4.13.0 black~=24.10.0 blurhash-python~=1.2.2 cachetools~=5.5.0 -certifi~=2024.8.30 +certifi~=2024.12.14 cffi~=1.17.1 charset-normalizer~=3.4.0 click~=8.1.7 @@ -42,29 +42,29 @@ django-modeltranslation~=0.19.10 django-ordered-model~=3.7.4 django-proxy~=1.3.0 django-redis~=5.4.0 -django-rq~=2.10.2 +django-rq~=3.0.0 django-sortedm2m~=4.0.0 django-structlog~=8.1.0 djangorestframework~=3.15.2 djangorestframework-camel-case~=1.4.2 execnet~=2.1.1 Faker~=12.0.1 # mixer 7.2.2 depends on Faker<12.1 and >=5.4.0 -filelock~=3.16.1 +filelock~=3.17.0 frozenlist~=1.5.0 funcy~=2.0 -google-api-core~=2.22.0 -google-auth~=2.36.0 -google-cloud-pubsub~=2.26.1 -google-cloud-webrisk~=1.15.0 -googleapis-common-protos~=1.65.0 +google-api-core~=2.24.1 +google-auth~=2.38.0 +google-cloud-pubsub~=2.28.0 +google-cloud-webrisk~=1.16.0 +googleapis-common-protos~=1.66.0 graphql-core~=3.2.5 grpc-google-iam-v1~=0.13.1 -grpcio~=1.67.0 -grpcio-status~=1.67.1 +grpcio~=1.70.0 +grpcio-status~=1.70.0 h11~=0.14.0 halo~=0.0.31 -hiredis~=3.0.0 -icalendar~=6.0.1 +hiredis~=3.1.0 +icalendar~=6.1.1 idna~=3.10 imagekitio==2.2.8 # version 3 contains many breaking changes importlib_metadata~=8.4.0 @@ -84,22 +84,22 @@ opentelemetry-semantic-conventions~=0.48b0 packaging~=24.1 pathspec~=0.12.1 pilkit~=3.0 -pillow~=11.0.0 +pillow~=11.1.0 platformdirs~=4.3.6 pluggy~=1.5.0 -posthog~=3.7.0 +posthog~=3.11.0 propcache~=0.2.0 -proto-plus~=1.25.0 -protobuf~=5.28.3 +proto-plus~=1.26.0 +protobuf~=5.29.3 psycopg~=3.2.3 psycopg-binary~=3.2.3 pyasn1~=0.6.1 pyasn1_modules~=0.4.1 pycparser~=2.22 -PyJWT~=2.9.0 +PyJWT~=2.10.1 pytest~=8.3.3 pytest-asyncio~=0.24.0 -pytest-cov~=5.0.0 +pytest-cov~=6.0.0 pytest-django~=4.9.0 pytest-xdist~=3.6.1 python-benedict~=0.34.0 @@ -113,15 +113,15 @@ pytz~=2024.2 redis~=5.2.0 requests~=2.32.3 requests-file~=2.1.0 -requests-toolbelt~=0.10.1 +requests-toolbelt~=1.0.0 rest-framework-generic-relations~=2.2.0 -rq~=1.16.2 +rq~=2.1.0 rsa~=4.9 ruamel.yaml~=0.18.6 ruamel.yaml.clib~=0.2.12 ruff~=0.7.1 -sentry-sdk~=2.17.0 -six~=1.16.0 +sentry-sdk~=2.20.0 +six~=1.17.0 soupsieve~=2.6 spinners~=0.0.24 sqlparse~=0.5.1 @@ -137,7 +137,7 @@ Unidecode~=1.3.8 uritools~=4.0.3 url-normalize~=1.4.3 urlextract~=1.9.0 -urllib3~=1.26.20 +urllib3~=2.3.0 uvicorn~=0.32.0 wrapt~=1.16.0 yarl~=1.17.1 diff --git a/templates/.docker-compose.env b/templates/.docker-compose.env index 305a79f69484ed8c85a2145032cd2d6ac1a4dcf3..1b25c4fc57013bc7ce9f2bb07598455ade3731b5 100644 --- a/templates/.docker-compose.env +++ b/templates/.docker-compose.env @@ -91,7 +91,6 @@ REDIS_PASSWORD={{REDIS_PASSWORD}} # [OPTIONAL] # POST_MEDIA_MAX_SIZE=30485760 # PROFILE_AVATAR_MAX_SIZE=10485760 -# PROFILE_COVER_MAX_SIZE=10485760 # COMMUNITY_AVATAR_MAX_SIZE=10485760 # COMMUNITY_COVER_MAX_SIZE=10485760