diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b890493692..ad4a1b7afe 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2069,6 +2069,16 @@ "failedToLoadAggregation": "Nepodařilo se načíst agregaci", "questionFallbackLabel": "Otázka {id}", "openInAggregationExplorer": "Otevřít v Průzkumníku agregací", + "tournamentFollowModalTitle": "Sledovat turnaj", + "tournamentFollowModalDescription": "Obdržíte oznámení o nových otázkách a konečném pořadí.", + "tournamentFollowModalAlsoFollowQuestions": "Sledovat také všechny otázky", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Automaticky sledovat každou otázku s výchozím nastavením oznámení. Nově přidané otázky budou také sledovány.", + "tournamentFollowModalSubmit": "Sledovat", + "tournamentUnfollowModalTitle": "Zrušit sledování turnaje", + "tournamentUnfollowModalDescription": "Už nebudete dostávat oznámení o tomto turnaji.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Zrušit sledování všech otázek", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Odstraňte sledování všech otázek v tomto turnaji.", + "tournamentUnfollowModalSubmit": "Zrušit sledování", "switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení", "view": "Zobrazit", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 8c167b41a2..5b00e5bf43 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -914,6 +914,16 @@ "followModalStatusChanges": "Status changes", "unfollowModalTitle": "Are you sure?", "unfollowModalDescription": "You won't be notified about this question anymore", + "tournamentFollowModalTitle": "Follow Tournament", + "tournamentFollowModalDescription": "You'll receive notifications about new questions and final rankings.", + "tournamentFollowModalAlsoFollowQuestions": "Also follow all questions", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Automatically follow each question with default notification settings. Newly added questions will also be followed.", + "tournamentFollowModalSubmit": "Follow", + "tournamentUnfollowModalTitle": "Unfollow Tournament", + "tournamentUnfollowModalDescription": "You will no longer receive notifications about this tournament.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Also unfollow all questions", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Remove your follows from all questions in this tournament.", + "tournamentUnfollowModalSubmit": "Unfollow", "best": "best", "cmmButton": "Changed my mind", "resolutionScores": "Resolution Scores", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 781d7c3a65..18ff4bd96c 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2069,6 +2069,16 @@ "failedToLoadAggregation": "No se pudo cargar la agregación", "questionFallbackLabel": "Pregunta {id}", "openInAggregationExplorer": "Abrir en el Explorador de Agregación", + "tournamentFollowModalTitle": "Seguir Torneo", + "tournamentFollowModalDescription": "Recibirás notificaciones sobre nuevas preguntas y clasificaciones finales.", + "tournamentFollowModalAlsoFollowQuestions": "Seguir también todas las preguntas", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Seguir automáticamente cada pregunta con la configuración de notificación predeterminada. Las preguntas recién añadidas también serán seguidas.", + "tournamentFollowModalSubmit": "Seguir", + "tournamentUnfollowModalTitle": "Dejar de Seguir Torneo", + "tournamentUnfollowModalDescription": "Ya no recibirás notificaciones sobre este torneo.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Dejar de seguir también todas las preguntas", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Elimina tus seguimientos de todas las preguntas en este torneo.", + "tournamentUnfollowModalSubmit": "Dejar de Seguir", "switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave", "view": "Ver", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 7d46728750..f31bf81d1f 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2067,6 +2067,16 @@ "failedToLoadAggregation": "Falha ao carregar agregação", "questionFallbackLabel": "Pergunta {id}", "openInAggregationExplorer": "Abrir no Explorador de Agregações", + "tournamentFollowModalTitle": "Seguir Torneio", + "tournamentFollowModalDescription": "Você receberá notificações sobre novas perguntas e classificações finais.", + "tournamentFollowModalAlsoFollowQuestions": "Seguir todas as perguntas", + "tournamentFollowModalAlsoFollowQuestionsDescription": "Siga automaticamente cada pergunta com as configurações padrão de notificação. Perguntas adicionadas recentemente também serão seguidas.", + "tournamentFollowModalSubmit": "Seguir", + "tournamentUnfollowModalTitle": "Deixar de Seguir Torneio", + "tournamentUnfollowModalDescription": "Você não receberá mais notificações sobre este torneio.", + "tournamentUnfollowModalAlsoUnfollowQuestions": "Deixar de seguir todas as perguntas", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "Remova seus seguimentos de todas as perguntas neste torneio.", + "tournamentUnfollowModalSubmit": "Deixar de Seguir", "switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave", "view": "Visualizar", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 2132a14e32..5366a19851 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2066,6 +2066,16 @@ "failedToLoadAggregation": "載入聚合失敗", "questionFallbackLabel": "問題 {id}", "openInAggregationExplorer": "在聚合探索器中開啟", + "tournamentFollowModalTitle": "關注比賽", + "tournamentFollowModalDescription": "您將會收到關於新問題和最終排名的通知。", + "tournamentFollowModalAlsoFollowQuestions": "亦關注所有問題", + "tournamentFollowModalAlsoFollowQuestionsDescription": "自動關注每個問題,並使用默認通知設置。新增的問題也將被關注。", + "tournamentFollowModalSubmit": "關注", + "tournamentUnfollowModalTitle": "取消關注比賽", + "tournamentUnfollowModalDescription": "您將不再收到有關此比賽的通知。", + "tournamentUnfollowModalAlsoUnfollowQuestions": "亦取消關注所有問題", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "從此比賽中的所有問題中移除您的關注。", + "tournamentUnfollowModalSubmit": "取消關注", "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", "thousandsOfOpenQuestions": "20,000+ 開放問題" diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index cd2c8290fe..3e36adf335 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2071,6 +2071,16 @@ "failedToLoadAggregation": "无法加载聚合", "questionFallbackLabel": "问题 {id}", "openInAggregationExplorer": "在聚合探索器中打开", + "tournamentFollowModalTitle": "关注比赛", + "tournamentFollowModalDescription": "您将收到关于新问题和最终排名的通知。", + "tournamentFollowModalAlsoFollowQuestions": "同时关注所有问题", + "tournamentFollowModalAlsoFollowQuestionsDescription": "根据默认通知设置自动关注每个问题。新添加的问题也会被关注。", + "tournamentFollowModalSubmit": "关注", + "tournamentUnfollowModalTitle": "取消关注比赛", + "tournamentUnfollowModalDescription": "您将不再收到关于此比赛的通知。", + "tournamentUnfollowModalAlsoUnfollowQuestions": "同时取消关注所有问题", + "tournamentUnfollowModalAlsoUnfollowQuestionsDescription": "取消您对此比赛中所有问题的关注。", + "tournamentUnfollowModalSubmit": "取消关注", "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", "thousandsOfOpenQuestions": "20,000+ 开放问题" diff --git a/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx b/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx index c1d71808e5..878fd74883 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/[slug]/actions.tsx @@ -53,10 +53,16 @@ export async function updateMember( } } -export async function subscribeProject(projectId: number) { - return ServerProjectsApi.subscribe(projectId); +export async function subscribeProject( + projectId: number, + params?: { follow_questions?: boolean } +) { + return ServerProjectsApi.subscribe(projectId, params); } -export async function unsubscribeProject(projectId: number) { - return ServerProjectsApi.unsubscribe(projectId); +export async function unsubscribeProject( + projectId: number, + params?: { unfollow_questions?: boolean } +) { + return ServerProjectsApi.unsubscribe(projectId, params); } diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx index 6369c745d9..b8b9cfcfc9 100644 --- a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_button.tsx @@ -8,6 +8,7 @@ import { subscribeProject, unsubscribeProject, } from "@/app/(main)/(tournaments)/tournament/[slug]/actions"; +import TournamentSubscribeModal from "@/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal"; import Button from "@/components/ui/button"; import { useModal } from "@/contexts/modal_context"; import { Tournament } from "@/types/projects"; @@ -23,41 +24,71 @@ const TournamentSubscribeButton: FC = ({ user, tournament }) => { const [isFollowing, setIsFollowing] = useState( () => tournament.is_subscribed ); + const [followQuestions, setFollowQuestions] = useState( + () => tournament.follow_questions ?? false + ); const { setCurrentModal } = useModal(); const [isLoading, setIsLoading] = useState(false); + const [modalMode, setModalMode] = useState<"follow" | "unfollow" | null>( + null + ); - const handleSubscribe = useCallback(async () => { + const handleFollowClick = useCallback(() => { if (!user) { setCurrentModal({ type: "signup" }); } else { - setIsLoading(true); + setModalMode("follow"); + } + }, [setCurrentModal, user]); + + const handleUnfollowClick = useCallback(() => { + setModalMode("unfollow"); + }, []); + const handleModalClose = useCallback(() => { + setModalMode(null); + }, []); + + const handleFollowSubmit = useCallback( + async (shouldFollowQuestions: boolean) => { + setIsLoading(true); try { - await subscribeProject(tournament.id); + await subscribeProject(tournament.id, { + follow_questions: shouldFollowQuestions, + }); setIsFollowing(true); + setFollowQuestions(shouldFollowQuestions); + setModalMode(null); } finally { setIsLoading(false); } - } - }, [setCurrentModal, tournament.id, user]); - - const handleUnsubscribe = useCallback(async () => { - setIsLoading(true); + }, + [tournament.id] + ); - try { - await unsubscribeProject(tournament.id); - setIsFollowing(false); - } finally { - setIsLoading(false); - } - }, [tournament.id]); + const handleUnfollowSubmit = useCallback( + async (shouldUnfollowQuestions: boolean) => { + setIsLoading(true); + try { + await unsubscribeProject(tournament.id, { + unfollow_questions: shouldUnfollowQuestions, + }); + setIsFollowing(false); + setFollowQuestions(false); + setModalMode(null); + } finally { + setIsLoading(false); + } + }, + [tournament.id] + ); return ( <> {user && isFollowing ? ( )} + + ); }; diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx new file mode 100644 index 0000000000..f7653327f9 --- /dev/null +++ b/front_end/src/app/(main)/(tournaments)/tournament/components/tournament_subscribe_modal.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useEffect, useState } from "react"; + +import BaseModal from "@/components/base_modal"; +import Button from "@/components/ui/button"; +import Checkbox from "@/components/ui/checkbox"; + +type Props = { + isOpen: boolean; + onClose: () => void; + mode: "follow" | "unfollow"; + defaultFollowQuestions?: boolean; + onSubmit: (followQuestions: boolean) => void; + isLoading: boolean; +}; + +const TournamentSubscribeModal: FC = ({ + isOpen, + onClose, + mode, + defaultFollowQuestions = false, + onSubmit, + isLoading, +}) => { + const t = useTranslations(); + const [followQuestions, setFollowQuestions] = useState( + defaultFollowQuestions + ); + + useEffect(() => { + setFollowQuestions(defaultFollowQuestions); + }, [defaultFollowQuestions]); + + const isFollow = mode === "follow"; + + return ( + onClose()} + label={ + isFollow + ? t("tournamentFollowModalTitle") + : t("tournamentUnfollowModalTitle") + } + className="max-w-sm" + > +

+ {isFollow + ? t("tournamentFollowModalDescription") + : t("tournamentUnfollowModalDescription")} +

+ +
+ +

+ {isFollow + ? t("tournamentFollowModalAlsoFollowQuestionsDescription") + : t("tournamentUnfollowModalAlsoUnfollowQuestionsDescription")} +

+
+ +
+ + +
+
+ ); +}; + +export default TournamentSubscribeModal; diff --git a/front_end/src/services/api/projects/projects.server.ts b/front_end/src/services/api/projects/projects.server.ts index 704e061761..00b3377119 100644 --- a/front_end/src/services/api/projects/projects.server.ts +++ b/front_end/src/services/api/projects/projects.server.ts @@ -33,12 +33,15 @@ class ServerProjectsApiClass extends ProjectsApi { ); } - async subscribe(projectId: number) { - return this.post(`/projects/${projectId}/subscribe/`, {}); + async subscribe(projectId: number, params?: { follow_questions?: boolean }) { + return this.post(`/projects/${projectId}/subscribe/`, params ?? {}); } - async unsubscribe(projectId: number) { - return this.post(`/projects/${projectId}/unsubscribe/`, {}); + async unsubscribe( + projectId: number, + params?: { unfollow_questions?: boolean } + ) { + return this.post(`/projects/${projectId}/unsubscribe/`, params ?? {}); } async updateCommunity( diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index 991278214a..9a8e303111 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -94,6 +94,7 @@ export type Tournament = TournamentPreview & { image_url: string; }; is_subscribed?: boolean; + follow_questions?: boolean; add_posts_to_main_feed: boolean; visibility: ProjectVisibility; default_permission?: ProjectPermissions | null; diff --git a/projects/migrations/0022_projectsubscription_follow_questions.py b/projects/migrations/0022_projectsubscription_follow_questions.py new file mode 100644 index 0000000000..5bc4672cf1 --- /dev/null +++ b/projects/migrations/0022_projectsubscription_follow_questions.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.9 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0021_projectindex_project_index_projectindexpost"), + ] + + operations = [ + migrations.AddField( + model_name="projectsubscription", + name="follow_questions", + field=models.BooleanField(default=False), + ), + ] diff --git a/projects/models.py b/projects/models.py index ce8effb058..d85e87f106 100644 --- a/projects/models.py +++ b/projects/models.py @@ -520,6 +520,7 @@ class ProjectSubscription(TimeStampedModel): project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="subscriptions" ) + follow_questions = models.BooleanField(default=False) class Meta: constraints = [ diff --git a/projects/services/subscriptions.py b/projects/services/subscriptions.py index 8a460ccbc0..e9df2f55c4 100644 --- a/projects/services/subscriptions.py +++ b/projects/services/subscriptions.py @@ -1,4 +1,4 @@ -from django.db import IntegrityError, transaction +from django.db import transaction from django.db.models import Q, Case, When, Value, BooleanField from notifications.constants import MailingTags @@ -9,7 +9,7 @@ NotificationQuestionParams, send_news_category_notebook_publish_notification, ) -from posts.models import Post, Notebook +from posts.models import Post, PostSubscription, Notebook from projects.models import Project, ProjectSubscription from projects.permissions import ObjectPermission from questions.constants import QuestionStatus @@ -17,25 +17,125 @@ from users.models import User -@transaction.atomic -def subscribe_project(project: Project, user: User): - obj = ProjectSubscription( - project=project, - user=user, +def _create_default_question_subscriptions(user: User, post: Post): + """ + Create default PostSubscription records for a question post. + Matches the defaults used in the frontend's getInitialQuestionSubscriptions. + """ + + from posts.services.subscriptions import ( + create_subscription_cp_change, + create_subscription_milestone, + create_subscription_new_comments, + create_subscription_status_change, ) - try: - obj.save() - except IntegrityError: - # Skip if use has been already subscribed - return + return [ + create_subscription_new_comments(user=user, post=post, comments_frequency=1), + create_subscription_status_change(user=user, post=post), + create_subscription_milestone(user=user, post=post, milestone_step=0.2), + create_subscription_cp_change(user=user, post=post, cp_change_threshold=0.25), + ] + + +def follow_all_project_questions(project: Project, user: User): + """ + Follow all questions in a project with default subscription settings. + Skips posts the user already has non-global subscriptions on. + """ + + posts = ( + Post.objects.filter_projects(project) + .filter_permission(user=user) + .filter_questions() + ) + + # Find posts user already follows (has non-global subscriptions) + already_followed_post_ids = set( + PostSubscription.objects.filter( + user=user, + post__in=posts, + is_global=False, + ) + .values_list("post_id", flat=True) + .distinct() + ) + + for post in posts: + if post.pk in already_followed_post_ids: + continue + _create_default_question_subscriptions(user, post) + + +def unfollow_all_project_questions(project: Project, user: User): + """ + Remove all non-global post subscriptions for questions in a project. + """ + + posts = ( + Post.objects.filter_projects(project) + .filter_permission(user=user) + .filter_questions() + ) + PostSubscription.objects.filter( + user=user, + post__in=posts, + is_global=False, + ).delete() + + +def follow_new_project_post(post: Post, project: Project): + """ + Auto-follow a newly added post for all project subscribers + who have follow_questions enabled. + """ + + subscriptions = ProjectSubscription.objects.filter( + project=project, + follow_questions=True, + ).select_related("user") + + for subscription in subscriptions: + user = subscription.user + # Check user has permission to view the post + if not Post.objects.filter_permission(user=user).filter(pk=post.pk).exists(): + continue + # Skip if user already has subscriptions on this post + if PostSubscription.objects.filter( + user=user, post=post, is_global=False + ).exists(): + continue + _create_default_question_subscriptions(user, post) + + +@transaction.atomic +def subscribe_project(project: Project, user: User, follow_questions: bool = False): + project_subscription = ProjectSubscription.objects.filter( + project=project, user=user + ).first() + if project_subscription: + # Update follow_questions if subscription already exists + project_subscription.follow_questions = follow_questions + project_subscription.save() + else: + ProjectSubscription.objects.create( + project=project, + user=user, + follow_questions=follow_questions, + ) project.update_followers_count() project.save() + if follow_questions: + follow_all_project_questions(project, user) + @transaction.atomic -def unsubscribe_project(project: Project, user: User): +def unsubscribe_project(project: Project, user: User, unfollow_questions: bool = False): + if unfollow_questions: + unfollow_all_project_questions(project, user) + ProjectSubscription.objects.filter(project=project, user=user).delete() project.update_followers_count() @@ -120,3 +220,7 @@ def notify_post_added_to_project(post: Post, project: Project): notify_project_subscriptions_post_open( post, notebook=post.notebook, project=project ) + + # Auto-follow new questions for subscribers with follow_questions enabled + if post.question_id or post.conditional_id or post.group_of_questions_id: + follow_new_project_post(post, project) diff --git a/projects/views/common.py b/projects/views/common.py index 9e6a7bf0cb..5f65606e87 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -236,7 +236,11 @@ def tournament_by_slug_api_view(request: Request, slug: str): data["followers_count"] = obj.followers_count if request.user.is_authenticated: - data["is_subscribed"] = obj.subscriptions.filter(user=request.user).exists() + subscription = obj.subscriptions.filter(user=request.user).first() + data["is_subscribed"] = subscription is not None + data["follow_questions"] = ( + subscription.follow_questions if subscription else False + ) if obj.index_id: data["index_data"] = serialize_index_data(obj.index) @@ -397,7 +401,10 @@ def project_subscribe_api_view(request: Request, pk: str): qs = get_projects_qs(user=request.user) project = get_object_or_404(qs, pk=pk) - subscribe_project(project=project, user=request.user) + follow_questions = request.data.get("follow_questions", False) + subscribe_project( + project=project, user=request.user, follow_questions=follow_questions + ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -407,7 +414,10 @@ def project_unsubscribe_api_view(request: Request, pk: str): qs = get_projects_qs(user=request.user) project = get_object_or_404(qs, pk=pk) - unsubscribe_project(project=project, user=request.user) + unfollow_questions = request.data.get("unfollow_questions", False) + unsubscribe_project( + project=project, user=request.user, unfollow_questions=unfollow_questions + ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/tests/unit/test_projects/test_services/test_follow_questions.py b/tests/unit/test_projects/test_services/test_follow_questions.py new file mode 100644 index 0000000000..8e0508aa40 --- /dev/null +++ b/tests/unit/test_projects/test_services/test_follow_questions.py @@ -0,0 +1,200 @@ +from datetime import datetime, timezone as dt_timezone + +from posts.models import PostSubscription +from projects.models import ProjectSubscription +from projects.permissions import ObjectPermission +from projects.services.subscriptions import ( + subscribe_project, + unsubscribe_project, + follow_new_project_post, +) +from questions.models import Question +from tests.unit.test_posts.factories import factory_post +from tests.unit.test_questions.factories import create_question +from tests.unit.test_users.factories import factory_user +from tests.unit.test_projects.factories import factory_project + + +def test_subscribe_with_follow_questions(user1): + """Subscribing with follow_questions=True creates PostSubscriptions for all questions.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + post1 = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + post1.update_pseudo_materialized_fields() + post2 = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + post2.update_pseudo_materialized_fields() + + subscribe_project(project=project, user=user1, follow_questions=True) + + # User should be subscribed to the project + sub = ProjectSubscription.objects.get(project=project, user=user1) + assert sub.follow_questions is True + + # User should have default subscriptions on both posts + for post in [post1, post2]: + post_subs = PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ) + sub_types = set(post_subs.values_list("type", flat=True)) + assert sub_types == { + PostSubscription.SubscriptionType.NEW_COMMENTS, + PostSubscription.SubscriptionType.STATUS_CHANGE, + PostSubscription.SubscriptionType.MILESTONE, + PostSubscription.SubscriptionType.CP_CHANGE, + } + + +def test_subscribe_without_follow_questions(user1): + """Subscribing without follow_questions does not create PostSubscriptions.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + subscribe_project(project=project, user=user1, follow_questions=False) + + assert ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert not PostSubscription.objects.filter(user=user1, is_global=False).exists() + + +def test_subscribe_follow_questions_skips_already_followed(user1): + """If user already follows a post, subscribing with follow_questions skips it.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + # Manually follow the post first + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.NEW_COMMENTS, + comments_frequency=5, + ) + + subscribe_project(project=project, user=user1, follow_questions=True) + + # Should still have only the original subscription (not overwritten with defaults) + post_subs = PostSubscription.objects.filter(user=user1, post=post, is_global=False) + assert post_subs.count() == 1 + assert post_subs.first().comments_frequency == 5 + + +def test_unsubscribe_with_unfollow_questions(user1): + """Unsubscribing with unfollow_questions=True removes all PostSubscriptions.""" + project = factory_project( + default_permission=ObjectPermission.FORECASTER, subscribers=[user1] + ) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + # Create some subscriptions + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.STATUS_CHANGE, + ) + + unsubscribe_project(project=project, user=user1, unfollow_questions=True) + + assert not ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert not PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ).exists() + + +def test_unsubscribe_without_unfollow_questions(user1): + """Unsubscribing without unfollow_questions keeps PostSubscriptions.""" + project = factory_project( + default_permission=ObjectPermission.FORECASTER, subscribers=[user1] + ) + post = factory_post( + author=factory_user(), + default_project=project, + question=create_question(question_type=Question.QuestionType.BINARY), + ) + + PostSubscription.objects.create( + user=user1, + post=post, + type=PostSubscription.SubscriptionType.STATUS_CHANGE, + ) + + unsubscribe_project(project=project, user=user1, unfollow_questions=False) + + assert not ProjectSubscription.objects.filter(project=project, user=user1).exists() + assert PostSubscription.objects.filter( + user=user1, post=post, is_global=False + ).exists() + + +def test_follow_new_project_post(user1, user2): + """When a new post is added, users with follow_questions get auto-subscribed.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + + # user1 subscribes with follow_questions + subscribe_project(project=project, user=user1, follow_questions=True) + # user2 subscribes without follow_questions + subscribe_project(project=project, user=user2, follow_questions=False) + + # New post added to project + new_post = factory_post( + author=factory_user(), + default_project=project, + question=create_question( + question_type=Question.QuestionType.BINARY, + open_time=datetime(2024, 1, 1, tzinfo=dt_timezone.utc), + scheduled_close_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + scheduled_resolve_time=datetime(2025, 1, 1, tzinfo=dt_timezone.utc), + ), + ) + new_post.update_pseudo_materialized_fields() + + follow_new_project_post(new_post, project) + + # user1 should have subscriptions on the new post + assert PostSubscription.objects.filter( + user=user1, post=new_post, is_global=False + ).exists() + + # user2 should not + assert not PostSubscription.objects.filter( + user=user2, post=new_post, is_global=False + ).exists() + + +def test_resubscribe_updates_follow_questions(user1): + """Re-subscribing updates the follow_questions preference.""" + project = factory_project(default_permission=ObjectPermission.FORECASTER) + + subscribe_project(project=project, user=user1, follow_questions=False) + sub = ProjectSubscription.objects.get(project=project, user=user1) + assert sub.follow_questions is False + + subscribe_project(project=project, user=user1, follow_questions=True) + sub.refresh_from_db() + assert sub.follow_questions is True