diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..71ec4ff --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from chat.models import UserProfile, Channel, Message, DirectMessage + +admin.site.register(UserProfile) +admin.site.register(Channel) +admin.site.register(Message) +admin.site.register(DirectMessage) diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..2fe899a --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chat' diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..c51ceb4 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,159 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from django.utils import timezone + + +class ChannelConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.channel_id = self.scope['url_route']['kwargs']['channel_id'] + self.group_name = f'channel_{self.channel_id}' + self.user = self.scope['user'] + + if not self.user.is_authenticated: + await self.close() + return + + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.set_online(True) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + await self.set_online(False) + + async def receive(self, text_data): + data = json.loads(text_data) + content = data.get('message', '').strip() + + if not content: + return + + msg = await self.save_message(content) + + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'chat_message', + 'message': content, + 'sender': self.user.username, + 'sender_id': self.user.id, + 'message_id': msg.id, + 'timestamp': msg.timestamp.strftime('%H:%M'), + 'avatar': await self.get_avatar(), + 'role': await self.get_role(), + } + ) + + async def chat_message(self, event): + await self.send(text_data=json.dumps(event)) + + @database_sync_to_async + def save_message(self, content): + from chat.models import Message, Channel + channel = Channel.objects.get(id=self.channel_id) + return Message.objects.create(channel=channel, sender=self.user, content=content) + + @database_sync_to_async + def set_online(self, status): + try: + self.user.profile.online = status + self.user.profile.save(update_fields=['online']) + except Exception: + pass + + @database_sync_to_async + def get_avatar(self): + try: + av = self.user.profile.avatar + return av.url if av else None + except Exception: + return None + + @database_sync_to_async + def get_role(self): + try: + return self.user.profile.role + except Exception: + return 'user' + + +class DMConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.user = self.scope['user'] + self.other_username = self.scope['url_route']['kwargs']['username'] + + if not self.user.is_authenticated: + await self.close() + return + + other_id = await self.get_other_id() + if other_id is None: + await self.close() + return + + ids = sorted([self.user.id, other_id]) + self.group_name = f'dm_{ids[0]}_{ids[1]}' + + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.set_online(True) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + await self.set_online(False) + + async def receive(self, text_data): + data = json.loads(text_data) + content = data.get('message', '').strip() + if not content: + return + + msg = await self.save_dm(content) + + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'dm_message', + 'message': content, + 'sender': self.user.username, + 'sender_id': self.user.id, + 'message_id': msg.id, + 'timestamp': msg.timestamp.strftime('%H:%M'), + 'avatar': await self.get_avatar(), + } + ) + + async def dm_message(self, event): + await self.send(text_data=json.dumps(event)) + + @database_sync_to_async + def get_other_id(self): + from django.contrib.auth.models import User + try: + return User.objects.get(username=self.other_username).id + except User.DoesNotExist: + return None + + @database_sync_to_async + def save_dm(self, content): + from django.contrib.auth.models import User + from chat.models import DirectMessage + other = User.objects.get(username=self.other_username) + return DirectMessage.objects.create(sender=self.user, receiver=other, content=content) + + @database_sync_to_async + def set_online(self, status): + try: + self.user.profile.online = status + self.user.profile.save(update_fields=['online']) + except Exception: + pass + + @database_sync_to_async + def get_avatar(self): + try: + av = self.user.profile.avatar + return av.url if av else None + except Exception: + return None diff --git a/chat/forms.py b/chat/forms.py new file mode 100644 index 0000000..0fa73af --- /dev/null +++ b/chat/forms.py @@ -0,0 +1,41 @@ +from django import forms +from django.contrib.auth.models import User +from chat.models import UserProfile, Channel + + +class RegisterForm(forms.Form): + username = forms.CharField(max_length=150) + email = forms.EmailField() + password = forms.CharField(widget=forms.PasswordInput) + + def clean_username(self): + username = self.cleaned_data['username'] + if User.objects.filter(username=username).exists(): + raise forms.ValidationError('Username already taken.') + return username + + def clean_email(self): + email = self.cleaned_data['email'] + if User.objects.filter(email=email).exists(): + raise forms.ValidationError('Email already in use.') + return email + + +class ProfileForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = ['avatar', 'bio'] + widgets = { + 'bio': forms.Textarea(attrs={'rows': 3}), + } + + +class ChannelForm(forms.ModelForm): + class Meta: + model = Channel + fields = ['name', 'description'] + + +class MessageFileForm(forms.Form): + file = forms.FileField(required=False) + content = forms.CharField(required=False, widget=forms.HiddenInput) diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..23050e2 --- /dev/null +++ b/chat/models.py @@ -0,0 +1,66 @@ +from django.db import models +from django.contrib.auth.models import User + + +class UserProfile(models.Model): + ROLE_CHOICES = [ + ('admin', 'Administrator'), + ('moderator', 'Moderator'), + ('user', 'User'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) + bio = models.TextField(blank=True, default='') + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user') + blocked_users = models.ManyToManyField(User, related_name='blocked_by', blank=True) + online = models.BooleanField(default=False) + + def __str__(self): + return f'{self.user.username} ({self.role})' + + def is_admin(self): + return self.role == 'admin' + + def is_moderator(self): + return self.role in ('admin', 'moderator') + + +class Channel(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(blank=True, default='') + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_channels') + members = models.ManyToManyField(User, related_name='channels', blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'#{self.name}' + + +class Message(models.Model): + channel = models.ForeignKey(Channel, on_delete=models.CASCADE, related_name='messages') + sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='messages') + content = models.TextField(blank=True, default='') + file = models.FileField(upload_to='uploads/', null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + deleted = models.BooleanField(default=False) + + class Meta: + ordering = ['timestamp'] + + def __str__(self): + return f'{self.sender.username}: {self.content[:50]}' + + +class DirectMessage(models.Model): + sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_dms') + receiver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_dms') + content = models.TextField(blank=True, default='') + file = models.FileField(upload_to='dm_uploads/', null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['timestamp'] + + def __str__(self): + return f'DM {self.sender.username} → {self.receiver.username}' diff --git a/chat/routing.py b/chat/routing.py new file mode 100644 index 0000000..559f2d8 --- /dev/null +++ b/chat/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from chat import consumers + +websocket_urlpatterns = [ + re_path(r'ws/channel/(?P\d+)/$', consumers.ChannelConsumer.as_asgi()), + re_path(r'ws/dm/(?P[\w.@+-]+)/$', consumers.DMConsumer.as_asgi()), +] diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..d34de64 --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from chat import views + +urlpatterns = [ + path('', views.home_view, name='home'), + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), + path('channel/create/', views.create_channel_view, name='create_channel'), + path('channel//', views.channel_view, name='channel'), + path('channel//delete/', views.delete_channel_view, name='delete_channel'), + path('dm//', views.dm_view, name='dm'), + path('profile/', views.profile_view, name='profile'), + path('admin-panel/', views.admin_panel_view, name='admin_panel'), + path('message//delete/', views.delete_message_view, name='delete_message'), + path('user//block/', views.block_user_view, name='block_user'), +] diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..5e78100 --- /dev/null +++ b/chat/views.py @@ -0,0 +1,223 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.contrib import messages +from django.http import JsonResponse +from django.views.decorators.http import require_POST + +from chat.models import UserProfile, Channel, Message, DirectMessage +from chat.forms import RegisterForm, ProfileForm, ChannelForm, MessageFileForm +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + + +def _broadcast_message(group_name, message_type, msg, request_user): + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + group_name, + { + 'type': message_type, + 'message': msg.content, + 'sender': request_user.username, + 'sender_id': request_user.id, + 'message_id': msg.id, + 'timestamp': msg.timestamp.strftime('%H:%M'), + 'avatar': request_user.profile.avatar.url if request_user.profile.avatar else None, + 'role': request_user.profile.role, + 'file_url': msg.file.url if msg.file else None, + 'file_name': msg.file.name if msg.file else None, + } + ) + +def register_view(request): + form = RegisterForm(request.POST or None) + if request.method == 'POST' and form.is_valid(): + user = User.objects.create_user( + username=form.cleaned_data['username'], + email=form.cleaned_data['email'], + password=form.cleaned_data['password'], + ) + UserProfile.objects.create(user=user) + login(request, user) + return redirect('home') + return render(request, 'chat/register.html', {'form': form}) + + +def login_view(request): + error = None + if request.method == 'POST': + user = authenticate(request, + username=request.POST.get('username'), + password=request.POST.get('password')) + if user: + login(request, user) + return redirect('home') + error = 'Invalid credentials.' + return render(request, 'chat/login.html', {'error': error}) + + +def logout_view(request): + logout(request) + return redirect('login') + + +@login_required +def home_view(request): + channels = Channel.objects.all().prefetch_related('members') + users = User.objects.exclude(id=request.user.id).select_related('profile') + return render(request, 'chat/home.html', {'channels': channels, 'users': users}) + + +@login_required +def channel_view(request, channel_id): + channel = get_object_or_404(Channel, id=channel_id) + # Join if not member + if request.user not in channel.members.all(): + channel.members.add(request.user) + + # File upload via POST + if request.method == 'POST': + f = request.FILES.get('file') + if f: + msg = Message.objects.create( + channel=channel, + sender=request.user, + content=request.POST.get('content', ''), + file=f, + ) + _broadcast_message(f'channel_{channel.id}', 'chat_message', msg, request.user) + return redirect('channel', channel_id=channel_id) + + messages_qs = channel.messages.filter(deleted=False).select_related('sender', 'sender__profile') + return render(request, 'chat/channel.html', { + 'channel': channel, + 'messages': messages_qs, + 'all_channels': Channel.objects.all(), + }) + + +@login_required +def create_channel_view(request): + profile = request.user.profile + if not profile.is_moderator(): + messages.error(request, 'No permission.') + return redirect('home') + form = ChannelForm(request.POST or None) + if form.is_valid(): + ch = form.save(commit=False) + ch.created_by = request.user + ch.save() + ch.members.add(request.user) + return redirect('channel', channel_id=ch.id) + return render(request, 'chat/create_channel.html', {'form': form}) + + +@login_required +def delete_channel_view(request, channel_id): + channel = get_object_or_404(Channel, id=channel_id) + if request.user.profile.is_admin(): + channel.delete() + return redirect('home') + + +@login_required +def dm_view(request, username): + other = get_object_or_404(User, username=username) + if request.method == 'POST': + f = request.FILES.get('file') + if f: + msg = DirectMessage.objects.create( + sender=request.user, + receiver=other, + content=request.POST.get('content', ''), + file=f, + ) + ids = sorted([request.user.id, other.id]) + _broadcast_message(f'dm_{ids[0]}_{ids[1]}', 'dm_message', msg, request.user) + return redirect('dm', username=username) + + dms = DirectMessage.objects.filter( + sender__in=[request.user, other], + receiver__in=[request.user, other], + ).select_related('sender', 'sender__profile').order_by('timestamp') + + return render(request, 'chat/dm.html', { + 'other': other, + 'messages': dms, + 'all_users': User.objects.exclude(id=request.user.id).select_related('profile'), + }) + + +@login_required +def profile_view(request): + profile = request.user.profile + form = ProfileForm(request.POST or None, request.FILES or None, instance=profile) + if form.is_valid(): + form.save() + messages.success(request, 'Profile updated.') + return redirect('profile') + return render(request, 'chat/profile.html', {'form': form}) + + +def _handle_admin_action(request, action, target, role): + if action == 'set_role' and role: + target.profile.role = role + target.profile.save() + messages.success(request, f'Role updated for {target.username}.') + elif action == 'block': + request.user.profile.blocked_users.add(target) + messages.success(request, f'{target.username} blocked.') + elif action == 'unblock': + request.user.profile.blocked_users.remove(target) + messages.success(request, f'{target.username} unblocked.') + + +@login_required +def admin_panel_view(request): + if not request.user.profile.is_admin(): + messages.error(request, 'No permission.') + return redirect('home') + + users = User.objects.all().select_related('profile').exclude(id=request.user.id) + + if request.method == 'POST': + user_id = request.POST.get('user_id') + action = request.POST.get('action') + role = request.POST.get('role') + target = get_object_or_404(User, id=user_id) + + _handle_admin_action(request, action, target, role) + + return redirect('admin_panel') + + blocked_ids = list(request.user.profile.blocked_users.values_list('id', flat=True)) + return render(request, 'chat/admin_panel.html', { + 'users': users, + 'blocked_ids': blocked_ids, + }) + + +@login_required +def delete_message_view(request, message_id): + msg = get_object_or_404(Message, id=message_id) + if request.user.profile.is_moderator() or msg.sender == request.user: + msg.deleted = True + msg.save() + return redirect('channel', channel_id=msg.channel_id) + + +@login_required +def block_user_view(request, user_id): + if request.user.profile.is_moderator(): + target = get_object_or_404(User, id=user_id) + request.user.profile.blocked_users.add(target) + return redirect('home') + + +def handler404(request, exception): + return render(request, 'chat/404.html', status=404) + + +def handler500(request): + return render(request, 'chat/500.html', status=500)