First commit of claude's rework in django + vanillajs fronted

This commit is contained in:
Gérald Colangelo
2026-04-02 11:24:30 +02:00
parent 7710a876df
commit fde92f92db
163 changed files with 84852 additions and 15 deletions

0
apps/social/__init__.py Normal file
View File

33
apps/social/admin.py Normal file
View File

@@ -0,0 +1,33 @@
from django.contrib import admin
from .models import BlogPost, Bug, Friendship, Message
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ('sender', 'recipient', 'subject', 'sent_at', 'read_at')
list_filter = ('sent_at',)
search_fields = ('sender__email', 'recipient__email', 'subject')
readonly_fields = ('sent_at', 'read_at')
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ('author', 'title', 'is_public', 'created_at')
list_filter = ('is_public',)
search_fields = ('author__email', 'title')
@admin.register(Bug)
class BugAdmin(admin.ModelAdmin):
list_display = ('reporter', 'title', 'severity', 'status', 'created_at')
list_filter = ('severity', 'status')
search_fields = ('reporter__email', 'title')
readonly_fields = ('created_at', 'updated_at', 'resolved_at')
@admin.register(Friendship)
class FriendshipAdmin(admin.ModelAdmin):
list_display = ('from_user', 'to_user', 'status', 'created_at')
list_filter = ('status',)
search_fields = ('from_user__email', 'to_user__email')

6
apps/social/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SocialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.social'

View File

@@ -0,0 +1,81 @@
# Generated by Django 4.2.16 on 2026-04-01 19:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=200, verbose_name='subject')),
('body', models.TextField(verbose_name='body')),
('sent_at', models.DateTimeField(auto_now_add=True)),
('read_at', models.DateTimeField(blank=True, null=True)),
('deleted_by_sender', models.BooleanField(default=False)),
('deleted_by_recipient', models.BooleanField(default=False)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-sent_at'],
},
),
migrations.CreateModel(
name='Bug',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300, verbose_name='title')),
('description', models.TextField(verbose_name='description')),
('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', max_length=10, verbose_name='severity')),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=15, verbose_name='status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('reporter', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reported_bugs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='BlogPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300, verbose_name='title')),
('body', models.TextField(verbose_name='body')),
('is_public', models.BooleanField(default=True, verbose_name='public')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Friendship',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('blocked', 'Blocked')], default='pending', max_length=10, verbose_name='status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_sent', to=settings.AUTH_USER_MODEL)),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_received', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'unique_together': {('from_user', 'to_user')},
},
),
]

View File

111
apps/social/models.py Normal file
View File

