Files
Barış Keserandgoogle-labs-jules[bot] cf0441ef71 feat: Add user social features (#353)
- Implement Follow and UserAnimeList models to allow user following and anime list tracking (watchlist, completed, dropped).
- Add `is_public` boolean field to User model for privacy control.
- Create serializers and ViewSets (FollowViewSet, UserAnimeListViewSet) with correct object-level permissions (IsOwnerOrReadOnly) to prevent IDOR vulnerabilities.
- Add `ActivityFeedViewSet` to aggregate recent badge and watch log activities of followed users.
- Add DRF routers in main `urls.py`.
- Include `test_social.py` with comprehensive API tests.
- Resolve migration conflict automatically using `--merge`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-10 20:49:37 +00:00

125 lines
4.6 KiB
Python

from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
from django.core.validators import RegexValidator
from django.utils.deconstruct import deconstructible
@deconstructible
class UsernameValidator(RegexValidator):
regex = r'^[\w-]+$'
message = _("Enter a valid username. This value may contain only letters, numbers, and _/- characters.")
flags = 0
class User(AbstractUser):
username_validator = UsernameValidator()
username = models.CharField(
_("username"),
max_length=150,
unique=True,
help_text=_("Required. 150 characters or fewer. Letters, digits, and _/- only."),
validators=[username_validator],
error_messages={
"unique": _("A user with that username already exists."),
},
)
is_premium = models.BooleanField(default=False, verbose_name=_("Premium Status"))
bio = models.TextField(_("bio"), blank=True, max_length=500)
is_public = models.BooleanField(default=True, verbose_name=_("Public Profile"))
def __str__(self):
return self.username
class Wallet(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='wallet')
balance = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_("Balance"))
def __str__(self):
return f"{self.user.username}'s Wallet: {self.balance:.2f}"
class WatchLog(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='watch_logs')
episode = models.ForeignKey('content.Episode', on_delete=models.CASCADE, related_name='watch_logs')
duration = models.PositiveIntegerField(help_text=_("Duration watched in seconds"))
watched_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['user', 'watched_at']),
]
def __str__(self):
return f"{self.user.username} watched {self.episode} for {self.duration}s"
class Badge(models.Model):
slug = models.SlugField(unique=True, help_text=_("Unique identifier for the badge logic"))
name = models.CharField(max_length=100)
description = models.TextField()
icon_url = models.URLField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class UserBadge(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='badges')
badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
awarded_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'badge')
verbose_name = _("User Badge")
verbose_name_plural = _("User Badges")
def __str__(self):
return f"{self.user.username} - {self.badge.name}"
class Notification(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
title = models.CharField(max_length=255)
message = models.TextField()
link = models.URLField(blank=True, null=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'is_read']),
models.Index(fields=['user', '-created_at']),
]
def __str__(self):
return f"Notification for {self.user.username}: {self.title}"
class Follow(models.Model):
follower = models.ForeignKey(User, related_name='following', on_delete=models.CASCADE)
following = models.ForeignKey(User, related_name='followers', on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('follower', 'following')
def __str__(self):
return f"{self.follower.username} follows {self.following.username}"
class UserAnimeList(models.Model):
STATUS_CHOICES = [
('watchlist', 'Watchlist'),
('completed', 'Completed'),
('dropped', 'Dropped'),
]
user = models.ForeignKey(User, related_name='anime_lists', on_delete=models.CASCADE)
anime = models.ForeignKey('content.Anime', related_name='user_lists', on_delete=models.CASCADE)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='watchlist')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'anime')
def __str__(self):
return f"{self.user.username} - {self.anime.title} ({self.get_status_display()})"