Skip to content

Commit f5858fe

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/pratiksha-chat-with-dashboards-impl
2 parents 9de7126 + 393cba8 commit f5858fe

5 files changed

Lines changed: 122 additions & 26 deletions

File tree

ddpui/core/reports/comment_service.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,18 +166,37 @@ def delete_comment(
166166
org: Org,
167167
orguser: OrgUser,
168168
) -> None:
169-
"""Soft-delete a comment. Author-only."""
169+
"""Delete a comment. Author-only.
170+
171+
Hard-deletes if no other user has commented in the thread (same
172+
snapshot + target_type + chart_id). Soft-deletes otherwise so the
173+
"This message was deleted" placeholder is shown alongside others'
174+
comments.
175+
"""
170176
comment = CommentService._get_comment(comment_id, org)
171177

172178
if comment.author != orguser:
173179
raise CommentPermissionError("You can only delete your own comments")
174180

175-
comment.is_deleted = True
176-
comment.content = ""
177-
comment.mentioned_emails = []
178-
comment.save()
179-
180-
logger.info(f"Soft-deleted comment {comment_id}")
181+
# Check if another author has commented in this thread
182+
thread_query = Q(
183+
snapshot=comment.snapshot,
184+
target_type=comment.target_type,
185+
)
186+
if comment.target_type == CommentTargetType.CHART:
187+
thread_query &= Q(snapshot_chart_id=comment.snapshot_chart_id)
188+
189+
has_other_authors = Comment.objects.filter(thread_query).exclude(author=orguser).exists()
190+
191+
if has_other_authors:
192+
comment.is_deleted = True
193+
comment.content = ""
194+
comment.mentioned_emails = []
195+
comment.save()
196+
logger.info(f"Soft-deleted comment {comment_id}")
197+
else:
198+
comment.delete()
199+
logger.info(f"Hard-deleted comment {comment_id}")
181200