@@ -0,0 +1,111 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# ── Message ───────────────────────────────────────────────────────────────────
class Message(models.Model):
sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_messages')
recipient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='received_messages')
subject = models.CharField(_('subject'), max_length=200)
body = models.TextField(_('body'))
sent_at = models.DateTimeField(auto_now_add=True)
read_at = models.DateTimeField(null=True, blank=True)
deleted_by_sender = models.BooleanField(default=False)
deleted_by_recipient = models.BooleanField(default=False)
class Meta:
ordering = ['-sent_at']
def __str__(self):
return f'{self.sender}{self.recipient}: {self.subject}'
# ── BlogPost ──────────────────────────────────────────────────────────────────
class BlogPost(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='blog_posts')
title = models.CharField(_('title'), max_length=300)
body = models.TextField(_('body'))
is_public = models.BooleanField(_('public'), default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
# ── Bug ───────────────────────────────────────────────────────────────────────
class BugSeverity(models.TextChoices):
LOW = 'low', _('Low')
MEDIUM = 'medium', _('Medium')
HIGH = 'high', _('High')
CRITICAL = 'critical', _('Critical')
class BugStatus(models.TextChoices):
OPEN = 'open', _('Open')
IN_PROGRESS = 'in_progress', _('In Progress')
RESOLVED = 'resolved', _('Resolved')
CLOSED = 'closed', _('Closed')
class Bug(models.Model):
reporter = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='reported_bugs',
)
title = models.CharField(_('title'), max_length=300)
description = models.TextField(_('description'))
severity = models.CharField(
_('severity'), max_length=10,
choices=BugSeverity.choices, default=BugSeverity.MEDIUM,
)
status = models.CharField(
_('status'), max_length=15,
choices=BugStatus.choices, default=BugStatus.OPEN,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
resolved_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f'[{self.get_severity_display()}] {self.title}'
# ── Friendship ────────────────────────────────────────────────────────────────
class FriendshipStatus(models.TextChoices):
PENDING = 'pending', _('Pending')
ACCEPTED = 'accepted', _('Accepted')
BLOCKED = 'blocked', _('Blocked')
class Friendship(models.Model):
from_user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_sent',
)
to_user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_received',
)
status = models.CharField(
_('status'), max_length=10,
choices=FriendshipStatus.choices, default=FriendshipStatus.PENDING,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [('from_user', 'to_user')]
ordering = ['-created_at']
def __str__(self):
return f'{self.from_user}{self.to_user} ({self.status})'

105
apps/social/serializers.py Normal file
View File

@@ -0,0 +1,105 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import BlogPost, Bug, Friendship, Message
User = get_user_model()
def _user_mini(user):
if user is None:
return None
name = (f'{user.first_name} {user.last_name}'.strip()) or user.username
return {'id': user.id, 'username': user.username, 'display_name': name}
# ── Message ───────────────────────────────────────────────────────────────────
class MessageListSerializer(serializers.ModelSerializer):
sender_detail = serializers.SerializerMethodField()
recipient_detail = serializers.SerializerMethodField()
def get_sender_detail(self, obj): return _user_mini(obj.sender)
def get_recipient_detail(self, obj): return _user_mini(obj.recipient)
class Meta:
model = Message
fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'sent_at', 'read_at']
class MessageDetailSerializer(serializers.ModelSerializer):
sender_detail = serializers.SerializerMethodField()
recipient_detail = serializers.SerializerMethodField()
def get_sender_detail(self, obj): return _user_mini(obj.sender)
def get_recipient_detail(self, obj): return _user_mini(obj.recipient)
class Meta:
model = Message
fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'body', 'sent_at', 'read_at']
class MessageCreateSerializer(serializers.ModelSerializer):
recipient = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
class Meta:
model = Message
fields = ['recipient', 'subject', 'body']
def validate_recipient(self, value):
if value == self.context['request'].user:
raise serializers.ValidationError('You cannot send a message to yourself.')
return value
# ── BlogPost ──────────────────────────────────────────────────────────────────
class BlogPostSerializer(serializers.ModelSerializer):
author_detail = serializers.SerializerMethodField()
def get_author_detail(self, obj): return _user_mini(obj.author)
class Meta:
model = BlogPost
fields = ['id', 'author_detail', 'title', 'body', 'is_public', 'created_at', 'updated_at']
read_only_fields = ['created_at', 'updated_at']
# ── Bug ───────────────────────────────────────────────────────────────────────
class BugSerializer(serializers.ModelSerializer):
reporter_detail = serializers.SerializerMethodField()
def get_reporter_detail(self, obj): return _user_mini(obj.reporter)
class Meta:
model = Bug
fields = [
'id', 'reporter_detail', 'title', 'description',
'severity', 'status', 'created_at', 'updated_at', 'resolved_at',
]
read_only_fields = ['status', 'resolved_at', 'created_at', 'updated_at']
# ── Friendship ────────────────────────────────────────────────────────────────
class FriendshipSerializer(serializers.ModelSerializer):
from_user_detail = serializers.SerializerMethodField()
to_user_detail = serializers.SerializerMethodField()
def get_from_user_detail(self, obj): return _user_mini(obj.from_user)
def get_to_user_detail(self, obj): return _user_mini(obj.to_user)
class Meta:
model = Friendship
fields = ['id', 'from_user_detail', 'to_user_detail', 'status', 'created_at']
read_only_fields = ['status', 'created_at']
class FriendRequestSerializer(serializers.Serializer):
to_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
def validate_to_user(self, value):
if value == self.context['request'].user:
raise serializers.ValidationError('You cannot send a friend request to yourself.')
return value

