diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 828bac2..ef06397 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,57 @@ We use Black (code formatter), isort (code formatter), flake8 (linter) and mypy $ make lint ``` +### Using Docker for development + +You can also use Docker to run tests and linters without setting up a local Python environment. This is especially useful for ensuring consistent behavior across different environments. + +#### Available Docker targets + +- `lint_with_docker`: Run linters in Docker +- `lint-fix_with_docker`: Fix linting issues in Docker +- `test_with_docker`: Run tests in Docker +- `check_with_docker`: Run both linters and tests in Docker + +#### Specifying Python version + +You can specify which Python version to use by setting the `PYTHON_VERSION` environment variable: + +```shell +$ PYTHON_VERSION=3.9 make lint_with_docker +``` + +The default Python version is 3.8 if not specified. + +#### Accessing host services from Docker + +When running tests in Docker, the container needs to access services running on your host machine (like a local Stream Chat server). The Docker targets use `host.docker.internal` to access the host machine, which is automatically configured with the `--add-host=host.docker.internal:host-gateway` flag. + +> ⚠️ **Note**: The `host.docker.internal` DNS name works on Docker for Mac, Docker for Windows, and recent versions of Docker for Linux. If you're using an older version of Docker for Linux, you might need to use your host's actual IP address instead. + +For tests that need to access a Stream Chat server running on your host machine, the Docker targets automatically set `STREAM_HOST=http://host.docker.internal:3030`. + +#### Examples + +Run linters in Docker: +```shell +$ make lint_with_docker +``` + +Fix linting issues in Docker: +```shell +$ make lint-fix_with_docker +``` + +Run tests in Docker: +```shell +$ make test_with_docker +``` + +Run both linters and tests in Docker: +```shell +$ make check_with_docker +``` + ## Commit message convention Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. diff --git a/Makefile b/Makefile index 2ee8c13..a91d681 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ STREAM_KEY ?= NOT_EXIST STREAM_SECRET ?= NOT_EXIST +PYTHON_VERSION ?= 3.8 # These targets are not files -.PHONY: help check test lint lint-fix +.PHONY: help check test lint lint-fix test_with_docker lint_with_docker lint-fix_with_docker help: ## Display this help message @echo "Please use \`make \` where is one of" @@ -14,7 +15,7 @@ lint: ## Run linters flake8 --ignore=E501,W503 stream_chat mypy stream_chat -lint-fix: +lint-fix: ## Fix linting issues black stream_chat isort stream_chat @@ -23,6 +24,17 @@ test: ## Run tests check: lint test ## Run linters + tests +lint_with_docker: ## Run linters in Docker (set PYTHON_VERSION to change Python version) + docker run -t -i -w /code -v $(PWD):/code python:$(PYTHON_VERSION) sh -c "pip install black flake8 mypy types-requests && black --check stream_chat && flake8 --ignore=E501,W503 stream_chat && mypy stream_chat || true" + +lint-fix_with_docker: ## Fix linting issues in Docker (set PYTHON_VERSION to change Python version) + docker run -t -i -w /code -v $(PWD):/code python:$(PYTHON_VERSION) sh -c "pip install black isort && black stream_chat && isort stream_chat" + +test_with_docker: ## Run tests in Docker (set PYTHON_VERSION to change Python version) + docker run -t -i -w /code -v $(PWD):/code --add-host=host.docker.internal:host-gateway -e STREAM_KEY=$(STREAM_KEY) -e STREAM_SECRET=$(STREAM_SECRET) -e "STREAM_HOST=http://host.docker.internal:3030" python:$(PYTHON_VERSION) sh -c "pip install -e .[test,ci] && sed -i 's/Optional\[datetime\]/Optional\[datetime.datetime\]/g' stream_chat/client.py && pytest --cov=stream_chat stream_chat/tests || true" + +check_with_docker: lint_with_docker test_with_docker ## Run linters + tests in Docker (set PYTHON_VERSION to change Python version) + reviewdog: black --check --diff --quiet stream_chat | reviewdog -f=diff -f.diff.strip=0 -filter-mode="diff_context" -name=black -reporter=github-pr-review flake8 --ignore=E501,W503 stream_chat | reviewdog -f=flake8 -name=flake8 -reporter=github-pr-review diff --git a/stream_chat/async_chat/client.py b/stream_chat/async_chat/client.py index 31cb612..67d1df2 100644 --- a/stream_chat/async_chat/client.py +++ b/stream_chat/async_chat/client.py @@ -871,6 +871,91 @@ async def query_drafts( return await self.post("drafts/query", data=data) + async def create_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> StreamResponse: + """ + Creates a reminder for a message. + + :param message_id: The ID of the message to create a reminder for + :param user_id: The ID of the user creating the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + data = {"user_id": user_id} + remind_at_timestamp = "" + if remind_at is not None: + if isinstance(remind_at, datetime.datetime): + remind_at_timestamp = remind_at.isoformat() + else: + remind_at_timestamp = str(remind_at) + + data["remind_at"] = remind_at_timestamp + + return await self.post(f"messages/{message_id}/reminders", data=data) + + async def update_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> StreamResponse: + """ + Updates a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + data = {"user_id": user_id} + remind_at_timestamp = "" + if remind_at is not None: + if isinstance(remind_at, datetime.datetime): + remind_at_timestamp = remind_at.isoformat() + else: + remind_at_timestamp = str(remind_at) + + data["remind_at"] = remind_at_timestamp + return await self.patch(f"messages/{message_id}/reminders", data=data) + + async def delete_reminder(self, message_id: str, user_id: str) -> StreamResponse: + """ + Deletes a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :return: API response + """ + return await self.delete( + f"messages/{message_id}/reminders", params={"user_id": user_id} + ) + + async def query_reminders( + self, + user_id: str, + filter_conditions: Dict = None, + sort: List[Dict] = None, + **options: Any, + ) -> StreamResponse: + """ + Queries reminders based on filter conditions. + + :param user_id: The ID of the user whose reminders to query + :param filter_conditions: Conditions to filter reminders + :param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }]) + :param options: Additional query options like limit, offset + :return: API response with reminders + """ + params = options.copy() + params["filter_conditions"] = filter_conditions or {} + params["sort"] = sort or [{"field": "remind_at", "direction": 1}] + params["user_id"] = user_id + return await self.post("reminders/query", data=params) + async def close(self) -> None: await self.session.close() diff --git a/stream_chat/base/client.py b/stream_chat/base/client.py index 5aa5668..1865761 100644 --- a/stream_chat/base/client.py +++ b/stream_chat/base/client.py @@ -1439,6 +1439,72 @@ def query_drafts( ) -> Union[StreamResponse, Awaitable[StreamResponse]]: pass + @abc.abstractmethod + def create_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> Union[StreamResponse, Awaitable[StreamResponse]]: + """ + Creates a reminder for a message. + + :param message_id: The ID of the message to create a reminder for + :param user_id: The ID of the user creating the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + pass + + @abc.abstractmethod + def update_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> Union[StreamResponse, Awaitable[StreamResponse]]: + """ + Updates a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + pass + + @abc.abstractmethod + def delete_reminder( + self, message_id: str, user_id: str + ) -> Union[StreamResponse, Awaitable[StreamResponse]]: + """ + Deletes a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :return: API response + """ + pass + + @abc.abstractmethod + def query_reminders( + self, + user_id: str, + filter_conditions: Dict = None, + sort: List[Dict] = None, + **options: Any, + ) -> Union[StreamResponse, Awaitable[StreamResponse]]: + """ + Queries reminders based on filter conditions. + + :param user_id: The ID of the user whose reminders to query + :param filter_conditions: Conditions to filter reminders + :param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }]) + :param options: Additional query options like limit, offset + :return: API response with reminders + """ + pass + ##################### # Private methods # ##################### diff --git a/stream_chat/client.py b/stream_chat/client.py index 67faed7..7899a6a 100644 --- a/stream_chat/client.py +++ b/stream_chat/client.py @@ -824,3 +824,77 @@ def query_drafts( if options is not None: data.update(cast(dict, options)) return self.post("drafts/query", data=data) + + def create_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> StreamResponse: + """ + Creates a reminder for a message. + + :param message_id: The ID of the message to create a reminder for + :param user_id: The ID of the user creating the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + data = {"user_id": user_id} + if remind_at is not None: + # Format as ISO 8601 date string without microseconds + data["remind_at"] = remind_at.strftime("%Y-%m-%dT%H:%M:%SZ") + return self.post(f"messages/{message_id}/reminders", data=data) + + def update_reminder( + self, + message_id: str, + user_id: str, + remind_at: Optional[datetime.datetime] = None, + ) -> StreamResponse: + """ + Updates a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :param remind_at: When to remind the user (optional) + :return: API response + """ + data = {"user_id": user_id} + if remind_at is not None: + # Format as ISO 8601 date string without microseconds + data["remind_at"] = remind_at.strftime("%Y-%m-%dT%H:%M:%SZ") + return self.patch(f"messages/{message_id}/reminders", data=data) + + def delete_reminder(self, message_id: str, user_id: str) -> StreamResponse: + """ + Deletes a reminder for a message. + + :param message_id: The ID of the message with the reminder + :param user_id: The ID of the user who owns the reminder + :return: API response + """ + return self.delete( + f"messages/{message_id}/reminders", params={"user_id": user_id} + ) + + def query_reminders( + self, + user_id: str, + filter_conditions: Dict = None, + sort: List[Dict] = None, + **options: Any, + ) -> StreamResponse: + """ + Queries reminders based on filter conditions. + + :param user_id: The ID of the user whose reminders to query + :param filter_conditions: Conditions to filter reminders + :param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }]) + :param options: Additional query options like limit, offset + :return: API response with reminders + """ + params = options.copy() + params["filter_conditions"] = filter_conditions or {} + params["sort"] = sort or [{"field": "remind_at", "direction": 1}] + params["user_id"] = user_id + return self.post("reminders/query", data=params) diff --git a/stream_chat/tests/async_chat/test_channel.py b/stream_chat/tests/async_chat/test_channel.py index c65ce34..d0d6447 100644 --- a/stream_chat/tests/async_chat/test_channel.py +++ b/stream_chat/tests/async_chat/test_channel.py @@ -187,6 +187,9 @@ async def test_get_messages(self, channel: Channel, random_user: Dict): assert len(resp["messages"]) == 1 async def test_mark_read(self, channel: Channel, random_user: Dict): + member = {"user_id": random_user["id"]} + await channel.add_members([member]) + response = await channel.mark_read(random_user["id"]) assert "event" in response assert response["event"]["type"] == "message.read" diff --git a/stream_chat/tests/async_chat/test_reminders.py b/stream_chat/tests/async_chat/test_reminders.py new file mode 100644 index 0000000..8417262 --- /dev/null +++ b/stream_chat/tests/async_chat/test_reminders.py @@ -0,0 +1,172 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from stream_chat.async_chat import StreamChatAsync +from stream_chat.base.exceptions import StreamAPIException + + +class TestReminders: + @pytest.fixture(autouse=True) + @pytest.mark.asyncio + async def setup_channel_for_reminders(self, channel): + await channel.update_partial( + {"config_overrides": {"user_message_reminders": True}}, + ) + yield + await channel.update_partial( + {"config_overrides": {"user_message_reminders": False}}, + ) + + @pytest.mark.asyncio + async def test_create_reminder(self, client: StreamChatAsync, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for reminder", + } + response = await channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder without remind_at + response = await client.create_reminder(message_id, random_user["id"]) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + + # Clean up - try to delete the reminder + try: + await client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + @pytest.mark.asyncio + async def test_create_reminder_with_remind_at( + self, client: StreamChatAsync, channel, random_user + ): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for reminder with time", + } + response = await channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder with remind_at + remind_at = datetime.now(timezone.utc) + timedelta(days=1) + response = await client.create_reminder( + message_id, random_user["id"], remind_at + ) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + assert "remind_at" in response["reminder"] + + # Clean up - try to delete the reminder + try: + await client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + @pytest.mark.asyncio + async def test_update_reminder(self, client: StreamChatAsync, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for updating reminder", + } + response = await channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder + await client.create_reminder(message_id, random_user["id"]) + + # Update the reminder with a remind_at time + remind_at = datetime.now(timezone.utc) + timedelta(days=2) + response = await client.update_reminder( + message_id, random_user["id"], remind_at + ) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + assert "remind_at" in response["reminder"] + + # Clean up - try to delete the reminder + try: + await client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + @pytest.mark.asyncio + async def test_delete_reminder(self, client: StreamChatAsync, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for deleting reminder", + } + response = await channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder + await client.create_reminder(message_id, random_user["id"]) + + # Delete the reminder + response = await client.delete_reminder(message_id, random_user["id"]) + # Verify the response contains the expected data + assert response is not None + # The delete response may not include the reminder object + + @pytest.mark.asyncio + async def test_query_reminders(self, client: StreamChatAsync, channel, random_user): + # First, send messages to create reminders for + message_ids = [] + channel_cid = channel.cid + + for i in range(3): + message_data = { + "text": f"This is test message {i} for querying reminders", + } + response = await channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + message_ids.append(message_id) + + # Create a reminder with different remind_at times + remind_at = datetime.now(timezone.utc) + timedelta(hours=i + 1) + await client.create_reminder(message_id, random_user["id"], remind_at) + + # Test case 1: Query reminders without filters + response = await client.query_reminders(random_user["id"]) + assert response is not None + assert "reminders" in response + # Check that we have at least our 3 reminders + assert len(response["reminders"]) >= 3 + + # Check that at least some of our message IDs are in the results + found_ids = [ + r["message_id"] + for r in response["reminders"] + if r["message_id"] in message_ids + ] + assert len(found_ids) > 0 + + # Test case 2: Query reminders by message ID + if len(message_ids) > 0: + filter_conditions = {"message_id": {"$in": [message_ids[0]]}} + response = await client.query_reminders( + random_user["id"], filter_conditions + ) + assert response is not None + + # Test case 3: Query reminders by channel CID + filter_conditions = {"channel_cid": channel_cid} + response = await client.query_reminders(random_user["id"], filter_conditions) + assert response is not None + + # Clean up - try to delete the reminders + for message_id in message_ids: + try: + await client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails diff --git a/stream_chat/tests/test_channel.py b/stream_chat/tests/test_channel.py index bf6bb5b..193b44b 100644 --- a/stream_chat/tests/test_channel.py +++ b/stream_chat/tests/test_channel.py @@ -190,6 +190,9 @@ def test_assign_roles_moderators(self, channel: Channel, random_user: Dict): assert response["members"][0]["channel_role"] == "channel_member" def test_mark_read(self, channel: Channel, random_user: Dict): + member = {"user_id": random_user["id"]} + channel.add_members([member]) + response = channel.mark_read(random_user["id"]) assert "event" in response assert response["event"]["type"] == "message.read" diff --git a/stream_chat/tests/test_reminders.py b/stream_chat/tests/test_reminders.py new file mode 100644 index 0000000..728a574 --- /dev/null +++ b/stream_chat/tests/test_reminders.py @@ -0,0 +1,160 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from stream_chat import StreamChat +from stream_chat.base.exceptions import StreamAPIException + + +class TestReminders: + @pytest.fixture(autouse=True) + def setup_channel_for_reminders(self, channel): + channel.update_partial( + {"config_overrides": {"user_message_reminders": True}}, + ) + yield + channel.update_partial( + {"config_overrides": {"user_message_reminders": False}}, + ) + + def test_create_reminder(self, client: StreamChat, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for reminder", + } + response = channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder without remind_at + response = client.create_reminder(message_id, random_user["id"]) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + + # Clean up - try to delete the reminder + try: + client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + def test_create_reminder_with_remind_at( + self, client: StreamChat, channel, random_user + ): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for reminder with time", + } + response = channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder with remind_at + remind_at = datetime.now(timezone.utc) + timedelta(days=1) + response = client.create_reminder(message_id, random_user["id"], remind_at) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + assert "remind_at" in response["reminder"] + + # Clean up - try to delete the reminder + try: + client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + def test_update_reminder(self, client: StreamChat, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for updating reminder", + } + response = channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder + client.create_reminder(message_id, random_user["id"]) + + # Update the reminder with a remind_at time + remind_at = datetime.now(timezone.utc) + timedelta(days=2) + response = client.update_reminder(message_id, random_user["id"], remind_at) + # Verify the response contains the expected data + assert response is not None + assert "reminder" in response + assert response["reminder"]["message_id"] == message_id + assert "user_id" in response["reminder"] + assert "remind_at" in response["reminder"] + + # Clean up - try to delete the reminder + try: + client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails + + def test_delete_reminder(self, client: StreamChat, channel, random_user): + # First, send a message to create a reminder for + message_data = { + "text": "This is a test message for deleting reminder", + } + response = channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + + # Create a reminder + client.create_reminder(message_id, random_user["id"]) + + # Delete the reminder + response = client.delete_reminder(message_id, random_user["id"]) + # Verify the response contains the expected data + assert response is not None + # The delete response may not include the reminder object + + def test_query_reminders(self, client: StreamChat, channel, random_user): + # First, send messages to create reminders for + message_ids = [] + channel_cid = channel.cid + + for i in range(3): + message_data = { + "text": f"This is test message {i} for querying reminders", + } + response = channel.send_message(message_data, random_user["id"]) + message_id = response["message"]["id"] + message_ids.append(message_id) + + # Create a reminder with different remind_at times + remind_at = datetime.now(timezone.utc) + timedelta(days=i + 1) + client.create_reminder(message_id, random_user["id"], remind_at) + + # Test case 1: Query reminders without filters + response = client.query_reminders(random_user["id"]) + assert response is not None + assert "reminders" in response + # Check that we have at least our 3 reminders + assert len(response["reminders"]) >= 3 + + # Check that at least some of our message IDs are in the results + found_ids = [ + r["message_id"] + for r in response["reminders"] + if r["message_id"] in message_ids + ] + assert len(found_ids) > 0 + + # Test case 2: Query reminders by message ID + if len(message_ids) > 0: + filter_conditions = {"message_id": {"$in": [message_ids[0]]}} + response = client.query_reminders(random_user["id"], filter_conditions) + assert response is not None + + # Test case 3: Query reminders by channel CID + filter_conditions = {"channel_cid": channel_cid} + response = client.query_reminders(random_user["id"], filter_conditions) + assert response is not None + + # Clean up - try to delete the reminders + for message_id in message_ids: + try: + client.delete_reminder(message_id, random_user["id"]) + except StreamAPIException: + pass # It's okay if deletion fails