182201
# language=SQL
183202
_COMMENT_STATES_SQL = """

ddpui/tests/api_tests/test_comment_api.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -494,14 +494,12 @@ def test_delete_own(self, mock_mentions, orguser, snapshot, org):
494494
author=orguser,
495495
org=org,
496496
)
497+
comment_id = comment.id
497498
request = mock_request(orguser)
498-
response = delete_comment(request, snapshot.id, comment.id)
499+
response = delete_comment(request, snapshot.id, comment_id)
499500
assert response["success"] is True
500-
comment.refresh_from_db()
501-
assert comment.is_deleted is True
502-
assert comment.content == ""
503-
assert comment.mentioned_emails == []
504-
comment.delete()
501+
# sole author in thread => hard-delete
502+
assert not Comment.objects.filter(id=comment_id).exists()
505503

506504
def test_delete_other_forbidden(self, orguser, other_orguser, snapshot, org):
507505
comment = Comment.objects.create(

ddpui/tests/core/reports/test_comment_service_mutations.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ def test_not_found_raises(self, org, author_orguser):
209209
class TestDeleteComment:
210210
"""Tests for CommentService.delete_comment"""
211211

212-
def test_success(self, snapshot, author_orguser, org):
212+
def test_hard_deletes_sole_comment(self, snapshot, author_orguser, org):
213+
"""Only comment in thread, only author — hard-delete."""
213214
comment = Comment.objects.create(
214215
target_type=CommentTargetType.SUMMARY,
215216
snapshot=snapshot,
@@ -218,16 +219,67 @@ def test_success(self, snapshot, author_orguser, org):
218219
author=author_orguser,
219220
org=org,
220221
)
222+
comment_id = comment.id
221223
CommentService.delete_comment(
222-
comment_id=comment.id,
224+
comment_id=comment_id,
223225
org=org,
224226
orguser=author_orguser,
225227
)
226-
comment.refresh_from_db()
227-
assert comment.is_deleted is True
228-
assert comment.content == ""
229-
assert comment.mentioned_emails == []
230-
comment.delete()
228+
assert not Comment.objects.filter(id=comment_id).exists()
229+
230+
def test_hard_deletes_multiple_own_comments(self, snapshot, author_orguser, org):
231+
"""Multiple comments in thread but ALL by the same author — hard-delete."""
232+
c1 = Comment.objects.create(
233+
target_type=CommentTargetType.SUMMARY,
234+
snapshot=snapshot,
235+
content="My first",
236+
author=author_orguser,
237+
org=org,
238+
)
239+
c2 = Comment.objects.create(
240+
target_type=CommentTargetType.SUMMARY,
241+
snapshot=snapshot,
242+
content="My second",
243+
author=author_orguser,
244+
org=org,
245+
)
246+
c2_id = c2.id
247+
CommentService.delete_comment(
248+
comment_id=c2_id,
249+
org=org,
250+
orguser=author_orguser,
251+
)
252+
assert not Comment.objects.filter(id=c2_id).exists()
253+
c1.delete()
254+
255+
def test_soft_deletes_when_other_author_exists(
256+
self, snapshot, author_orguser, other_orguser, org
257+
):
258+
"""Another user has commented in the thread — soft-delete."""
259+
Comment.objects.create(
260+
target_type=CommentTargetType.SUMMARY,
261+
snapshot=snapshot,
262+
content="Other person's comment",
263+
author=other_orguser,
264+
org=org,
265+
)
266+
my_comment = Comment.objects.create(
267+
target_type=CommentTargetType.SUMMARY,
268+
snapshot=snapshot,
269+
content="Delete me",
270+
mentioned_emails=["someone@test.com"],
271+
author=author_orguser,
272+
org=org,
273+
)
274+
CommentService.delete_comment(
275+
comment_id=my_comment.id,
276+
org=org,
277+
orguser=author_orguser,
278+
)
279+
my_comment.refresh_from_db()
280+
assert my_comment.is_deleted is True
281+
assert my_comment.content == ""
282+
assert my_comment.mentioned_emails == []
231283

232284
def test_non_author_raises(self, snapshot, author_orguser, other_orguser, org):
233285
comment = Comment.objects.create(

ddpui/websockets/__init__.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from urllib.parse import parse_qs
66
from django.contrib.auth.models import User
77

8-
from ddpui.websockets.schemas import WebsocketResponse
8+
from ddpui.websockets.schemas import WebsocketResponse, WebsocketCloseCodes
99
from ddpui.models.org_user import OrgUser
1010
from ddpui.utils.custom_logger import CustomLogger
1111

@@ -72,14 +72,34 @@ def authenticate_user(self, token: str, orgslug: str):
7272
def respond(self, message: WebsocketResponse):
7373
self.send(text_data=json.dumps(message.model_dump()))
7474

75+
def _get_cookie(self, name: str) -> str | None:
76+
"""Extract a cookie value from the WebSocket scope headers."""
77+
for header_name, header_value in self.scope.get("headers", []):
78+
if header_name == b"cookie":
79+
cookie = SimpleCookie(header_value.decode())
80+
if name in cookie:
81+
return cookie[name].value
82+
return None
83+
7584
def connect(self):
7685
query_string = parse_qs(self.scope["query_string"].decode())
77-
token = query_string.get("token", [None])[0]
7886
orgslug = query_string.get("orgslug", [None])[0]
7987

80-
if self.authenticate_user(token, orgslug):
81-
logger.info("User authenticated, establishing connection")
88+
# Read JWT from the access_token httpOnly cookie (webapp_v2)
89+
token = self._get_cookie("access_token")
90+
91+
# TODO: remove this fallback once webapp_v1 is fully deprecated
92+
if not token:
93+
token = query_string.get("token", [None])[0]
94+
95+
if not token:
96+
logger.info("No access_token cookie found, closing connection")
97+
self.accept()
98+
self.close(code=WebsocketCloseCodes.NO_TOKEN)
99+
elif not self.authenticate_user(token, orgslug):
100+
logger.info("Authentication failed (invalid/expired token), closing connection")
82101
self.accept()
102+
self.close(code=WebsocketCloseCodes.INVALID_TOKEN)
83103
else:
84-
logger.info("Authentication failed, closing connection")
85-
self.close()
104+
logger.info("User authenticated via cookie, establishing connection")
105+
self.accept()

ddpui/websockets/schemas.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
from enum import Enum
44

55

6+
class WebsocketCloseCodes:
7+
"""Custom WebSocket close codes (4000-4999 range is for application use)"""
8+
9+
NO_TOKEN = 4001
10+
INVALID_TOKEN = 4003
11+
12+
613
class WebsocketResponseStatus(str, Enum):
714
SUCCESS = "success"
815
ERROR = "error"

0 commit comments

Comments
 (0)