14
apps/social/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import BlogPostViewSet, BugViewSet, FriendshipViewSet, MessageViewSet, member_search
router = DefaultRouter()
router.register('social/messages', MessageViewSet, basename='message')
router.register('social/blog', BlogPostViewSet, basename='blogpost')
router.register('social/bugs', BugViewSet, basename='bug')
router.register('social/friends', FriendshipViewSet, basename='friendship')
urlpatterns = [
path('social/members/', member_search, name='member-search'),
] + router.urls

262
apps/social/views.py Normal file
View File

@@ -0,0 +1,262 @@
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import BlogPost, Bug, BugStatus, Friendship, FriendshipStatus, Message
from .serializers import (
BlogPostSerializer,
BugSerializer,
FriendRequestSerializer,
FriendshipSerializer,
MessageCreateSerializer,
MessageDetailSerializer,
MessageListSerializer,
)
User = get_user_model()
# ── Member search (for adding friends) ───────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def member_search(request):
q = request.query_params.get('q', '').strip()
if len(q) < 2:
return Response([])
users = (
User.objects
.exclude(pk=request.user.pk)
.filter(
Q(username__icontains=q) |
Q(first_name__icontains=q) |
Q(last_name__icontains=q)
)[:20]
)
data = [
{
'id': u.id,
'username': u.username,
'display_name': (f'{u.first_name} {u.last_name}'.strip()) or u.username,
}
for u in users
]
return Response(data)
# ── Messages ──────────────────────────────────────────────────────────────────
class MessageViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""Inbox: messages received by the current user."""
qs = (
Message.objects
.filter(recipient=request.user, deleted_by_recipient=False)
.select_related('sender', 'recipient')
.order_by('-sent_at')
)
return Response(MessageListSerializer(qs, many=True).data)
def create(self, request):
ser = MessageCreateSerializer(data=request.data, context={'request': request})
ser.is_valid(raise_exception=True)
msg = ser.save(sender=request.user)
return Response(MessageDetailSerializer(msg).data, status=status.HTTP_201_CREATED)
def retrieve(self, request, pk=None):
user = request.user
msg = get_object_or_404(
Message,
Q(sender=user, deleted_by_sender=False) |
Q(recipient=user, deleted_by_recipient=False),
pk=pk,
)
if msg.recipient == user and msg.read_at is None:
msg.read_at = timezone.now()
msg.save(update_fields=['read_at'])
return Response(MessageDetailSerializer(msg).data)
def destroy(self, request, pk=None):
user = request.user
msg = get_object_or_404(
Message,
Q(sender=user) | Q(recipient=user),
pk=pk,
)
if msg.sender == user:
msg.deleted_by_sender = True
if msg.recipient == user:
msg.deleted_by_recipient = True
msg.save(update_fields=['deleted_by_sender', 'deleted_by_recipient'])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get'], url_path='sent')
def sent(self, request):
qs = (
Message.objects
.filter(sender=request.user, deleted_by_sender=False)
.select_related('sender', 'recipient')
.order_by('-sent_at')
)
return Response(MessageListSerializer(qs, many=True).data)
@action(detail=False, methods=['get'], url_path='unread-count')
def unread_count(self, request):
count = Message.objects.filter(
recipient=request.user, read_at__isnull=True, deleted_by_recipient=False
).count()
return Response({'unread': count})
# ── BlogPost ──────────────────────────────────────────────────────────────────
class BlogPostViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = BlogPostSerializer
search_fields = ['title', 'body']
ordering_fields = ['created_at']
def get_queryset(self):
user = self.request.user
return (
BlogPost.objects
.filter(Q(author=user) | Q(is_public=True))
.select_related('author')
)
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def check_write_permission(self, instance):
from rest_framework.exceptions import PermissionDenied
if instance.author != self.request.user and not self.request.user.is_staff:
raise PermissionDenied
def perform_update(self, serializer):
self.check_write_permission(self.get_object())
serializer.save()
def perform_destroy(self, instance):
self.check_write_permission(instance)
instance.delete()
# ── Bug ───────────────────────────────────────────────────────────────────────
class BugViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = BugSerializer
filterset_fields = ['severity', 'status']
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'severity']
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Bug.objects.select_related('reporter').all()
return Bug.objects.filter(reporter=user).select_related('reporter')
def perform_create(self, serializer):
serializer.save(reporter=self.request.user)
@action(detail=True, methods=['post'], url_path='resolve')
def resolve(self, request, pk=None):
from rest_framework.exceptions import PermissionDenied
if not request.user.is_staff:
raise PermissionDenied
bug = self.get_object()
bug.status = BugStatus.RESOLVED
bug.resolved_at = timezone.now()
bug.save(update_fields=['status', 'resolved_at', 'updated_at'])
return Response(BugSerializer(bug).data)
# ── Friendship ────────────────────────────────────────────────────────────────
class FriendshipViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
serializer_class = FriendshipSerializer
def list(self, request):
"""Accepted friends."""
user = request.user
qs = (
Friendship.objects
.filter(Q(from_user=user) | Q(to_user=user), status=FriendshipStatus.ACCEPTED)
.select_related('from_user', 'to_user')
)
return Response(FriendshipSerializer(qs, many=True).data)
def create(self, request):
ser = FriendRequestSerializer(data=request.data, context={'request': request})
ser.is_valid(raise_exception=True)
to_user = ser.validated_data['to_user']
user = request.user
existing = Friendship.objects.filter(
Q(from_user=user, to_user=to_user) |
Q(from_user=to_user, to_user=user)
).first()
if existing:
return Response({'detail': 'A friendship or request already exists.'}, status=status.HTTP_409_CONFLICT)
friendship = Friendship.objects.create(from_user=user, to_user=to_user)
return Response(FriendshipSerializer(friendship).data, status=status.HTTP_201_CREATED)
def destroy(self, request, pk=None):
user = request.user
friendship = get_object_or_404(
Friendship,
Q(from_user=user) | Q(to_user=user),
pk=pk,
)
friendship.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get'], url_path='requests')
def requests(self, request):
"""Incoming pending requests."""
qs = (
Friendship.objects
.filter(to_user=request.user, status=FriendshipStatus.PENDING)
.select_related('from_user', 'to_user')
)
return Response(FriendshipSerializer(qs, many=True).data)
@action(detail=False, methods=['get'], url_path='sent-requests')
def sent_requests(self, request):
"""Outgoing pending requests."""
qs = (
Friendship.objects
.filter(from_user=request.user, status=FriendshipStatus.PENDING)
.select_related('from_user', 'to_user')
)
return Response(FriendshipSerializer(qs, many=True).data)
@action(detail=True, methods=['post'], url_path='accept')
def accept(self, request, pk=None):
friendship = get_object_or_404(
Friendship, pk=pk, to_user=request.user, status=FriendshipStatus.PENDING
)
friendship.status = FriendshipStatus.ACCEPTED
friendship.save(update_fields=['status', 'updated_at'])
return Response(FriendshipSerializer(friendship).data)
@action(detail=True, methods=['post'], url_path='decline')
def decline(self, request, pk=None):
"""Decline an incoming request or cancel an outgoing one."""
user = request.user
friendship = get_object_or_404(
Friendship,
Q(from_user=user) | Q(to_user=user),
pk=pk, status=FriendshipStatus.PENDING,
)
friendship.delete()
return Response(status=status.HTTP_204_NO_CONTENT)