feat(chat): add models, views, WebSocket consumers, forms and URL config
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'chat'
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}'
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import re_path
|
||||
from chat import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/channel/(?P<channel_id>\d+)/$', consumers.ChannelConsumer.as_asgi()),
|
||||
re_path(r'ws/dm/(?P<username>[\w.@+-]+)/$', consumers.DMConsumer.as_asgi()),
|
||||
]
|
||||
@@ -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/<int:channel_id>/', views.channel_view, name='channel'),
|
||||
path('channel/<int:channel_id>/delete/', views.delete_channel_view, name='delete_channel'),
|
||||
path('dm/<str:username>/', views.dm_view, name='dm'),
|
||||
path('profile/', views.profile_view, name='profile'),
|
||||
path('admin-panel/', views.admin_panel_view, name='admin_panel'),
|
||||
path('message/<int:message_id>/delete/', views.delete_message_view, name='delete_message'),
|
||||
path('user/<int:user_id>/block/', views.block_user_view, name='block_user'),
|
||||
]
|
||||
+223
@@ -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)
|
||||
Reference in New Issue
Block a user