@@ -223,7 +223,15 @@ def visible_to_user(
223223 )
224224 return queryset .filter (anon_filter ).distinct ().order_by ("-created" )
225225
226- # Get explicitly permitted conversation IDs via guardian
226+ # Get explicitly permitted conversation IDs via guardian (user- AND
227+ # group-level READ grants). ``ConversationManager.user_can(READ)``
228+ # routes back through this queryset, so the filter IS the check — but
229+ # non-READ writes delegate to ``_default_user_can``, which honours group
230+ # grants by default. Consulting only the USER object-permission table
231+ # here therefore made a group-only READ grant entirely non-functional:
232+ # invisible in lists AND denied by ``user_can(READ)`` (issue #1986
233+ # item 3 — the same filter/check group-grant gap PR #1985 closed for
234+ # annotations / relationships / extracts).
227235 model_name = self .model ._meta .model_name
228236 app_label = self .model ._meta .app_label
229237
@@ -236,9 +244,28 @@ def visible_to_user(
236244 except LookupError :
237245 permitted_ids = []
238246
247+ # Group-level READ grants, resolved in their own ``try`` so a missing
248+ # group table never discards the already-resolved user-level grants.
249+ # The lazy ``values_list`` keeps both lookups as SQL subqueries
250+ # (no extra round-trips).
251+ try :
252+ group_permission_model_name = f"{ model_name } groupobjectpermission"
253+ group_permission_model_type = apps .get_model (
254+ app_label , group_permission_model_name
255+ )
256+ group_permitted_ids = group_permission_model_type .objects .filter (
257+ permission__codename = f"read_{ model_name } " ,
258+ group_id__in = user .groups .values_list ("id" , flat = True ),
259+ ).values_list ("content_object_id" , flat = True )
260+ except LookupError :
261+ group_permitted_ids = []
262+
239263 # Base conditions: apply to BOTH CHAT and THREAD types
240264 base_conditions = (
241- Q (creator_id = user .id ) | Q (is_public = True ) | Q (id__in = permitted_ids )
265+ Q (creator_id = user .id )
266+ | Q (is_public = True )
267+ | Q (id__in = permitted_ids )
268+ | Q (id__in = group_permitted_ids )
242269 )
243270
244271 # CHAT type: base conditions only (restrictive)
@@ -374,9 +401,25 @@ def visible_to_user(
374401
375402 # Superusers are computed like any other user (scoped admin access, 2026-05) — no blanket bypass.
376403
377- # Anonymous users only see messages in public conversations
404+ # Anonymous users see messages only in conversations THEY can see.
405+ # Delegating to ``Conversation.objects.visible_to_user`` keeps the
406+ # CHAT/THREAD bifurcation the single source of truth (issue #1986
407+ # item 2): for an anonymous viewer that is public THREADs plus context
408+ # inheritance on public corpuses/documents, and NEVER a CHAT. The
409+ # previous ``conversation__is_public=True`` shortcut both leaked a
410+ # public *CHAT*'s messages (the conversation itself stays hidden via
411+ # ``ConversationQuerySet.visible_to_user``, so the messages were the
412+ # odd surface out) and missed messages in context-inherited
413+ # public-resource threads. This mirrors the authenticated branch
414+ # below, which already routes message visibility through conversation
415+ # visibility.
378416 if user .is_anonymous :
379- return queryset .filter (conversation__is_public = True ).order_by ("created" )
417+ visible_conversation_ids = Conversation .objects .visible_to_user (
418+ user
419+ ).values_list ("id" , flat = True )
420+ return queryset .filter (
421+ conversation_id__in = visible_conversation_ids
422+ ).order_by ("created" )
380423
381424 # Primary visibility: messages in visible conversations
382425 # This inherits the bifurcated CHAT/THREAD permission logic
@@ -386,7 +429,12 @@ def visible_to_user(
386429 ).values_list ("id" , flat = True )
387430 conversation_visible = Q (conversation_id__in = visible_conversation_ids )
388431
389- # Additional conditions for explicit message-level access
432+ # Additional conditions for explicit message-level access (user- AND
433+ # group-level guardian grants). Adding the group branch mirrors the
434+ # conversation queryset's group-grant fix (issue #1986 item 3) so a
435+ # message shared via a Django group is visible in lists, not only to
436+ # direct user grantees. Both branches preserve the existing
437+ # any-message-permission shape (a grant of any codename implies READ).
390438 from django .apps import apps
391439
392440 try :
@@ -401,9 +449,22 @@ def visible_to_user(
401449 except LookupError :
402450 has_permission = Q (pk__in = [])
403451
452+ try :
453+ group_permission_model = apps .get_model (
454+ "conversations" , "chatmessagegroupobjectpermission"
455+ )
456+ has_group_permission = Q (
457+ id__in = group_permission_model .objects .filter (
458+ group_id__in = user .groups .values_list ("id" , flat = True )
459+ ).values_list ("content_object_id" , flat = True )
460+ )
461+ except LookupError :
462+ has_group_permission = Q (pk__in = [])
463+
404464 base_conditions = (
405465 Q (creator = user ) # User created the message
406- | has_permission # User has explicit permission on message
466+ | has_permission # User has explicit user-level permission
467+ | has_group_permission # User has a group-level permission
407468 )
408469
409470 # Moderator conditions: user can moderate the conversation
0 commit comments