From 8f54bdedc27e6e1a6d6e562992ff209c3c9b6e2a Mon Sep 17 00:00:00 2001 From: husanjon Date: Thu, 2 Apr 2026 20:50:00 +0500 Subject: [PATCH] chat qo'shildi --- config/asgi.py | 39 ++-- config/conf/modules.py | 2 +- config/conf/navigation.py | 11 ++ config/settings/common.py | 10 + config/urls.py | 1 + core/apps/chat/__init__.py | 0 core/apps/chat/admin/__init__.py | 1 + core/apps/chat/admin/chat.py | 86 +++++++++ core/apps/chat/apps.py | 9 + core/apps/chat/choices/__init__.py | 1 + core/apps/chat/choices/chat.py | 15 ++ core/apps/chat/consumers/__init__.py | 1 + core/apps/chat/consumers/chat.py | 105 +++++++++++ core/apps/chat/filters/__init__.py | 1 + core/apps/chat/filters/chat.py | 33 ++++ core/apps/chat/forms/__init__.py | 1 + core/apps/chat/forms/chat.py | 17 ++ core/apps/chat/middlewares/__init__.py | 1 + core/apps/chat/middlewares/auth.py | 70 +++++++ .../migrations/0001_initial_chat_message.py | 36 ++++ .../0002_chatroom_and_message_media.py | 152 ++++++++++++++++ core/apps/chat/migrations/__init__.py | 0 core/apps/chat/models/__init__.py | 1 + core/apps/chat/models/chat.py | 110 +++++++++++ core/apps/chat/permissions/__init__.py | 1 + core/apps/chat/permissions/chat.py | 23 +++ core/apps/chat/serializers/__init__.py | 1 + .../apps/chat/serializers/chat/ChatMessage.py | 75 ++++++++ core/apps/chat/serializers/chat/ChatRoom.py | 68 +++++++ core/apps/chat/serializers/chat/__init__.py | 2 + core/apps/chat/signals/__init__.py | 1 + core/apps/chat/signals/chat.py | 55 ++++++ core/apps/chat/tests/__init__.py | 1 + core/apps/chat/tests/chat/__init__.py | 2 + core/apps/chat/tests/chat/test_ChatMessage.py | 101 ++++++++++ core/apps/chat/tests/chat/test_ChatRoom.py | 101 ++++++++++ core/apps/chat/translation/__init__.py | 1 + core/apps/chat/translation/chat.py | 13 ++ core/apps/chat/urls.py | 9 + core/apps/chat/validators/__init__.py | 1 + core/apps/chat/validators/chat.py | 15 ++ core/apps/chat/views/__init__.py | 1 + core/apps/chat/views/chat.py | 172 ++++++++++++++++++ ...evaluationmodel_form_ownership_and_more.py | 11 ++ 44 files changed, 1344 insertions(+), 13 deletions(-) create mode 100644 core/apps/chat/__init__.py create mode 100644 core/apps/chat/admin/__init__.py create mode 100644 core/apps/chat/admin/chat.py create mode 100644 core/apps/chat/apps.py create mode 100644 core/apps/chat/choices/__init__.py create mode 100644 core/apps/chat/choices/chat.py create mode 100644 core/apps/chat/consumers/__init__.py create mode 100644 core/apps/chat/consumers/chat.py create mode 100644 core/apps/chat/filters/__init__.py create mode 100644 core/apps/chat/filters/chat.py create mode 100644 core/apps/chat/forms/__init__.py create mode 100644 core/apps/chat/forms/chat.py create mode 100644 core/apps/chat/middlewares/__init__.py create mode 100644 core/apps/chat/middlewares/auth.py create mode 100644 core/apps/chat/migrations/0001_initial_chat_message.py create mode 100644 core/apps/chat/migrations/0002_chatroom_and_message_media.py create mode 100644 core/apps/chat/migrations/__init__.py create mode 100644 core/apps/chat/models/__init__.py create mode 100644 core/apps/chat/models/chat.py create mode 100644 core/apps/chat/permissions/__init__.py create mode 100644 core/apps/chat/permissions/chat.py create mode 100644 core/apps/chat/serializers/__init__.py create mode 100644 core/apps/chat/serializers/chat/ChatMessage.py create mode 100644 core/apps/chat/serializers/chat/ChatRoom.py create mode 100644 core/apps/chat/serializers/chat/__init__.py create mode 100644 core/apps/chat/signals/__init__.py create mode 100644 core/apps/chat/signals/chat.py create mode 100644 core/apps/chat/tests/__init__.py create mode 100644 core/apps/chat/tests/chat/__init__.py create mode 100644 core/apps/chat/tests/chat/test_ChatMessage.py create mode 100644 core/apps/chat/tests/chat/test_ChatRoom.py create mode 100644 core/apps/chat/translation/__init__.py create mode 100644 core/apps/chat/translation/chat.py create mode 100644 core/apps/chat/urls.py create mode 100644 core/apps/chat/validators/__init__.py create mode 100644 core/apps/chat/validators/chat.py create mode 100644 core/apps/chat/views/__init__.py create mode 100644 core/apps/chat/views/chat.py diff --git a/config/asgi.py b/config/asgi.py index 0e14eda..19d0c36 100644 --- a/config/asgi.py +++ b/config/asgi.py @@ -1,12 +1,27 @@ -import os - -from django.core.asgi import get_asgi_application - -asgi_application = get_asgi_application() -from config.env import env # noqa - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) - - -application = asgi_application - +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + +asgi_application = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter # noqa +from django.urls import re_path # noqa + +from core.apps.chat.consumers import ChatConsumer # noqa +from core.apps.chat.middlewares.auth import JWTAuthMiddlewareStack # noqa + +websocket_urlpatterns = [ + re_path( + r"^ws/chat/room/(?P\d+)/$", + ChatConsumer.as_asgi(), + ), +] + +application = ProtocolTypeRouter({ + "http": asgi_application, + "websocket": JWTAuthMiddlewareStack( + URLRouter(websocket_urlpatterns) + ), +}) diff --git a/config/conf/modules.py b/config/conf/modules.py index 19b0ce9..29ce6d2 100644 --- a/config/conf/modules.py +++ b/config/conf/modules.py @@ -1 +1 @@ -MODULES = ["core.apps.shared", "core.apps.evaluation", "core.apps.payment"] +MODULES = ["core.apps.shared", "core.apps.evaluation", "core.apps.payment", "core.apps.chat"] diff --git a/config/conf/navigation.py b/config/conf/navigation.py index 33f8975..c4731b4 100644 --- a/config/conf/navigation.py +++ b/config/conf/navigation.py @@ -128,6 +128,17 @@ PAGES = [ }, ], }, + { + "title": _("Chat"), + "separator": True, + "items": [ + { + "title": _("Chat xabarlari"), + "icon": "chat", + "link": reverse_lazy("admin:chat_chatmessagemodel_changelist"), + }, + ], + }, { "title": _("Ma'lumotnomalari"), "separator": True, diff --git a/config/settings/common.py b/config/settings/common.py index c4ebafd..e15cd00 100644 --- a/config/settings/common.py +++ b/config/settings/common.py @@ -148,8 +148,18 @@ AUTH_USER_MODEL = "accounts.User" CELERY_BROKER_URL = env("REDIS_URL") CELERY_RESULT_BACKEND = env("REDIS_URL") +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [env("REDIS_URL")], + }, + }, +} + ALLOWED_HOSTS += env("ALLOWED_HOSTS").split(",") CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS").split(",") +SITE_URL = env.str("SITE_URL", default="http://localhost:8055") MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") diff --git a/config/urls.py b/config/urls.py index cf42739..45a63fc 100644 --- a/config/urls.py +++ b/config/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ path("api/", include("core.apps.shared.urls")), path("api/v1/", include("core.apps.evaluation.urls")), path("api/v1/", include("core.apps.payment.urls")), + path("api/v1/", include("core.apps.chat.urls")), ] urlpatterns += [ path("admin/", admin.site.urls), diff --git a/core/apps/chat/__init__.py b/core/apps/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/chat/admin/__init__.py b/core/apps/chat/admin/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/admin/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/admin/chat.py b/core/apps/chat/admin/chat.py new file mode 100644 index 0000000..b064d5e --- /dev/null +++ b/core/apps/chat/admin/chat.py @@ -0,0 +1,86 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin + +from core.apps.chat.models import ChatmessageModel, ChatroomModel + + +@admin.register(ChatmessageModel) +class ChatmessageAdmin(ModelAdmin): + list_display = ( + "id", + "room", + "message_type", + "sender", + "short_text", + "is_read", + "created_at", + ) + list_filter = ("message_type", "is_read", "created_at") + search_fields = ( + "text", + "sender__phone", + "sender__first_name", + "sender__last_name", + ) + readonly_fields = ("created_at",) + autocomplete_fields = ("sender",) + ordering = ("-created_at",) + fieldsets = ( + ( + _("Xabar"), + { + "fields": ("room", "sender", "message_type", "text", "file", "is_read"), + }, + ), + ( + _("Tizim"), + { + "classes": ("collapse",), + "fields": ("created_at",), + }, + ), + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + @admin.display(description=_("Xabar")) + def short_text(self, obj): + if not obj.text: + return f"[{obj.message_type}]" + return obj.text[:60] + "..." if len(obj.text) > 60 else obj.text + + +@admin.register(ChatroomModel) +class ChatroomAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + "type", + "auto_evaluation", + "created_at", + ) + list_filter = ("type",) + search_fields = ("auto_evaluation__id",) + autocomplete_fields = ("auto_evaluation",) + filter_horizontal = ("members",) + readonly_fields = ("created_at", "updated_at") + fieldsets = ( + ( + _("Xona"), + { + "fields": ("type", "auto_evaluation", "members"), + }, + ), + ( + _("Tizim"), + { + "classes": ("collapse",), + "fields": ("created_at", "updated_at"), + }, + ), + ) diff --git a/core/apps/chat/apps.py b/core/apps/chat/apps.py new file mode 100644 index 0000000..3a5d9e8 --- /dev/null +++ b/core/apps/chat/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.chat" + + def ready(self): + import core.apps.chat.signals # noqa diff --git a/core/apps/chat/choices/__init__.py b/core/apps/chat/choices/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/choices/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/choices/chat.py b/core/apps/chat/choices/chat.py new file mode 100644 index 0000000..097f470 --- /dev/null +++ b/core/apps/chat/choices/chat.py @@ -0,0 +1,15 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class RoomType(models.TextChoices): + AUTO_EVALUATION = "auto_evaluation", _("AutoEvaluation xonasi") + DIRECT = "direct", _("To'g'ridan-to'g'ri") + + +class MessageType(models.TextChoices): + TEXT = "text", _("Matn") + IMAGE = "image", _("Rasm") + VIDEO = "video", _("Video") + VOICE = "voice", _("Ovoz") + FILE = "file", _("Fayl") diff --git a/core/apps/chat/consumers/__init__.py b/core/apps/chat/consumers/__init__.py new file mode 100644 index 0000000..e3cc847 --- /dev/null +++ b/core/apps/chat/consumers/__init__.py @@ -0,0 +1 @@ +from .chat import ChatConsumer # noqa diff --git a/core/apps/chat/consumers/chat.py b/core/apps/chat/consumers/chat.py new file mode 100644 index 0000000..e343c41 --- /dev/null +++ b/core/apps/chat/consumers/chat.py @@ -0,0 +1,105 @@ +import json + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer +from django.contrib.auth.models import AnonymousUser + + +class ChatConsumer(AsyncWebsocketConsumer): + """ + Xona asosidagi chat consumer. + Ulanish: ws://host/ws/chat/room/{room_id}/?token= + + Matn xabari yuborish: + { "message_type": "text", "text": "Salom" } + + Fayl xabari (oldin REST orqali yuklab, keyin URL yuborish): + { "message_type": "image", "file_url": "https://...", "text": "caption (ixtiyoriy)" } + """ + + async def connect(self): + user = self.scope.get("user") + if not user or isinstance(user, AnonymousUser): + await self.close(code=4001) + return + + self.room_id = self.scope["url_route"]["kwargs"]["room_id"] + self.room_group = f"chat_room_{self.room_id}" + + await self.channel_layer.group_add(self.room_group, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + if hasattr(self, "room_group"): + await self.channel_layer.group_discard(self.room_group, self.channel_name) + + async def receive(self, text_data): + user = self.scope.get("user") + if not user or isinstance(user, AnonymousUser): + await self.close(code=4001) + return + + try: + data = json.loads(text_data) + except (json.JSONDecodeError, ValueError): + await self.send(text_data=json.dumps({"error": "Noto'g'ri format."})) + return + + message_type = data.get("message_type", "text") + text = (data.get("text") or "").strip() + + # Matn xabari uchun text majburiy + if message_type == "text" and not text: + await self.send(text_data=json.dumps({"error": "Matn bo'sh bo'lishi mumkin emas."})) + return + + # WS orqali faqat matn + caption saqlanadi. + # Fayl yuklash uchun REST /chat/messages/ POST ishlatiladi. + if message_type != "text": + await self.send( + text_data=json.dumps( + {"error": "Fayl xabarlarni yuklash uchun REST API dan foydalaning."} + ) + ) + return + + # DB ga saqlash — post_save signal WS ga broadcast qiladi + await self._save_message(user, text) + + async def chat_message(self, event): + await self.send( + text_data=json.dumps( + { + "id": event["id"], + "message_type": event["message_type"], + "text": event["text"], + "file_url": event["file_url"], + "sender": event["sender"], + "created_at": event["created_at"], + } + ) + ) + + @database_sync_to_async + def _save_message(self, user, text): + from core.apps.chat.models import ChatmessageModel + + msg = ChatmessageModel.objects.create( + room_id=self.room_id, + sender=user, + message_type="text", + text=text, + ) + full_name = user.get_full_name().strip() or str(user.phone) + return { + "id": msg.id, + "message_type": msg.message_type, + "text": msg.text, + "file_url": None, + "sender": { + "id": user.id, + "full_name": full_name, + "role": user.role, + }, + "created_at": msg.created_at.isoformat(), + } diff --git a/core/apps/chat/filters/__init__.py b/core/apps/chat/filters/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/filters/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/filters/chat.py b/core/apps/chat/filters/chat.py new file mode 100644 index 0000000..193b0ab --- /dev/null +++ b/core/apps/chat/filters/chat.py @@ -0,0 +1,33 @@ +from django_filters import rest_framework as filters + +from core.apps.chat.models import ChatmessageModel, ChatroomModel + + +class ChatmessageFilter(filters.FilterSet): + room = filters.NumberFilter(field_name="room_id", lookup_expr="exact") + message_type = filters.CharFilter(field_name="message_type", lookup_expr="exact") + is_read = filters.BooleanFilter(field_name="is_read") + created_from = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte") + created_to = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte") + + class Meta: + model = ChatmessageModel + fields = [ + "room", + "message_type", + "is_read", + "created_from", + "created_to", + ] + + +class ChatroomFilter(filters.FilterSet): + type = filters.CharFilter(field_name="type", lookup_expr="exact") + auto_evaluation = filters.NumberFilter(field_name="auto_evaluation_id", lookup_expr="exact") + + class Meta: + model = ChatroomModel + fields = [ + "type", + "auto_evaluation", + ] diff --git a/core/apps/chat/forms/__init__.py b/core/apps/chat/forms/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/forms/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/forms/chat.py b/core/apps/chat/forms/chat.py new file mode 100644 index 0000000..66662b1 --- /dev/null +++ b/core/apps/chat/forms/chat.py @@ -0,0 +1,17 @@ +from django import forms + +from core.apps.chat.models import ChatmessageModel, ChatroomModel + + +class ChatmessageForm(forms.ModelForm): + + class Meta: + model = ChatmessageModel + fields = "__all__" + + +class ChatroomForm(forms.ModelForm): + + class Meta: + model = ChatroomModel + fields = "__all__" diff --git a/core/apps/chat/middlewares/__init__.py b/core/apps/chat/middlewares/__init__.py new file mode 100644 index 0000000..cd9e746 --- /dev/null +++ b/core/apps/chat/middlewares/__init__.py @@ -0,0 +1 @@ +from .auth import * # noqa \ No newline at end of file diff --git a/core/apps/chat/middlewares/auth.py b/core/apps/chat/middlewares/auth.py new file mode 100644 index 0000000..9af2fff --- /dev/null +++ b/core/apps/chat/middlewares/auth.py @@ -0,0 +1,70 @@ +import traceback +from urllib.parse import parse_qs + +from channels.auth import AuthMiddlewareStack +from channels.db import database_sync_to_async +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.db import close_old_connections +from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError +from jwt import decode as jwt_decode + +User = get_user_model() + + +class JWTAuthMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + close_old_connections() + try: + if jwt_token_list := parse_qs(scope["query_string"].decode("utf8")).get( + "token", None + ): + jwt_token = jwt_token_list[0] + jwt_payload = self.get_payload(jwt_token) + user_credentials = self.get_user_credentials(jwt_payload) + user = await self.get_logged_in_user(user_credentials) + scope["user"] = user + else: + scope["user"] = AnonymousUser() + except ( + InvalidSignatureError, + KeyError, + ExpiredSignatureError, + DecodeError, + ): + traceback.print_exc() + except Exception as e: + print(e) + scope["user"] = AnonymousUser() + return await self.app(scope, receive, send) + + def get_payload(self, jwt_token): + payload = jwt_decode(jwt_token, settings.SECRET_KEY, algorithms=["HS256"]) + return payload + + def get_user_credentials(self, payload): + """ + method to get user credentials from jwt token payload. + defaults to user id. + """ + user_id = payload["user_id"] + return user_id + + async def get_logged_in_user(self, user_id): + user = await self.get_user(user_id) + return user + + @database_sync_to_async + def get_user(self, user_id): + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return AnonymousUser() + + +def JWTAuthMiddlewareStack(app): + return JWTAuthMiddleware(AuthMiddlewareStack(app)) diff --git a/core/apps/chat/migrations/0001_initial_chat_message.py b/core/apps/chat/migrations/0001_initial_chat_message.py new file mode 100644 index 0000000..037b312 --- /dev/null +++ b/core/apps/chat/migrations/0001_initial_chat_message.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.7 on 2026-04-02 14:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('evaluation', '0026_alter_autoevaluationmodel_form_ownership_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ChatmessageModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(verbose_name='text')), + ('is_read', models.BooleanField(db_index=True, default=False, verbose_name='is read')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('auto_evaluation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_messages', to='evaluation.autoevaluationmodel', verbose_name='auto evaluation')), + ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chat_messages', to=settings.AUTH_USER_MODEL, verbose_name='sender')), + ], + options={ + 'verbose_name': 'Chat Message', + 'verbose_name_plural': 'Chat Messages', + 'db_table': 'ChatMessage', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['auto_evaluation_id', 'created_at'], name='chat_eval_date_idx'), models.Index(fields=['auto_evaluation_id', 'is_read'], name='chat_eval_read_idx')], + }, + ), + ] diff --git a/core/apps/chat/migrations/0002_chatroom_and_message_media.py b/core/apps/chat/migrations/0002_chatroom_and_message_media.py new file mode 100644 index 0000000..f87d87f --- /dev/null +++ b/core/apps/chat/migrations/0002_chatroom_and_message_media.py @@ -0,0 +1,152 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import core.apps.chat.models.chat + + +def delete_old_messages(apps, schema_editor): + """Eski xabarlarni o'chirish — room FK bilan mos kelmaydi.""" + ChatmessageModel = apps.get_model("chat", "ChatmessageModel") + ChatmessageModel.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat", "0001_initial_chat_message"), + ("evaluation", "0026_alter_autoevaluationmodel_form_ownership_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # 1. ChatroomModel yaratish + migrations.CreateModel( + name="ChatroomModel", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "type", + models.CharField( + choices=[ + ("auto_evaluation", "AutoEvaluation xonasi"), + ("direct", "To\u2018g\u2018ridan-to\u2018g\u2018ri"), + ], + db_index=True, + default="auto_evaluation", + max_length=20, + verbose_name="type", + ), + ), + ( + "auto_evaluation", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_room", + to="evaluation.autoevaluationmodel", + verbose_name="auto evaluation", + ), + ), + ( + "members", + models.ManyToManyField( + blank=True, + related_name="chat_rooms", + to=settings.AUTH_USER_MODEL, + verbose_name="members", + ), + ), + ], + options={ + "verbose_name": "Chat Xona", + "verbose_name_plural": "Chat Xonalar", + "db_table": "ChatRoom", + }, + ), + # 2. Eski xabarlarni o'chirish (room FKsiz migratsiya mumkin emas) + migrations.RunPython(delete_old_messages, migrations.RunPython.noop), + # 3. Eski indexlarni o'chirish + migrations.RemoveIndex( + model_name="chatmessagemodel", + name="chat_eval_date_idx", + ), + migrations.RemoveIndex( + model_name="chatmessagemodel", + name="chat_eval_read_idx", + ), + # 4. auto_evaluation FK o'chirish + migrations.RemoveField( + model_name="chatmessagemodel", + name="auto_evaluation", + ), + # 5. room FK qo'shish + migrations.AddField( + model_name="chatmessagemodel", + name="room", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chat.chatroommodel", + verbose_name="room", + null=True, + ), + ), + # 6. room ni non-null qilish + migrations.AlterField( + model_name="chatmessagemodel", + name="room", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chat.chatroommodel", + verbose_name="room", + ), + ), + # 7. message_type qo'shish + migrations.AddField( + model_name="chatmessagemodel", + name="message_type", + field=models.CharField( + choices=[ + ("text", "Matn"), + ("image", "Rasm"), + ("video", "Video"), + ("voice", "Ovoz"), + ("file", "Fayl"), + ], + db_index=True, + default="text", + max_length=10, + verbose_name="message type", + ), + ), + # 8. text ni nullable qilish + migrations.AlterField( + model_name="chatmessagemodel", + name="text", + field=models.TextField(blank=True, null=True, verbose_name="text"), + ), + # 9. file field qo'shish + migrations.AddField( + model_name="chatmessagemodel", + name="file", + field=models.FileField( + blank=True, + null=True, + upload_to=core.apps.chat.models.chat.chat_file_upload_path, + verbose_name="file", + ), + ), + # 10. Yangi indexlar + migrations.AddIndex( + model_name="chatmessagemodel", + index=models.Index(fields=["room_id", "created_at"], name="chat_room_date_idx"), + ), + migrations.AddIndex( + model_name="chatmessagemodel", + index=models.Index(fields=["room_id", "is_read"], name="chat_room_read_idx"), + ), + ] diff --git a/core/apps/chat/migrations/__init__.py b/core/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/chat/models/__init__.py b/core/apps/chat/models/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/models/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/models/chat.py b/core/apps/chat/models/chat.py new file mode 100644 index 0000000..559a184 --- /dev/null +++ b/core/apps/chat/models/chat.py @@ -0,0 +1,110 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from model_bakery import baker + +from core.apps.chat.choices.chat import MessageType, RoomType + + +def chat_file_upload_path(instance, filename): + return f"chat/{instance.message_type}/{filename}" + + +class ChatroomModel(AbstractBaseModel): + type = models.CharField( + verbose_name=_("type"), + max_length=20, + choices=RoomType.choices, + default=RoomType.AUTO_EVALUATION, + db_index=True, + ) + auto_evaluation = models.OneToOneField( + "evaluation.AutoEvaluationModel", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="chat_room", + verbose_name=_("auto evaluation"), + ) + members = models.ManyToManyField( + "accounts.User", + blank=True, + related_name="chat_rooms", + verbose_name=_("members"), + ) + + def __str__(self): + if self.type == RoomType.AUTO_EVALUATION and self.auto_evaluation_id: + return f"AutoEval #{self.auto_evaluation_id} xonasi" + return f"Direct xona #{self.pk}" + + @classmethod + def _baker(cls): + return baker.make(cls) + + class Meta: + db_table = "ChatRoom" + verbose_name = _("Chat Xona") + verbose_name_plural = _("Chat Xonalar") + + +class ChatmessageModel(models.Model): + room = models.ForeignKey( + ChatroomModel, + on_delete=models.CASCADE, + related_name="messages", + verbose_name=_("room"), + ) + sender = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + related_name="chat_messages", + verbose_name=_("sender"), + ) + message_type = models.CharField( + verbose_name=_("message type"), + max_length=10, + choices=MessageType.choices, + default=MessageType.TEXT, + db_index=True, + ) + text = models.TextField(verbose_name=_("text"), null=True, blank=True) + file = models.FileField( + verbose_name=_("file"), + upload_to=chat_file_upload_path, + null=True, + blank=True, + ) + is_read = models.BooleanField( + verbose_name=_("is read"), + default=False, + db_index=True, + ) + created_at = models.DateTimeField( + verbose_name=_("created at"), + auto_now_add=True, + ) + + def __str__(self): + return f"#{self.pk} — {self.sender}" + + @classmethod + def _baker(cls): + return baker.make(cls) + + class Meta: + db_table = "ChatMessage" + verbose_name = _("Chat Xabar") + verbose_name_plural = _("Chat Xabarlar") + ordering = ["created_at"] + indexes = [ + models.Index( + fields=["room_id", "created_at"], + name="chat_room_date_idx", + ), + models.Index( + fields=["room_id", "is_read"], + name="chat_room_read_idx", + ), + ] diff --git a/core/apps/chat/permissions/__init__.py b/core/apps/chat/permissions/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/permissions/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/permissions/chat.py b/core/apps/chat/permissions/chat.py new file mode 100644 index 0000000..a8dcfe6 --- /dev/null +++ b/core/apps/chat/permissions/chat.py @@ -0,0 +1,23 @@ +from rest_framework import permissions + + +class ChatmessagePermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class ChatroomPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/chat/serializers/__init__.py b/core/apps/chat/serializers/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/serializers/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/serializers/chat/ChatMessage.py b/core/apps/chat/serializers/chat/ChatMessage.py new file mode 100644 index 0000000..72e0d75 --- /dev/null +++ b/core/apps/chat/serializers/chat/ChatMessage.py @@ -0,0 +1,75 @@ +from rest_framework import serializers + +from core.apps.chat.models import ChatmessageModel + + +class BaseChatmessageSerializer(serializers.ModelSerializer): + sender = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + + def get_sender(self, obj): + if obj.sender is None: + return None + full_name = obj.sender.get_full_name().strip() + if not full_name: + full_name = str(obj.sender.phone) + return { + "id": obj.sender.id, + "full_name": full_name, + "role": obj.sender.role, + } + + def get_file_url(self, obj): + if not obj.file: + return None + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.file.url) + return obj.file.url + + class Meta: + model = ChatmessageModel + fields = [ + "id", + "room", + "sender", + "message_type", + "text", + "file_url", + "is_read", + "created_at", + ] + + +class ListChatmessageSerializer(BaseChatmessageSerializer): + class Meta(BaseChatmessageSerializer.Meta): ... + + +class RetrieveChatmessageSerializer(BaseChatmessageSerializer): + class Meta(BaseChatmessageSerializer.Meta): ... + + +class CreateChatmessageSerializer(serializers.ModelSerializer): + class Meta: + model = ChatmessageModel + fields = [ + "room", + "message_type", + "text", + "file", + ] + + def validate(self, attrs): + message_type = attrs.get("message_type", "text") + text = attrs.get("text", "").strip() if attrs.get("text") else "" + file = attrs.get("file") + + if message_type == "text" and not text: + raise serializers.ValidationError({"text": "Matn xabari uchun text majburiy."}) + if message_type != "text" and not file: + raise serializers.ValidationError({"file": f"{message_type} xabari uchun fayl majburiy."}) + return attrs + + def create(self, validated_data): + validated_data["sender"] = self.context["request"].user + return super().create(validated_data) diff --git a/core/apps/chat/serializers/chat/ChatRoom.py b/core/apps/chat/serializers/chat/ChatRoom.py new file mode 100644 index 0000000..f4ae82f --- /dev/null +++ b/core/apps/chat/serializers/chat/ChatRoom.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + +from core.apps.chat.models import ChatroomModel + + +class MemberSerializer(serializers.Serializer): + id = serializers.IntegerField() + full_name = serializers.SerializerMethodField() + role = serializers.CharField() + + def get_full_name(self, obj): + name = obj.get_full_name().strip() + return name if name else str(obj.phone) + + +class BaseChatroomSerializer(serializers.ModelSerializer): + members = serializers.SerializerMethodField() + + def get_members(self, obj): + return [ + { + "id": u.id, + "full_name": (u.get_full_name().strip() or str(u.phone)), + "role": u.role, + } + for u in obj.members.only("id", "first_name", "last_name", "phone", "role") + ] + + class Meta: + model = ChatroomModel + fields = [ + "id", + "type", + "auto_evaluation", + "members", + "created_at", + ] + + +class ListChatroomSerializer(BaseChatroomSerializer): + class Meta(BaseChatroomSerializer.Meta): ... + + +class RetrieveChatroomSerializer(BaseChatroomSerializer): + class Meta(BaseChatroomSerializer.Meta): ... + + +class CreateChatroomSerializer(serializers.ModelSerializer): + type = serializers.ChoiceField( + choices=["auto_evaluation", "direct"], + required=True, + ) + + class Meta: + model = ChatroomModel + fields = [ + "type", + "auto_evaluation", + "members", + ] + + def validate(self, attrs): + room_type = attrs.get("type") + if room_type == "auto_evaluation" and not attrs.get("auto_evaluation"): + raise serializers.ValidationError( + {"auto_evaluation": "auto_evaluation turi uchun AutoEvaluation majburiy."} + ) + return attrs diff --git a/core/apps/chat/serializers/chat/__init__.py b/core/apps/chat/serializers/chat/__init__.py new file mode 100644 index 0000000..3776f85 --- /dev/null +++ b/core/apps/chat/serializers/chat/__init__.py @@ -0,0 +1,2 @@ +from .ChatMessage import * # noqa +from .ChatRoom import * # noqa diff --git a/core/apps/chat/signals/__init__.py b/core/apps/chat/signals/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/signals/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/signals/chat.py b/core/apps/chat/signals/chat.py new file mode 100644 index 0000000..b578ba1 --- /dev/null +++ b/core/apps/chat/signals/chat.py @@ -0,0 +1,55 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.chat.models import ChatmessageModel, ChatroomModel + + +@receiver(post_save, sender=ChatmessageModel) +def broadcast_new_message(sender, instance, created, **kwargs): + """Yangi xabar saqlanganda xonadagi barcha WS ulanishlariga yuboradi.""" + if not created: + return + + channel_layer = get_channel_layer() + if channel_layer is None: + return + + sender_obj = instance.sender + if sender_obj: + full_name = sender_obj.get_full_name().strip() or str(sender_obj.phone) + sender_data = { + "id": sender_obj.id, + "full_name": full_name, + "role": sender_obj.role, + } + else: + sender_data = None + + site_url = getattr(settings, "SITE_URL", "").rstrip("/") + file_url = (site_url + instance.file.url) if instance.file else None + + async_to_sync(channel_layer.group_send)( + f"chat_room_{instance.room_id}", + { + "type": "chat_message", + "id": instance.id, + "message_type": instance.message_type, + "text": instance.text, + "file_url": file_url, + "sender": sender_data, + "created_at": instance.created_at.isoformat(), + }, + ) + + +@receiver(post_save, sender="evaluation.AutoEvaluationModel") +def auto_create_chatroom(sender, instance, created, **kwargs): + """AutoEvaluation yaratilganda avtomatik chat xonasi ochiladi.""" + if created: + ChatroomModel.objects.get_or_create( + auto_evaluation=instance, + defaults={"type": "auto_evaluation"}, + ) diff --git a/core/apps/chat/tests/__init__.py b/core/apps/chat/tests/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/tests/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/tests/chat/__init__.py b/core/apps/chat/tests/chat/__init__.py new file mode 100644 index 0000000..e0a3d3f --- /dev/null +++ b/core/apps/chat/tests/chat/__init__.py @@ -0,0 +1,2 @@ +from .test_ChatMessage import * # noqa +from .test_ChatRoom import * # noqa diff --git a/core/apps/chat/tests/chat/test_ChatMessage.py b/core/apps/chat/tests/chat/test_ChatMessage.py new file mode 100644 index 0000000..2e204c7 --- /dev/null +++ b/core/apps/chat/tests/chat/test_ChatMessage.py @@ -0,0 +1,101 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.chat.models import ChatmessageModel + + +@pytest.fixture +def instance(db): + return ChatmessageModel._baker() + + +@pytest.fixture +def api_client(instance): + client = APIClient() + ##client.force_authenticate(user=instance.user) + return client, instance + + +@pytest.fixture +def data(api_client): + client, instance = api_client + return ( + { + "list": reverse("ChatMessage-list"), + "retrieve": reverse("ChatMessage-detail", kwargs={"pk": instance.pk}), + "retrieve-not-found": reverse("ChatMessage-detail", kwargs={"pk": 1000}), + }, + client, + instance, + ) + + +@pytest.mark.django_db +def test_list(data): + urls, client, _ = data + response = client.get(urls["list"]) + data_resp = response.json() + assert response.status_code == 200 + assert data_resp["status"] is True + + +@pytest.mark.django_db +def test_retrieve(data): + urls, client, _ = data + response = client.get(urls["retrieve"]) + data_resp = response.json() + assert response.status_code == 200 + assert data_resp["status"] is True + + +@pytest.mark.django_db +def test_retrieve_not_found(data): + urls, client, _ = data + response = client.get(urls["retrieve-not-found"]) + data_resp = response.json() + assert response.status_code == 404 + assert data_resp["status"] is False + + +# @pytest.mark.django_db +# def test_create(data): +# urls, client, _ = data +# response = client.post(urls["list"], data={"name": "test"}) +# assert response.json()["status"] is True +# assert response.status_code == 201 + + +# @pytest.mark.django_db +# def test_update(data): +# urls, client, _ = data +# response = client.patch(urls["retrieve"], data={"name": "updated"}) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# +# # verify updated value +# response = client.get(urls["retrieve"]) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# assert response.json()["data"]["name"] == "updated" + + +# @pytest.mark.django_db +# def test_partial_update(): +# urls, client, _ = data +# response = client.patch(urls["retrieve"], data={"name": "updated"}) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# +# # verify updated value +# response = client.get(urls["retrieve"]) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# assert response.json()["data"]["name"] == "updated" + + +# @pytest.mark.django_db +# def test_destroy(data): +# urls, client, _ = data +# response = client.delete(urls["retrieve"]) +# assert response.status_code == 204 diff --git a/core/apps/chat/tests/chat/test_ChatRoom.py b/core/apps/chat/tests/chat/test_ChatRoom.py new file mode 100644 index 0000000..b4f08e2 --- /dev/null +++ b/core/apps/chat/tests/chat/test_ChatRoom.py @@ -0,0 +1,101 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.chat.models import ChatroomModel + + +@pytest.fixture +def instance(db): + return ChatroomModel._baker() + + +@pytest.fixture +def api_client(instance): + client = APIClient() + ##client.force_authenticate(user=instance.user) + return client, instance + + +@pytest.fixture +def data(api_client): + client, instance = api_client + return ( + { + "list": reverse("ChatRoom-list"), + "retrieve": reverse("ChatRoom-detail", kwargs={"pk": instance.pk}), + "retrieve-not-found": reverse("ChatRoom-detail", kwargs={"pk": 1000}), + }, + client, + instance, + ) + + +@pytest.mark.django_db +def test_list(data): + urls, client, _ = data + response = client.get(urls["list"]) + data_resp = response.json() + assert response.status_code == 200 + assert data_resp["status"] is True + + +@pytest.mark.django_db +def test_retrieve(data): + urls, client, _ = data + response = client.get(urls["retrieve"]) + data_resp = response.json() + assert response.status_code == 200 + assert data_resp["status"] is True + + +@pytest.mark.django_db +def test_retrieve_not_found(data): + urls, client, _ = data + response = client.get(urls["retrieve-not-found"]) + data_resp = response.json() + assert response.status_code == 404 + assert data_resp["status"] is False + + +# @pytest.mark.django_db +# def test_create(data): +# urls, client, _ = data +# response = client.post(urls["list"], data={"name": "test"}) +# assert response.json()["status"] is True +# assert response.status_code == 201 + + +# @pytest.mark.django_db +# def test_update(data): +# urls, client, _ = data +# response = client.patch(urls["retrieve"], data={"name": "updated"}) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# +# # verify updated value +# response = client.get(urls["retrieve"]) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# assert response.json()["data"]["name"] == "updated" + + +# @pytest.mark.django_db +# def test_partial_update(): +# urls, client, _ = data +# response = client.patch(urls["retrieve"], data={"name": "updated"}) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# +# # verify updated value +# response = client.get(urls["retrieve"]) +# assert response.json()["status"] is True +# assert response.status_code == 200 +# assert response.json()["data"]["name"] == "updated" + + +# @pytest.mark.django_db +# def test_destroy(data): +# urls, client, _ = data +# response = client.delete(urls["retrieve"]) +# assert response.status_code == 204 diff --git a/core/apps/chat/translation/__init__.py b/core/apps/chat/translation/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/translation/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/translation/chat.py b/core/apps/chat/translation/chat.py new file mode 100644 index 0000000..884852e --- /dev/null +++ b/core/apps/chat/translation/chat.py @@ -0,0 +1,13 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.chat.models import ChatmessageModel, ChatroomModel + + +@register(ChatmessageModel) +class ChatmessageTranslation(TranslationOptions): + fields = [] + + +@register(ChatroomModel) +class ChatroomTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/chat/urls.py b/core/apps/chat/urls.py new file mode 100644 index 0000000..2bbdafe --- /dev/null +++ b/core/apps/chat/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ChatmessageView, ChatRoomView + +router = DefaultRouter() +router.register("rooms", ChatRoomView, basename="chat-rooms") +router.register("messages", ChatmessageView, basename="chat-messages") +urlpatterns = [path("", include(router.urls))] diff --git a/core/apps/chat/validators/__init__.py b/core/apps/chat/validators/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/validators/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/validators/chat.py b/core/apps/chat/validators/chat.py new file mode 100644 index 0000000..3829348 --- /dev/null +++ b/core/apps/chat/validators/chat.py @@ -0,0 +1,15 @@ +# from django.core.exceptions import ValidationError + + +class ChatmessageValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class ChatroomValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/chat/views/__init__.py b/core/apps/chat/views/__init__.py new file mode 100644 index 0000000..2940d30 --- /dev/null +++ b/core/apps/chat/views/__init__.py @@ -0,0 +1 @@ +from .chat import * # noqa diff --git a/core/apps/chat/views/chat.py b/core/apps/chat/views/chat.py new file mode 100644 index 0000000..6aa0b67 --- /dev/null +++ b/core/apps/chat/views/chat.py @@ -0,0 +1,172 @@ +from django_core.mixins import BaseViewSetMixin +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from core.apps.chat.filters.chat import ChatmessageFilter, ChatroomFilter +from core.apps.chat.models import ChatmessageModel, ChatroomModel +from core.apps.chat.serializers.chat import ( + CreateChatmessageSerializer, + CreateChatroomSerializer, + ListChatmessageSerializer, + ListChatroomSerializer, + RetrieveChatmessageSerializer, + RetrieveChatroomSerializer, +) + + +@extend_schema( + tags=["Chat"], + parameters=[ + OpenApiParameter("room", int, description="Xona ID bo'yicha filter"), + OpenApiParameter("message_type", str, description="Xabar turi: text, image, video, voice, file"), + OpenApiParameter("is_read", bool, description="O'qilgan/o'qilmagan"), + OpenApiParameter("created_from", str, description="Boshlanish sanasi (ISO 8601)"), + OpenApiParameter("created_to", str, description="Tugash sanasi (ISO 8601)"), + ], +) +class ChatmessageView(BaseViewSetMixin, ModelViewSet): + permission_classes = [IsAuthenticated] + parser_classes_for_create = None # multipart + json qo'llab-quvvatlanadi + + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ChatmessageFilter + ordering_fields = ["created_at"] + ordering = ["created_at"] + pagination_class = None + + action_permission_classes = {} + action_serializer_class = { + "list": ListChatmessageSerializer, + "retrieve": RetrieveChatmessageSerializer, + "create": CreateChatmessageSerializer, + } + + def get_serializer_class(self): + return self.action_serializer_class.get(self.action, ListChatmessageSerializer) + + def get_queryset(self): + return ( + ChatmessageModel.objects.select_related("sender") + .only( + "id", + "room_id", + "message_type", + "text", + "file", + "is_read", + "created_at", + "sender__id", + "sender__first_name", + "sender__last_name", + "sender__phone", + "sender__role", + ) + ) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + results = list(queryset) + serializer = self.get_serializer(results, many=True) + return Response( + { + "count": len(results), + "next": None, + "previous": None, + "results": serializer.data, + } + ) + + @extend_schema(description="Xabarlarni o'qilgan deb belgilash") + @action(detail=False, methods=["post"], url_path="mark-read") + def mark_read(self, request): + """ + Berilgan xona uchun barcha o'qilmagan xabarlarni o'qilgan qiladi. + Body: { "room": } + """ + room_id = request.data.get("room") + if not room_id: + return Response({"detail": "room majburiy."}, status=400) + + updated = ( + ChatmessageModel.objects.filter(room_id=room_id, is_read=False) + .exclude(sender=request.user) + .update(is_read=True) + ) + return Response({"updated": updated}) + + +@extend_schema( + tags=["ChatRoom"], + parameters=[ + OpenApiParameter("type", str, description="Xona turi: auto_evaluation, direct"), + OpenApiParameter("auto_evaluation", int, description="AutoEvaluation ID"), + ], +) +class ChatRoomView(BaseViewSetMixin, ModelViewSet): + permission_classes = [IsAuthenticated] + + filter_backends = [DjangoFilterBackend] + filterset_class = ChatroomFilter + pagination_class = None + + action_permission_classes = {} + action_serializer_class = { + "list": ListChatroomSerializer, + "retrieve": RetrieveChatroomSerializer, + "create": CreateChatroomSerializer, + } + + def get_serializer_class(self): + return self.action_serializer_class.get(self.action, ListChatroomSerializer) + + def get_queryset(self): + return ChatroomModel.objects.prefetch_related("members").only( + "id", + "type", + "auto_evaluation_id", + "created_at", + ) + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + results = list(queryset) + serializer = self.get_serializer(results, many=True) + return Response( + { + "count": len(results), + "next": None, + "previous": None, + "results": serializer.data, + } + ) + + @extend_schema( + description="Xonaga yangi a'zo qo'shish", + request={"application/json": {"type": "object", "properties": {"user_id": {"type": "integer"}}}}, + ) + @action(detail=True, methods=["post"], url_path="add-member") + def add_member(self, request, pk=None): + room = self.get_object() + user_id = request.data.get("user_id") + if not user_id: + return Response({"detail": "user_id majburiy."}, status=400) + room.members.add(user_id) + return Response({"detail": "A'zo qo'shildi."}) + + @extend_schema( + description="Xonadan a'zoni chiqarish", + request={"application/json": {"type": "object", "properties": {"user_id": {"type": "integer"}}}}, + ) + @action(detail=True, methods=["post"], url_path="remove-member") + def remove_member(self, request, pk=None): + room = self.get_object() + user_id = request.data.get("user_id") + if not user_id: + return Response({"detail": "user_id majburiy."}, status=400) + room.members.remove(user_id) + return Response({"detail": "A'zo chiqarildi."}) diff --git a/core/apps/evaluation/migrations/0026_alter_autoevaluationmodel_form_ownership_and_more.py b/core/apps/evaluation/migrations/0026_alter_autoevaluationmodel_form_ownership_and_more.py index 84ca448..d128938 100644 --- a/core/apps/evaluation/migrations/0026_alter_autoevaluationmodel_form_ownership_and_more.py +++ b/core/apps/evaluation/migrations/0026_alter_autoevaluationmodel_form_ownership_and_more.py @@ -11,6 +11,17 @@ class Migration(migrations.Migration): ] operations = [ + # Mavjud integer qiymatlar ReferenceItem'da yo'q — FK qo'yishdan oldin NULL qilamiz + migrations.RunSQL( + sql=""" + UPDATE "AutoEvaluation" + SET form_ownership = NULL, + property_rights = NULL, + rate_type = NULL, + value_determined = NULL; + """, + reverse_sql=migrations.RunSQL.noop, + ), migrations.AlterField( model_name='autoevaluationmodel', name='form_ownership',