feat(chat): add models, views, WebSocket consumers, forms and URL config

This commit is contained in:
2026-04-28 12:36:36 +02:00
parent 9295617d8e
commit 57950e73da
9 changed files with 526 additions and 0 deletions
View File
+7
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'chat'
+159
View File
@@ -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
+41
View File
@@ -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)
+66
View File
@@ -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}'
+7
View File
@@ -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()),
]
+17
View File
@@ -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
View File
@@ -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)