From 4d39cea9fdb5da539ba1580618bc12f2c41df72e Mon Sep 17 00:00:00 2001 From: Uno-Takashi Date: Tue, 23 Jun 2026 00:33:42 +0000 Subject: [PATCH] feat(streamer): owner room deletion and is_host in user_list Expose `is_host` on the user_list payload so clients can mark the room owner, and add a host-only `delete_room` action that broadcasts `room_deleted` to every member and logically deletes the room and its users. Non-host requests are ignored. Co-Authored-By: Claude Opus 4.8 --- streamer/consumers.py | 39 +++++++++++++- streamer/format.py | 2 + streamer/tests.py | 117 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/streamer/consumers.py b/streamer/consumers.py index 69a8025..4947c5e 100644 --- a/streamer/consumers.py +++ b/streamer/consumers.py @@ -312,6 +312,32 @@ async def user_list(self, **kwargs): response_data = UserList(user_list=user_list) await self.send(text_data=json.dumps(response_data.model_dump())) + @action() + async def delete_room(self, **kwargs): + """ホスト(オーナー)がルームを削除するアクション。 + + ルーム内の全員(送信者を含む)へ ``room_deleted`` を通知してから、 + ルームと参加者をまとめて論理削除する。ホスト以外からの要求は無視する。 + """ + if self.anime_room is None or self.anime_user is None: + return + await self.database_renew_state() + if not self.anime_user.is_host: + return + room_id_str = str(self.anime_room.room_id) + # 猟予期間の自動削除が予約されていれば取り消す(ここで明示的に削除するため)。 + _cancel_pending_room_delete(room_id_str) + server_message = ServerMessage(message_type="room_deleted") + response_data = RoomSend( + response=server_message, + sender_channel_name=self.channel_name, + ) + await self.channel_layer.group_send( + room_id_str, + json.loads(json.dumps(response_data.model_dump())), + ) + await self.database_delete_room_and_users() + async def room_send(self, data: dict): """自分を含むのグループに所属するユーザーへの一斉送信 @@ -454,6 +480,17 @@ def database_delete_room(self): self.anime_room.delete() self.anime_room.save() + @database_sync_to_async + def database_delete_room_and_users(self): + """ルームと、その中の生存ユーザーをまとめて論理削除する。 + + ホストがルームを削除したときに呼ぶ。QuerySet の ``delete()`` は + ``LogicalDeletionMixin`` により論理削除(``deleted_at`` 付与)。 + """ + room_id = self.anime_room.room_id + AnimeUser.objects.alive().filter(room_id=room_id).delete() + AnimeRoom.objects.alive().filter(room_id=room_id).delete() + @database_sync_to_async def database_increase_num_people(self): """人が増えた場合にデータベースのnum_peopleとsum_peopleを加算する""" @@ -516,7 +553,7 @@ def database_renew_state(self): def database_user_list(self): """ルーム内のユーザーを取得する""" ar = AnimeRoom.objects.get(room_id=self.anime_room.room_id) - user_list = ar.inroom.alive().values("user_name", "user_id") + user_list = ar.inroom.alive().values("user_name", "user_id", "is_host") return list(user_list) @database_sync_to_async diff --git a/streamer/format.py b/streamer/format.py index 4b59df7..22603ce 100644 --- a/streamer/format.py +++ b/streamer/format.py @@ -22,10 +22,12 @@ class User(BaseModel): Attributes: user_id: UUID identifying the user. user_name: display name chosen by the user. + is_host: whether this participant is the room host (owner). """ user_id: UUID user_name: str + is_host: bool = False class ResponseBaseFormat(BaseModel): diff --git a/streamer/tests.py b/streamer/tests.py index 1ec7d51..34d81a6 100644 --- a/streamer/tests.py +++ b/streamer/tests.py @@ -143,6 +143,10 @@ async def test_anime_party_consumer_video_action(self): response = await communicator1.receive_json_from() assert response["action"] == "user_list" assert len(response["user_list"]) == 2 + # user_list はホスト(オーナー)判定のため is_host を含む。 + hosts = {u["user_name"]: u["is_host"] for u in response["user_list"]} + assert hosts[user_name1] is True + assert hosts[user_name2] is False await communicator2.send_json_to( { "action": "video_operation", @@ -163,6 +167,119 @@ async def test_anime_party_consumer_video_action(self): assert response["room_id"] == join_room_id await communicator1.disconnect() + @pytest.mark.django_db(transaction=True) + @pytest.mark.asyncio + async def test_anime_party_consumer_delete_room_ok(self): + """ホストが delete_room を送ると、ルームが論理削除され、ルーム内の + 全員(ホスト自身を含む)へ room_deleted が通知されるテスト""" + host = WebsocketCommunicator( + AnimePartyConsumer.as_asgi(), "/anime-store/party/" + ) + await host.connect() + await host.send_json_to( + { + "action": "create", + "user_name": "host_user", + "part_id": "123456", + "request_id": 100, + } + ) + create_response = await host.receive_json_from() + room_id = create_response["room_id"] + # create 直後の user_list を読み飛ばす。 + await host.receive_json_from() + + guest = WebsocketCommunicator( + AnimePartyConsumer.as_asgi(), "/anime-store/party/" + ) + await guest.connect() + await guest.send_json_to( + { + "action": "join", + "user_name": "guest_user", + "room_id": room_id, + "request_id": 100, + } + ) + # guest: join 応答 + user_list、host: user_add + user_list を読み飛ばす。 + await guest.receive_json_from() + await guest.receive_json_from() + await host.receive_json_from() + await host.receive_json_from() + + # ホストがルーム削除を要求する。 + await host.send_json_to({"action": "delete_room", "request_id": 100}) + + host_msg = await host.receive_json_from() + assert host_msg["action"] == "server_message" + assert host_msg["message_type"] == "room_deleted" + + guest_msg = await guest.receive_json_from() + assert guest_msg["action"] == "server_message" + assert guest_msg["message_type"] == "room_deleted" + + assert await self.room_alive(room_id) is False + assert await self.alive_user_count(room_id) == 0 + + # 既存テストにならい片方のみ切断する(num_people は作成者を数えないため、 + # 2 回 decrement すると CHECK 制約 >= 0 を割る既知の会計上の癖を避ける)。 + await host.disconnect() + + @pytest.mark.django_db(transaction=True) + @pytest.mark.asyncio + async def test_anime_party_consumer_delete_room_non_host_ignored(self): + """ホスト以外が delete_room を送っても無視され、ルームは残るテスト""" + host = WebsocketCommunicator( + AnimePartyConsumer.as_asgi(), "/anime-store/party/" + ) + await host.connect() + await host.send_json_to( + { + "action": "create", + "user_name": "host_user", + "part_id": "123456", + "request_id": 100, + } + ) + create_response = await host.receive_json_from() + room_id = create_response["room_id"] + await host.receive_json_from() + + guest = WebsocketCommunicator( + AnimePartyConsumer.as_asgi(), "/anime-store/party/" + ) + await guest.connect() + await guest.send_json_to( + { + "action": "join", + "user_name": "guest_user", + "room_id": room_id, + "request_id": 100, + } + ) + await guest.receive_json_from() + await guest.receive_json_from() + await host.receive_json_from() + await host.receive_json_from() + + # 非ホスト(ゲスト)が削除を要求しても無視される。 + await guest.send_json_to({"action": "delete_room", "request_id": 100}) + + assert await guest.receive_nothing() is True + assert await self.room_alive(room_id) is True + assert await self.alive_user_count(room_id) == 2 + + # 片方のみ切断する(num_people の二重 decrement による CHECK 制約違反を避ける)。 + await host.disconnect() + + @database_sync_to_async + def room_alive(self, room_id): + return AnimeRoom.objects.alive().filter(room_id=room_id).exists() + + @database_sync_to_async + def alive_user_count(self, room_id): + return AnimeUser.objects.alive().filter(room_id=room_id).count() + @database_sync_to_async def anime_user_exist(self, user_id): return AnimeUser.objects.filter(user_id=user_id).exists()