Initial commit: React + Django 城市手册项目
- Django 4.2 + DRF + JWT + GraphQL - React 18 + MobX + styled-components - PostgreSQL 数据库 - Docker + Docker Compose + Nginx - 完整的功能模块(用户、版块、文章、服务、交互、版主管理) - 完整的文档(需求、部署、测试)
This commit is contained in:
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Apps package
|
||||
1
backend/apps/api/__init__.py
Normal file
1
backend/apps/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API app
|
||||
6
backend/apps/api/apps.py
Normal file
6
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.api'
|
||||
7
backend/apps/api/graphql_urls.py
Normal file
7
backend/apps/api/graphql_urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
from graphene_django.views import GraphQLView
|
||||
from apps.api.schema import schema
|
||||
|
||||
urlpatterns = [
|
||||
path('', GraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
]
|
||||
31
backend/apps/api/schema.py
Normal file
31
backend/apps/api/schema.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
class UserType(DjangoObjectType):
|
||||
"""GraphQL type for User."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
"""Root GraphQL Query."""
|
||||
|
||||
all_users = graphene.List(UserType)
|
||||
me = graphene.Field(UserType)
|
||||
|
||||
def resolve_all_users(root, info):
|
||||
"""Resolve all users query."""
|
||||
return User.objects.all()
|
||||
|
||||
def resolve_me(root, info):
|
||||
"""Resolve current user query."""
|
||||
if info.context.user.is_authenticated:
|
||||
return info.context.user
|
||||
return None
|
||||
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
29
backend/apps/api/serializers.py
Normal file
29
backend/apps/api/serializers.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
"""Custom JWT token serializer that includes user data."""
|
||||
|
||||
@classmethod
|
||||
def get_token(cls, user):
|
||||
token = super().get_token(user)
|
||||
# Add custom claims
|
||||
token['email'] = user.email
|
||||
token['username'] = user.username
|
||||
return token
|
||||
|
||||
def validate(self, attrs):
|
||||
data = super().validate(attrs)
|
||||
# Add user data to response
|
||||
data['user'] = {
|
||||
'id': self.user.id,
|
||||
'email': self.user.email,
|
||||
'username': self.user.username,
|
||||
'first_name': self.user.first_name,
|
||||
'last_name': self.user.last_name,
|
||||
}
|
||||
return data
|
||||
12
backend/apps/api/urls.py
Normal file
12
backend/apps/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenRefreshView,
|
||||
)
|
||||
from apps.users.urls import urlpatterns as users_urls
|
||||
from apps.api.views import CustomTokenObtainPairView
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('', include(users_urls)),
|
||||
]
|
||||
11
backend/apps/api/views.py
Normal file
11
backend/apps/api/views.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from .serializers import CustomTokenObtainPairSerializer
|
||||
|
||||
|
||||
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
"""Custom token view that returns user data with tokens."""
|
||||
|
||||
serializer_class = CustomTokenObtainPairSerializer
|
||||
1
backend/apps/articles/__init__.py
Normal file
1
backend/apps/articles/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Articles app
|
||||
7
backend/apps/articles/apps.py
Normal file
7
backend/apps/articles/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ArticlesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.articles'
|
||||
verbose_name = '文章管理'
|
||||
144
backend/apps/articles/models.py
Normal file
144
backend/apps/articles/models.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.regions.models import Region
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
"""Model for articles."""
|
||||
|
||||
ARTICLE_TYPE_CHOICES = [
|
||||
('basic', '城市信息'),
|
||||
('history', '历史'),
|
||||
('culture', '文化'),
|
||||
('practical', '实用'),
|
||||
('life', '生活'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('draft', '草稿'),
|
||||
('pending_moderator', '待版主审核'),
|
||||
('pending_ai', '待AI审核'),
|
||||
('published', '已发布'),
|
||||
('rejected', '已拒绝'),
|
||||
]
|
||||
|
||||
MODERATOR_STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '通过'),
|
||||
('rejected', '拒绝'),
|
||||
]
|
||||
|
||||
AI_STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '通过'),
|
||||
('rejected', '拒绝'),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name='标题')
|
||||
content = models.TextField(verbose_name='内容')
|
||||
region = models.ForeignKey(
|
||||
Region,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='articles',
|
||||
verbose_name='所属版块'
|
||||
)
|
||||
article_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ARTICLE_TYPE_CHOICES,
|
||||
verbose_name='内容类型'
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='articles',
|
||||
verbose_name='提交者'
|
||||
)
|
||||
|
||||
# Moderator review
|
||||
moderator_reviewer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='moderated_articles',
|
||||
verbose_name='版主审核人'
|
||||
)
|
||||
moderator_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=MODERATOR_STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='版主审核状态'
|
||||
)
|
||||
moderator_reviewed_at = models.DateTimeField(null=True, blank=True, verbose_name='版主审核时间')
|
||||
moderator_rejection_reason = models.TextField(null=True, blank=True, verbose_name='版主拒绝原因')
|
||||
|
||||
# AI review
|
||||
ai_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=AI_STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='AI审核状态'
|
||||
)
|
||||
ai_reviewed_at = models.DateTimeField(null=True, blank=True, verbose_name='AI审核时间')
|
||||
ai_rejection_reason = models.TextField(null=True, blank=True, verbose_name='AI拒绝原因')
|
||||
|
||||
# Publish status
|
||||
publish_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
verbose_name='发布状态'
|
||||
)
|
||||
published_at = models.DateTimeField(null=True, blank=True, verbose_name='发布时间')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'articles'
|
||||
verbose_name = '文章'
|
||||
verbose_name_plural = '文章'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def submit_for_review(self):
|
||||
"""Submit article for moderator review."""
|
||||
self.publish_status = 'pending_moderator'
|
||||
self.save()
|
||||
|
||||
def approve_moderator(self, reviewer, reason=''):
|
||||
"""Approve article by moderator."""
|
||||
self.moderator_status = 'approved'
|
||||
self.moderator_reviewer = reviewer
|
||||
self.moderator_reviewed_at = timezone.now()
|
||||
self.moderator_rejection_reason = reason
|
||||
self.publish_status = 'pending_ai'
|
||||
self.save()
|
||||
|
||||
def reject_moderator(self, reviewer, reason):
|
||||
"""Reject article by moderator."""
|
||||
self.moderator_status = 'rejected'
|
||||
self.moderator_reviewer = reviewer
|
||||
self.moderator_reviewed_at = timezone.now()
|
||||
self.moderator_rejection_reason = reason
|
||||
self.publish_status = 'rejected'
|
||||
self.save()
|
||||
|
||||
def approve_ai(self, reason=''):
|
||||
"""Approve article by AI."""
|
||||
self.ai_status = 'approved'
|
||||
self.ai_reviewed_at = timezone.now()
|
||||
self.ai_rejection_reason = reason
|
||||
self.publish_status = 'published'
|
||||
self.published_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def reject_ai(self, reason):
|
||||
"""Reject article by AI."""
|
||||
self.ai_status = 'rejected'
|
||||
self.ai_reviewed_at = timezone.now()
|
||||
self.ai_rejection_reason = reason
|
||||
self.publish_status = 'rejected'
|
||||
self.save()
|
||||
65
backend/apps/articles/serializers.py
Normal file
65
backend/apps/articles/serializers.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Article
|
||||
|
||||
|
||||
class ArticleSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Article model."""
|
||||
|
||||
article_type_display = serializers.CharField(source='get_article_type_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_publish_status_display', read_only=True)
|
||||
moderator_status_display = serializers.CharField(source='get_moderator_status_display', read_only=True)
|
||||
ai_status_display = serializers.CharField(source='get_ai_status_display', read_only=True)
|
||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = ('id', 'title', 'content', 'region', 'region_name', 'article_type', 'article_type_display',
|
||||
'author', 'author_username', 'moderator_status', 'moderator_status_display',
|
||||
'moderator_reviewer', 'moderator_reviewed_at', 'moderator_rejection_reason',
|
||||
'ai_status', 'ai_status_display', 'ai_reviewed_at', 'ai_rejection_reason',
|
||||
'publish_status', 'status_display', 'published_at', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'author', 'moderator_reviewer', 'moderator_reviewed_at',
|
||||
'ai_reviewed_at', 'published_at', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
class ArticleCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating articles."""
|
||||
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = ('title', 'content', 'region', 'article_type')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['author'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ArticleUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating articles."""
|
||||
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = ('title', 'content', 'article_type')
|
||||
|
||||
|
||||
class ArticleReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for article review actions."""
|
||||
|
||||
action = serializers.ChoiceField(choices=['approve', 'reject'])
|
||||
reason = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ArticleListSerializer(serializers.ModelSerializer):
|
||||
"""Simplified serializer for article list."""
|
||||
|
||||
article_type_display = serializers.CharField(source='get_article_type_display', read_only=True)
|
||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = ('id', 'title', 'article_type', 'article_type_display',
|
||||
'author', 'author_username', 'region', 'region_name',
|
||||
'publish_status', 'created_at')
|
||||
read_only_fields = ('id', 'created_at')
|
||||
10
backend/apps/articles/urls.py
Normal file
10
backend/apps/articles/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ArticleViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'articles', ArticleViewSet, basename='article')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
204
backend/apps/articles/views.py
Normal file
204
backend/apps/articles/views.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from rest_framework import viewsets, permissions, status, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from .models import Article
|
||||
from .serializers import (
|
||||
ArticleSerializer,
|
||||
ArticleCreateSerializer,
|
||||
ArticleUpdateSerializer,
|
||||
ArticleReviewSerializer,
|
||||
ArticleListSerializer
|
||||
)
|
||||
|
||||
|
||||
class ArticleViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Article model."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
search_fields = ['title', 'content']
|
||||
filterset_fields = ['article_type', 'region', 'publish_status']
|
||||
ordering_fields = ['created_at', 'updated_at', 'published_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Article.objects.select_related('author', 'region', 'moderator_reviewer')
|
||||
|
||||
# Only show published articles to non-authenticated users
|
||||
if not self.request.user.is_authenticated:
|
||||
return queryset.filter(publish_status='published')
|
||||
|
||||
# Show all for admins
|
||||
if self.request.user.is_admin():
|
||||
return queryset
|
||||
|
||||
# Show own articles + published articles for regular users
|
||||
return queryset.filter(
|
||||
Q(author=self.request.user) |
|
||||
Q(publish_status='published')
|
||||
).distinct()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return ArticleCreateSerializer
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return ArticleUpdateSerializer
|
||||
elif self.action == 'list':
|
||||
return ArticleListSerializer
|
||||
elif self.action in ['approve', 'reject', 'submit']:
|
||||
return ArticleReviewSerializer
|
||||
return ArticleSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# Only allow updating own articles or by admin
|
||||
if (not self.request.user.is_admin() and
|
||||
str(serializer.instance.author.id) != str(self.request.user.id)):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only update your own articles")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# Only allow deleting own articles or by admin
|
||||
if (not self.request.user.is_admin() and
|
||||
str(instance.author.id) != str(self.request.user.id)):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only delete your own articles")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""Submit article for review."""
|
||||
article = self.get_object()
|
||||
if article.author != request.user:
|
||||
return Response(
|
||||
{'detail': 'You can only submit your own articles'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
article.submit_for_review()
|
||||
return Response({'message': 'Article submitted for review'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
"""Approve article (moderator only)."""
|
||||
article = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.is_moderator():
|
||||
return Response(
|
||||
{'detail': 'Only moderators can approve articles'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Check if moderator has permission for this region
|
||||
from apps.moderation.models import ModeratorPermission
|
||||
has_permission = ModeratorPermission.objects.filter(
|
||||
moderator=request.user,
|
||||
region=article.region,
|
||||
status='active'
|
||||
).exists()
|
||||
|
||||
if not has_permission:
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to approve articles in this region'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
article.approve_moderator(
|
||||
reviewer=request.user,
|
||||
reason=serializer.validated_data.get('reason', '')
|
||||
)
|
||||
return Response({'message': 'Article approved'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
"""Reject article (moderator only)."""
|
||||
article = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.is_moderator():
|
||||
return Response(
|
||||
{'detail': 'Only moderators can reject articles'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Check if moderator has permission for this region
|
||||
from apps.moderation.models import ModeratorPermission
|
||||
has_permission = ModeratorPermission.objects.filter(
|
||||
moderator=request.user,
|
||||
region=article.region,
|
||||
status='active'
|
||||
).exists()
|
||||
|
||||
if not has_permission:
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to reject articles in this region'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
article.reject_moderator(
|
||||
reviewer=request.user,
|
||||
reason=serializer.validated_data.get('reason', 'Required reason')
|
||||
)
|
||||
return Response({'message': 'Article rejected'})
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def comments(self, request, pk=None):
|
||||
"""Get comments for an article."""
|
||||
article = self.get_object()
|
||||
from apps.interactions.serializers import CommentSerializer
|
||||
from apps.interactions.models import Comment
|
||||
comments = Comment.objects.filter(
|
||||
target_type='article',
|
||||
target_id=article.id,
|
||||
ai_status='approved'
|
||||
)
|
||||
serializer = CommentSerializer(comments, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def like(self, request, pk=None):
|
||||
"""Like or unlike an article."""
|
||||
article = self.get_object()
|
||||
from apps.interactions.models import Like
|
||||
|
||||
like, created = Like.objects.get_or_create(
|
||||
user=request.user,
|
||||
target_type='article',
|
||||
target_id=article.id
|
||||
)
|
||||
|
||||
if not created:
|
||||
like.delete()
|
||||
return Response({'message': 'Unliked', 'liked': False})
|
||||
|
||||
return Response({'message': 'Liked', 'liked': True})
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def stats(self, request, pk=None):
|
||||
"""Get article statistics."""
|
||||
article = self.get_object()
|
||||
from apps.interactions.models import Like, Comment, Rating
|
||||
|
||||
return Response({
|
||||
'likes_count': Like.objects.filter(
|
||||
target_type='article',
|
||||
target_id=article.id
|
||||
).count(),
|
||||
'comments_count': Comment.objects.filter(
|
||||
target_type='article',
|
||||
target_id=article.id,
|
||||
ai_status='approved'
|
||||
).count(),
|
||||
'views_count': getattr(article, 'views_count', 0),
|
||||
})
|
||||
1
backend/apps/core/__init__.py
Normal file
1
backend/apps/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core app
|
||||
6
backend/apps/core/apps.py
Normal file
6
backend/apps/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.core'
|
||||
1
backend/apps/featured_services/__init__.py
Normal file
1
backend/apps/featured_services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Featured services app
|
||||
7
backend/apps/featured_services/apps.py
Normal file
7
backend/apps/featured_services/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FeaturedServicesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.featured_services'
|
||||
verbose_name = '特色服务'
|
||||
149
backend/apps/featured_services/models.py
Normal file
149
backend/apps/featured_services/models.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.regions.models import Region
|
||||
|
||||
|
||||
class FeaturedService(models.Model):
|
||||
"""Model for featured services."""
|
||||
|
||||
SERVICE_CATEGORY_CHOICES = [
|
||||
('clothing', '衣'),
|
||||
('food', '食'),
|
||||
('accommodation', '住'),
|
||||
('transport', '行'),
|
||||
('entertainment', '娱乐'),
|
||||
('tourism', '旅游'),
|
||||
('culture', '文化'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('draft', '草稿'),
|
||||
('pending_moderator', '待版主审核'),
|
||||
('pending_ai', '待AI审核'),
|
||||
('published', '已发布'),
|
||||
('rejected', '已拒绝'),
|
||||
]
|
||||
|
||||
MODERATOR_STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '通过'),
|
||||
('rejected', '拒绝'),
|
||||
]
|
||||
|
||||
AI_STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '通过'),
|
||||
('rejected', '拒绝'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200, verbose_name='服务名称')
|
||||
description = models.TextField(verbose_name='服务描述')
|
||||
region = models.ForeignKey(
|
||||
Region,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='featured_services',
|
||||
verbose_name='所属版块'
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=SERVICE_CATEGORY_CHOICES,
|
||||
verbose_name='服务分类'
|
||||
)
|
||||
address = models.CharField(max_length=200, null=True, blank=True, verbose_name='地址')
|
||||
contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='联系方式')
|
||||
image = models.ImageField(upload_to='services/', null=True, blank=True, verbose_name='图片')
|
||||
submitter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='submitted_services',
|
||||
verbose_name='提交者'
|
||||
)
|
||||
|
||||
# Moderator review
|
||||
moderator_reviewer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='moderated_services',
|
||||
verbose_name='版主审核人'
|
||||
)
|
||||
moderator_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=MODERATOR_STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='版主审核状态'
|
||||
)
|
||||
moderator_reviewed_at = models.DateTimeField(null=True, blank=True, verbose_name='版主审核时间')
|
||||
moderator_rejection_reason = models.TextField(null=True, blank=True, verbose_name='版主拒绝原因')
|
||||
|
||||
# AI review
|
||||
ai_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=AI_STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='AI审核状态'
|
||||
)
|
||||
ai_reviewed_at = models.DateTimeField(null=True, blank=True, verbose_name='AI审核时间')
|
||||
ai_rejection_reason = models.TextField(null=True, blank=True, verbose_name='AI拒绝原因')
|
||||
|
||||
# Publish status
|
||||
publish_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
verbose_name='发布状态'
|
||||
)
|
||||
published_at = models.DateTimeField(null=True, blank=True, verbose_name='发布时间')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'featured_services'
|
||||
verbose_name = '特色服务'
|
||||
verbose_name_plural = '特色服务'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def submit_for_review(self):
|
||||
"""Submit service for moderator review."""
|
||||
self.publish_status = 'pending_moderator'
|
||||
self.save()
|
||||
|
||||
def approve_moderator(self, reviewer, reason=''):
|
||||
"""Approve service by moderator."""
|
||||
self.moderator_status = 'approved'
|
||||
self.moderator_reviewer = reviewer
|
||||
self.moderator_reviewed_at = timezone.now()
|
||||
self.moderator_rejection_reason = reason
|
||||
self.publish_status = 'pending_ai'
|
||||
self.save()
|
||||
|
||||
def reject_moderator(self, reviewer, reason):
|
||||
"""Reject service by moderator."""
|
||||
self.moderator_status = 'rejected'
|
||||
self.moderator_reviewer = reviewer
|
||||
self.moderator_reviewed_at = timezone.now()
|
||||
self.moderator_rejection_reason = reason
|
||||
self.publish_status = 'rejected'
|
||||
self.save()
|
||||
|
||||
def approve_ai(self, reason=''):
|
||||
"""Approve service by AI."""
|
||||
self.ai_status = 'approved'
|
||||
self.ai_reviewed_at = timezone.now()
|
||||
self.ai_rejection_reason = reason
|
||||
self.publish_status = 'published'
|
||||
self.published_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def reject_ai(self, reason):
|
||||
"""Reject service by AI."""
|
||||
self.ai_status = 'rejected'
|
||||
self.ai_reviewed_at = timezone.now()
|
||||
self.ai_rejection_reason = reason
|
||||
self.publish_status = 'rejected'
|
||||
self.save()
|
||||
66
backend/apps/featured_services/serializers.py
Normal file
66
backend/apps/featured_services/serializers.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from rest_framework import serializers
|
||||
from .models import FeaturedService
|
||||
|
||||
|
||||
class FeaturedServiceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for FeaturedService model."""
|
||||
|
||||
category_display = serializers.CharField(source='get_category_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_publish_status_display', read_only=True)
|
||||
moderator_status_display = serializers.CharField(source='get_moderator_status_display', read_only=True)
|
||||
ai_status_display = serializers.CharField(source='get_ai_status_display', read_only=True)
|
||||
submitter_username = serializers.CharField(source='submitter.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FeaturedService
|
||||
fields = ('id', 'name', 'description', 'region', 'region_name', 'category', 'category_display',
|
||||
'address', 'contact', 'image', 'submitter', 'submitter_username',
|
||||
'moderator_status', 'moderator_status_display', 'moderator_reviewer',
|
||||
'moderator_reviewed_at', 'moderator_rejection_reason',
|
||||
'ai_status', 'ai_status_display', 'ai_reviewed_at', 'ai_rejection_reason',
|
||||
'publish_status', 'status_display', 'published_at', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'submitter', 'moderator_reviewer', 'moderator_reviewed_at',
|
||||
'ai_reviewed_at', 'published_at', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
class FeaturedServiceCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating featured services."""
|
||||
|
||||
class Meta:
|
||||
model = FeaturedService
|
||||
fields = ('name', 'description', 'region', 'category', 'address', 'contact', 'image')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['submitter'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class FeaturedServiceUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating featured services."""
|
||||
|
||||
class Meta:
|
||||
model = FeaturedService
|
||||
fields = ('name', 'description', 'category', 'address', 'contact', 'image')
|
||||
|
||||
|
||||
class FeaturedServiceReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for service review actions."""
|
||||
|
||||
action = serializers.ChoiceField(choices=['approve', 'reject'])
|
||||
reason = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FeaturedServiceListSerializer(serializers.ModelSerializer):
|
||||
"""Simplified serializer for service list."""
|
||||
|
||||
category_display = serializers.CharField(source='get_category_display', read_only=True)
|
||||
submitter_username = serializers.CharField(source='submitter.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FeaturedService
|
||||
fields = ('id', 'name', 'category', 'category_display', 'image',
|
||||
'submitter', 'submitter_username', 'region', 'region_name',
|
||||
'publish_status', 'created_at')
|
||||
read_only_fields = ('id', 'created_at')
|
||||
10
backend/apps/featured_services/urls.py
Normal file
10
backend/apps/featured_services/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import FeaturedServiceViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'services', FeaturedServiceViewSet, basename='featured_service')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
227
backend/apps/featured_services/views.py
Normal file
227
backend/apps/featured_services/views.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from rest_framework import viewsets, permissions, status, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from .models import FeaturedService
|
||||
from .serializers import (
|
||||
FeaturedServiceSerializer,
|
||||
FeaturedServiceCreateSerializer,
|
||||
FeaturedServiceUpdateSerializer,
|
||||
FeaturedServiceReviewSerializer,
|
||||
FeaturedServiceListSerializer
|
||||
)
|
||||
|
||||
|
||||
class FeaturedServiceViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for FeaturedService model."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
search_fields = ['name', 'description']
|
||||
filterset_fields = ['category', 'region', 'publish_status']
|
||||
ordering_fields = ['created_at', 'updated_at', 'published_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = FeaturedService.objects.select_related('submitter', 'region', 'moderator_reviewer')
|
||||
|
||||
# Only show published services to non-authenticated users
|
||||
if not self.request.user.is_authenticated:
|
||||
return queryset.filter(publish_status='published')
|
||||
|
||||
# Show all for admins
|
||||
if self.request.user.is_admin():
|
||||
return queryset
|
||||
|
||||
# Show own services + published services for regular users
|
||||
return queryset.filter(
|
||||
Q(submitter=self.request.user) |
|
||||
Q(publish_status='published')
|
||||
).distinct()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return FeaturedServiceCreateSerializer
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return FeaturedServiceUpdateSerializer
|
||||
elif self.action == 'list':
|
||||
return FeaturedServiceListSerializer
|
||||
elif self.action in ['approve', 'reject', 'submit']:
|
||||
return FeaturedServiceReviewSerializer
|
||||
return FeaturedServiceSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(submitter=self.request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# Only allow updating own services or by admin
|
||||
if (not self.request.user.is_admin() and
|
||||
str(serializer.instance.submitter.id) != str(self.request.user.id)):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only update your own services")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# Only allow deleting own services or by admin
|
||||
if (not self.request.user.is_admin() and
|
||||
str(instance.submitter.id) != str(self.request.user.id)):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only delete your own services")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""Submit service for review."""
|
||||
service = self.get_object()
|
||||
if service.submitter != request.user:
|
||||
return Response(
|
||||
{'detail': 'You can only submit your own services'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
service.submit_for_review()
|
||||
return Response({'message': 'Service submitted for review'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
"""Approve service (moderator only)."""
|
||||
service = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.is_moderator():
|
||||
return Response(
|
||||
{'detail': 'Only moderators can approve services'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Check if moderator has permission for this region
|
||||
from apps.moderation.models import ModeratorPermission
|
||||
has_permission = ModeratorPermission.objects.filter(
|
||||
moderator=request.user,
|
||||
region=service.region,
|
||||
status='active'
|
||||
).exists()
|
||||
|
||||
if not has_permission:
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to approve services in this region'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
service.approve_moderator(
|
||||
reviewer=request.user,
|
||||
reason=serializer.validated_data.get('reason', '')
|
||||
)
|
||||
return Response({'message': 'Service approved'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
"""Reject service (moderator only)."""
|
||||
service = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.user.is_moderator():
|
||||
return Response(
|
||||
{'detail': 'Only moderators can reject services'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Check if moderator has permission for this region
|
||||
from apps.moderation.models import ModeratorPermission
|
||||
has_permission = ModeratorPermission.objects.filter(
|
||||
moderator=request.user,
|
||||
region=service.region,
|
||||
status='active'
|
||||
).exists()
|
||||
|
||||
if not has_permission:
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to reject services in this region'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
service.reject_moderator(
|
||||
reviewer=request.user,
|
||||
reason=serializer.validated_data.get('reason', 'Required reason')
|
||||
)
|
||||
return Response({'message': 'Service rejected'})
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def comments(self, request, pk=None):
|
||||
"""Get comments for a service."""
|
||||
service = self.get_object()
|
||||
from apps.interactions.serializers import CommentSerializer
|
||||
from apps.interactions.models import Comment
|
||||
comments = Comment.objects.filter(
|
||||
target_type='service',
|
||||
target_id=service.id,
|
||||
ai_status='approved'
|
||||
)
|
||||
serializer = CommentSerializer(comments, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def like(self, request, pk=None):
|
||||
"""Like or unlike a service."""
|
||||
service = self.get_object()
|
||||
from apps.interactions.models import Like
|
||||
|
||||
like, created = Like.objects.get_or_create(
|
||||
user=request.user,
|
||||
target_type='service',
|
||||
target_id=service.id
|
||||
)
|
||||
|
||||
if not created:
|
||||
like.delete()
|
||||
return Response({'message': 'Unliked', 'liked': False})
|
||||
|
||||
return Response({'message': 'Liked', 'liked': True})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def rate(self, request, pk=None):
|
||||
"""Rate a service."""
|
||||
service = self.get_object()
|
||||
from apps.interactions.serializers import RatingCreateSerializer
|
||||
|
||||
serializer = RatingCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(target_type='service', target_id=service.id)
|
||||
return Response({'message': 'Rating saved'})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def stats(self, request, pk=None):
|
||||
"""Get service statistics."""
|
||||
service = self.get_object()
|
||||
from apps.interactions.models import Like, Comment, Rating
|
||||
|
||||
likes_count = Like.objects.filter(
|
||||
target_type='service',
|
||||
target_id=service.id
|
||||
).count()
|
||||
|
||||
comments_count = Comment.objects.filter(
|
||||
target_type='service',
|
||||
target_id=service.id,
|
||||
ai_status='approved'
|
||||
).count()
|
||||
|
||||
ratings = Rating.objects.filter(
|
||||
target_type='service',
|
||||
target_id=service.id
|
||||
)
|
||||
|
||||
avg_rating = ratings.aggregate(avg=models.Avg('score'))['avg'] or 0
|
||||
|
||||
return Response({
|
||||
'likes_count': likes_count,
|
||||
'comments_count': comments_count,
|
||||
'avg_rating': round(avg_rating, 1),
|
||||
'ratings_count': ratings.count(),
|
||||
})
|
||||
1
backend/apps/interactions/__init__.py
Normal file
1
backend/apps/interactions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Interactions app
|
||||
7
backend/apps/interactions/apps.py
Normal file
7
backend/apps/interactions/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InteractionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.interactions'
|
||||
verbose_name = '交互功能'
|
||||
159
backend/apps/interactions/models.py
Normal file
159
backend/apps/interactions/models.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
"""Model for comments."""
|
||||
|
||||
AI_STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '通过'),
|
||||
('rejected', '拒绝'),
|
||||
]
|
||||
|
||||
TARGET_TYPE_CHOICES = [
|
||||
('article', '文章'),
|
||||
('service', '特色服务'),
|
||||
]
|
||||
|
||||
content = models.TextField(verbose_name='评论内容')
|
||||
target_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TARGET_TYPE_CHOICES,
|
||||
verbose_name='评论对象类型'
|
||||
)
|
||||
target_id = models.PositiveIntegerField(verbose_name='评论对象ID')
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='comments',
|
||||
verbose_name='评论者'
|
||||
)
|
||||
ai_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=AI_STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='AI审核状态'
|
||||
)
|
||||
ai_rejection_reason = models.TextField(null=True, blank=True, verbose_name='AI拒绝原因')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'comments'
|
||||
verbose_name = '评论'
|
||||
verbose_name_plural = '评论'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.author.username} on {self.target_type} {self.target_id}'
|
||||
|
||||
def approve_ai(self):
|
||||
"""Approve comment by AI."""
|
||||
self.ai_status = 'approved'
|
||||
self.save()
|
||||
|
||||
def reject_ai(self, reason):
|
||||
"""Reject comment by AI."""
|
||||
self.ai_status = 'rejected'
|
||||
self.ai_rejection_reason = reason
|
||||
self.save()
|
||||
|
||||
|
||||
class Rating(models.Model):
|
||||
"""Model for ratings."""
|
||||
|
||||
TARGET_TYPE_CHOICES = [
|
||||
('region', '城市'),
|
||||
('service', '特色服务'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='ratings',
|
||||
verbose_name='用户'
|
||||
)
|
||||
target_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TARGET_TYPE_CHOICES,
|
||||
verbose_name='评分对象类型'
|
||||
)
|
||||
target_id = models.PositiveIntegerField(verbose_name='评分对象ID')
|
||||
score = models.PositiveSmallIntegerField(verbose_name='评分值')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'ratings'
|
||||
verbose_name = '评分'
|
||||
verbose_name_plural = '评分'
|
||||
unique_together = ['user', 'target_type', 'target_id']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} rated {self.target_type} {self.target_id}: {self.score}'
|
||||
|
||||
|
||||
class Like(models.Model):
|
||||
"""Model for likes."""
|
||||
|
||||
TARGET_TYPE_CHOICES = [
|
||||
('article', '文章'),
|
||||
('service', '特色服务'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='likes',
|
||||
verbose_name='用户'
|
||||
)
|
||||
target_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TARGET_TYPE_CHOICES,
|
||||
verbose_name='点赞对象类型'
|
||||
)
|
||||
target_id = models.PositiveIntegerField(verbose_name='点赞对象ID')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'likes'
|
||||
verbose_name = '点赞'
|
||||
verbose_name_plural = '点赞'
|
||||
unique_together = ['user', 'target_type', 'target_id']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} likes {self.target_type} {self.target_id}'
|
||||
|
||||
|
||||
class Favorite(models.Model):
|
||||
"""Model for favorites."""
|
||||
|
||||
TARGET_TYPE_CHOICES = [
|
||||
('region', '城市'),
|
||||
('service', '特色服务'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorites',
|
||||
verbose_name='用户'
|
||||
)
|
||||
target_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TARGET_TYPE_CHOICES,
|
||||
verbose_name='收藏对象类型'
|
||||
)
|
||||
target_id = models.PositiveIntegerField(verbose_name='收藏对象ID')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'favorites'
|
||||
verbose_name = '收藏'
|
||||
verbose_name_plural = '收藏'
|
||||
unique_together = ['user', 'target_type', 'target_id']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} favorited {self.target_type} {self.target_id}'
|
||||
117
backend/apps/interactions/serializers.py
Normal file
117
backend/apps/interactions/serializers.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Comment, Rating, Like, Favorite
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Comment model."""
|
||||
|
||||
target_type_display = serializers.CharField(source='get_target_type_display', read_only=True)
|
||||
ai_status_display = serializers.CharField(source='get_ai_status_display', read_only=True)
|
||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ('id', 'content', 'target_type', 'target_type_display', 'target_id',
|
||||
'author', 'author_username', 'ai_status', 'ai_status_display',
|
||||
'ai_rejection_reason', 'created_at')
|
||||
read_only_fields = ('id', 'author', 'ai_status', 'ai_rejection_reason', 'created_at')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['author'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class CommentCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating comments."""
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ('content', 'target_type', 'target_id')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['author'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Rating model."""
|
||||
|
||||
target_type_display = serializers.CharField(source='get_target_type_display', read_only=True)
|
||||
user_username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ('id', 'user', 'user_username', 'target_type', 'target_type_display',
|
||||
'target_id', 'score', 'created_at')
|
||||
read_only_fields = ('id', 'user', 'created_at')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RatingCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating/updating ratings."""
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ('target_type', 'target_id', 'score')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
# Check if rating already exists
|
||||
Rating.objects.filter(
|
||||
user=validated_data['user'],
|
||||
target_type=validated_data['target_type'],
|
||||
target_id=validated_data['target_id']
|
||||
).delete()
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class LikeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Like model."""
|
||||
|
||||
target_type_display = serializers.CharField(source='get_target_type_display', read_only=True)
|
||||
user_username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Like
|
||||
fields = ('id', 'user', 'user_username', 'target_type', 'target_type_display',
|
||||
'target_id', 'created_at')
|
||||
read_only_fields = ('id', 'user', 'created_at')
|
||||
|
||||
|
||||
class FavoriteSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Favorite model."""
|
||||
|
||||
target_type_display = serializers.CharField(source='get_target_type_display', read_only=True)
|
||||
user_username = serializers.CharField(source='user.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Favorite
|
||||
fields = ('id', 'user', 'user_username', 'target_type', 'target_type_display',
|
||||
'target_id', 'created_at')
|
||||
read_only_fields = ('id', 'user', 'created_at')
|
||||
|
||||
|
||||
class FavoriteCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating/removing favorites."""
|
||||
|
||||
class Meta:
|
||||
model = Favorite
|
||||
fields = ('target_type', 'target_id')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
# Check if favorite already exists
|
||||
existing = Favorite.objects.filter(
|
||||
user=validated_data['user'],
|
||||
target_type=validated_data['target_type'],
|
||||
target_id=validated_data['target_id']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.delete()
|
||||
return None # Return None to indicate removal
|
||||
|
||||
return super().create(validated_data)
|
||||
18
backend/apps/interactions/urls.py
Normal file
18
backend/apps/interactions/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
CommentViewSet,
|
||||
RatingViewSet,
|
||||
LikeViewSet,
|
||||
FavoriteViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'comments', CommentViewSet, basename='comment')
|
||||
router.register(r'ratings', RatingViewSet, basename='rating')
|
||||
router.register(r'likes', LikeViewSet, basename='like')
|
||||
router.register(r'favorites', FavoriteViewSet, basename='favorite')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
226
backend/apps/interactions/views.py
Normal file
226
backend/apps/interactions/views.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from rest_framework import viewsets, permissions, status, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from .models import Comment, Rating, Like, Favorite
|
||||
from .serializers import (
|
||||
CommentSerializer,
|
||||
CommentCreateSerializer,
|
||||
RatingSerializer,
|
||||
RatingCreateSerializer,
|
||||
LikeSerializer,
|
||||
FavoriteSerializer,
|
||||
FavoriteCreateSerializer
|
||||
)
|
||||
|
||||
|
||||
class CommentViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Comment model."""
|
||||
|
||||
queryset = Comment.objects.select_related('author')
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
search_fields = ['content']
|
||||
filterset_fields = ['target_type', 'target_id', 'ai_status']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
# Only show approved comments
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.queryset.filter(ai_status='approved')
|
||||
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Regular users see approved + their own
|
||||
return self.queryset.filter(
|
||||
Q(ai_status='approved') |
|
||||
Q(author=self.request.user)
|
||||
).distinct()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return CommentCreateSerializer
|
||||
return CommentSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# Only allow updating own comments
|
||||
if str(serializer.instance.author.id) != str(self.request.user.id):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only update your own comments")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# Only allow deleting own comments or by admin
|
||||
if (not self.request.user.is_admin() and
|
||||
str(instance.author.id) != str(self.request.user.id)):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only delete your own comments")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve_ai(self, request, pk=None):
|
||||
"""Approve comment by AI (simulated)."""
|
||||
if not request.user.is_ai_auditor():
|
||||
return Response(
|
||||
{'detail': 'Only AI auditors can approve comments'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
comment = self.get_object()
|
||||
comment.approve_ai()
|
||||
return Response({'message': 'Comment approved by AI'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject_ai(self, request, pk=None):
|
||||
"""Reject comment by AI (simulated)."""
|
||||
if not request.user.is_ai_auditor():
|
||||
return Response(
|
||||
{'detail': 'Only AI auditors can reject comments'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
comment = self.get_object()
|
||||
reason = request.data.get('reason', 'Content violates guidelines')
|
||||
comment.reject_ai(reason)
|
||||
return Response({'message': 'Comment rejected by AI'})
|
||||
|
||||
|
||||
class RatingViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Rating model."""
|
||||
|
||||
queryset = Rating.objects.select_related('user')
|
||||
serializer_class = RatingSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
filterset_fields = ['target_type', 'target_id', 'user']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Regular users see their own ratings
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return RatingCreateSerializer
|
||||
return RatingSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# Only allow deleting own ratings
|
||||
if str(instance.user.id) != str(self.request.user.id):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("You can only delete your own ratings")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_ratings(self, request):
|
||||
"""Get current user's ratings."""
|
||||
ratings = Rating.objects.filter(user=request.user).select_related()
|
||||
serializer = self.get_serializer(ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class LikeViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Like model."""
|
||||
|
||||
queryset = Like.objects.select_related('user')
|
||||
serializer_class = LikeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
filterset_fields = ['target_type', 'target_id', 'user']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Regular users see their own likes
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def toggle(self, request):
|
||||
"""Toggle like on a target."""
|
||||
target_type = request.data.get('target_type')
|
||||
target_id = request.data.get('target_id')
|
||||
|
||||
if not target_type or not target_id:
|
||||
return Response(
|
||||
{'detail': 'target_type and target_id are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
like, created = Like.objects.get_or_create(
|
||||
user=request.user,
|
||||
target_type=target_type,
|
||||
target_id=target_id
|
||||
)
|
||||
|
||||
if not created:
|
||||
like.delete()
|
||||
return Response({'message': 'Unliked', 'liked': False})
|
||||
|
||||
return Response({'message': 'Liked', 'liked': True})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_likes(self, request):
|
||||
"""Get current user's likes."""
|
||||
likes = Like.objects.filter(user=request.user).select_related()
|
||||
serializer = self.get_serializer(likes, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class FavoriteViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Favorite model."""
|
||||
|
||||
queryset = Favorite.objects.select_related('user')
|
||||
serializer_class = FavoriteSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
filterset_fields = ['target_type', 'target_id', 'user']
|
||||
ordering_fields = ['created_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Regular users see their own favorites
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return FavoriteCreateSerializer
|
||||
return FavoriteSerializer
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def toggle(self, request):
|
||||
"""Toggle favorite on a target."""
|
||||
serializer = FavoriteCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
result = serializer.save()
|
||||
if result is None:
|
||||
return Response({'message': 'Unfavorited', 'favorited': False})
|
||||
return Response({'message': 'Favorited', 'favorited': True})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_favorites(self, request):
|
||||
"""Get current user's favorites."""
|
||||
favorites = Favorite.objects.filter(user=request.user).select_related()
|
||||
serializer = FavoriteSerializer(favorites, many=True)
|
||||
return Response(serializer.data)
|
||||
1
backend/apps/moderation/__init__.py
Normal file
1
backend/apps/moderation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Moderation app
|
||||
7
backend/apps/moderation/apps.py
Normal file
7
backend/apps/moderation/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.moderation'
|
||||
verbose_name = '版主管理'
|
||||
190
backend/apps/moderation/models.py
Normal file
190
backend/apps/moderation/models.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.regions.models import Region
|
||||
|
||||
|
||||
class ModeratorApplication(models.Model):
|
||||
"""Model for moderator applications."""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', '待审核'),
|
||||
('approved', '已通过'),
|
||||
('rejected', '已拒绝'),
|
||||
('cancelled', '已取消'),
|
||||
]
|
||||
|
||||
RANK_CHOICES = [
|
||||
('general', '将军'),
|
||||
('colonel', '校官'),
|
||||
('captain', '尉官'),
|
||||
('soldier', '士兵'),
|
||||
]
|
||||
|
||||
applicant = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderator_applications',
|
||||
verbose_name='申请者'
|
||||
)
|
||||
region = models.ForeignKey(
|
||||
Region,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderator_applications',
|
||||
verbose_name='申请的版块'
|
||||
)
|
||||
support_count = models.IntegerField(default=0, verbose_name='支持人数')
|
||||
deadline = models.DateTimeField(verbose_name='截止时间')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='状态')
|
||||
rank = models.CharField(max_length=20, choices=RANK_CHOICES, verbose_name='军衔级别')
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reviewed_applications',
|
||||
verbose_name='审核人'
|
||||
)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True, verbose_name='审核时间')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='申请时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'moderator_applications'
|
||||
verbose_name = '版主申请'
|
||||
verbose_name_plural = '版主申请'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.applicant.username} - {self.region.name} ({self.get_status_display()})'
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if the application has expired."""
|
||||
from django.utils import timezone
|
||||
return timezone.now() > self.deadline
|
||||
|
||||
def has_enough_support(self):
|
||||
"""Check if the application has enough support."""
|
||||
# TODO: Define minimum support count
|
||||
return self.support_count >= 10
|
||||
|
||||
|
||||
class ModeratorPermission(models.Model):
|
||||
"""Model for moderator permissions."""
|
||||
|
||||
PERMISSION_STATUS_CHOICES = [
|
||||
('active', '正常'),
|
||||
('restricted', '限制'),
|
||||
('revoked', '取消'),
|
||||
]
|
||||
|
||||
RANK_CHOICES = [
|
||||
('general', '将军'),
|
||||
('colonel', '校官'),
|
||||
('captain', '尉官'),
|
||||
('soldier', '士兵'),
|
||||
]
|
||||
|
||||
moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderator_permissions',
|
||||
verbose_name='版主'
|
||||
)
|
||||
region = models.ForeignKey(
|
||||
Region,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='moderator_permissions',
|
||||
verbose_name='管辖版块'
|
||||
)
|
||||
rank = models.CharField(max_length=20, choices=RANK_CHOICES, verbose_name='军衔级别')
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=PERMISSION_STATUS_CHOICES,
|
||||
default='active',
|
||||
verbose_name='权限状态'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
restricted_until = models.DateTimeField(null=True, blank=True, verbose_name='限制结束时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'moderator_permissions'
|
||||
verbose_name = '版主权限'
|
||||
verbose_name_plural = '版主权限'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.moderator.username} - {self.region.name} ({self.get_status_display()})'
|
||||
|
||||
def is_active(self):
|
||||
"""Check if the permission is currently active."""
|
||||
from django.utils import timezone
|
||||
if self.status != 'active':
|
||||
return False
|
||||
if self.restricted_until and timezone.now() < self.restricted_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ModeratorSupport(models.Model):
|
||||
"""Model for moderator application supports."""
|
||||
|
||||
supporter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='supported_applications',
|
||||
verbose_name='支持者'
|
||||
)
|
||||
application = models.ForeignKey(
|
||||
ModeratorApplication,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='supports',
|
||||
verbose_name='版主申请'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='支持时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'moderator_supports'
|
||||
verbose_name = '版主支持'
|
||||
verbose_name_plural = '版主支持'
|
||||
unique_together = ['supporter', 'application']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.supporter.username} supports {self.application.region.name}'
|
||||
|
||||
|
||||
class PermissionRestriction(models.Model):
|
||||
"""Model for permission restrictions."""
|
||||
|
||||
RESTRICTION_TYPE_CHOICES = [
|
||||
('partial', '部分限制'),
|
||||
('full', '完全限制'),
|
||||
]
|
||||
|
||||
operator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='created_restrictions',
|
||||
verbose_name='操作者'
|
||||
)
|
||||
target_moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='received_restrictions',
|
||||
verbose_name='被限制版主'
|
||||
)
|
||||
restriction_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=RESTRICTION_TYPE_CHOICES,
|
||||
verbose_name='限制类型'
|
||||
)
|
||||
start_time = models.DateTimeField(verbose_name='限制开始时间')
|
||||
end_time = models.DateTimeField(verbose_name='限制结束时间')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'permission_restrictions'
|
||||
verbose_name = '权限限制'
|
||||
verbose_name_plural = '权限限制'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.operator.username} restricted {self.target_moderator.username} ({self.get_restriction_type_display()})'
|
||||
92
backend/apps/moderation/serializers.py
Normal file
92
backend/apps/moderation/serializers.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from rest_framework import serializers
|
||||
from .models import (
|
||||
ModeratorApplication,
|
||||
ModeratorPermission,
|
||||
ModeratorSupport,
|
||||
PermissionRestriction
|
||||
)
|
||||
|
||||
|
||||
class ModeratorApplicationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ModeratorApplication model."""
|
||||
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
rank_display = serializers.CharField(source='get_rank_display', read_only=True)
|
||||
applicant_username = serializers.CharField(source='applicant.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
region_path = serializers.SerializerMethodField()
|
||||
is_expired = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModeratorApplication
|
||||
fields = ('id', 'applicant', 'applicant_username', 'region', 'region_name', 'region_path',
|
||||
'support_count', 'deadline', 'status', 'status_display', 'rank',
|
||||
'is_expired', 'reviewed_by', 'reviewed_at', 'created_at')
|
||||
read_only_fields = ('id', 'created_at', 'reviewed_by', 'reviewed_at')
|
||||
|
||||
def get_region_path(self, obj):
|
||||
return obj.region.get_full_path()
|
||||
|
||||
|
||||
class ModeratorApplicationCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating moderator applications."""
|
||||
|
||||
class Meta:
|
||||
model = ModeratorApplication
|
||||
fields = ('region', 'rank', 'deadline')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['applicant'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ModeratorPermissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ModeratorPermission model."""
|
||||
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
rank_display = serializers.CharField(source='get_rank_display', read_only=True)
|
||||
moderator_username = serializers.CharField(source='moderator.username', read_only=True)
|
||||
region_name = serializers.CharField(source='region.name', read_only=True)
|
||||
is_active = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModeratorPermission
|
||||
fields = ('id', 'moderator', 'moderator_username', 'region', 'region_name',
|
||||
'rank', 'rank_display', 'status', 'status_display', 'is_active',
|
||||
'restricted_until', 'created_at')
|
||||
read_only_fields = ('id', 'created_at')
|
||||
|
||||
|
||||
class ModeratorSupportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ModeratorSupport model."""
|
||||
|
||||
supporter_username = serializers.CharField(source='supporter.username', read_only=True)
|
||||
application_region_name = serializers.CharField(source='application.region.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ModeratorSupport
|
||||
fields = ('id', 'supporter', 'supporter_username', 'application',
|
||||
'application_region_name', 'created_at')
|
||||
read_only_fields = ('id', 'created_at')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['supporter'] = self.context['request'].user
|
||||
# Increment support count
|
||||
application = validated_data['application']
|
||||
application.support_count += 1
|
||||
application.save()
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class PermissionRestrictionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for PermissionRestriction model."""
|
||||
|
||||
restriction_type_display = serializers.CharField(source='get_restriction_type_display', read_only=True)
|
||||
operator_username = serializers.CharField(source='operator.username', read_only=True)
|
||||
target_moderator_username = serializers.CharField(source='target_moderator.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PermissionRestriction
|
||||
fields = ('id', 'operator', 'operator_username', 'target_moderator', 'target_moderator_username',
|
||||
'restriction_type', 'restriction_type_display', 'start_time', 'end_time', 'created_at')
|
||||
read_only_fields = ('id', 'created_at')
|
||||
16
backend/apps/moderation/urls.py
Normal file
16
backend/apps/moderation/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
ModeratorApplicationViewSet,
|
||||
ModeratorPermissionViewSet,
|
||||
PermissionRestrictionViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'applications', ModeratorApplicationViewSet, basename='moderator_application')
|
||||
router.register(r'permissions', ModeratorPermissionViewSet, basename='moderator_permission')
|
||||
router.register(r'restrictions', PermissionRestrictionViewSet, basename='permission_restriction')
|
||||
|
||||
urlpatterns = [
|
||||
path('moderator/', include(router.urls)),
|
||||
]
|
||||
197
backend/apps/moderation/views.py
Normal file
197
backend/apps/moderation/views.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from rest_framework import viewsets, permissions, status, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from .models import (
|
||||
ModeratorApplication,
|
||||
ModeratorPermission,
|
||||
ModeratorSupport,
|
||||
PermissionRestriction
|
||||
)
|
||||
from .serializers import (
|
||||
ModeratorApplicationSerializer,
|
||||
ModeratorApplicationCreateSerializer,
|
||||
ModeratorPermissionSerializer,
|
||||
ModeratorSupportSerializer,
|
||||
PermissionRestrictionSerializer
|
||||
)
|
||||
|
||||
|
||||
class ModeratorApplicationViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for ModeratorApplication model."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, filters.DjangoFilterBackend]
|
||||
search_fields = ['applicant__username', 'region__name']
|
||||
filterset_fields = ['status', 'rank', 'region']
|
||||
ordering_fields = ['created_at', 'deadline']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = ModeratorApplication.objects.select_related('applicant', 'region', 'reviewed_by')
|
||||
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return queryset
|
||||
|
||||
# Regular users see their own applications
|
||||
return queryset.filter(applicant=self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return ModeratorApplicationCreateSerializer
|
||||
return ModeratorApplicationSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def support(self, request, pk=None):
|
||||
"""Support a moderator application."""
|
||||
application = self.get_object()
|
||||
|
||||
# Check if application is still pending
|
||||
if application.status != 'pending':
|
||||
return Response(
|
||||
{'detail': 'Can only support pending applications'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if already supported
|
||||
if ModeratorSupport.objects.filter(
|
||||
supporter=request.user,
|
||||
application=application
|
||||
).exists():
|
||||
return Response(
|
||||
{'detail': 'Already supported this application'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = ModeratorSupportSerializer(data={'application': application.id})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Application supported', 'support_count': application.support_count})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
"""Approve moderator application (admin only)."""
|
||||
if not request.user.is_admin():
|
||||
return Response(
|
||||
{'detail': 'Only admins can approve applications'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
application = self.get_object()
|
||||
|
||||
if application.status != 'pending':
|
||||
return Response(
|
||||
{'detail': 'Can only approve pending applications'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if has enough support
|
||||
if not application.has_enough_support():
|
||||
return Response(
|
||||
{'detail': 'Not enough support votes'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create moderator permission
|
||||
from .models import ModeratorPermission
|
||||
ModeratorPermission.objects.create(
|
||||
moderator=application.applicant,
|
||||
region=application.region,
|
||||
rank=application.rank,
|
||||
status='active'
|
||||
)
|
||||
|
||||
# Update application status
|
||||
application.status = 'approved'
|
||||
application.reviewed_by = request.user
|
||||
application.reviewed_at = timezone.now()
|
||||
application.save()
|
||||
|
||||
return Response({'message': 'Application approved, moderator permissions granted'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
"""Reject moderator application (admin only)."""
|
||||
if not request.user.is_admin():
|
||||
return Response(
|
||||
{'detail': 'Only admins can reject applications'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
application = self.get_object()
|
||||
|
||||
if application.status != 'pending':
|
||||
return Response(
|
||||
{'detail': 'Can only reject pending applications'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
application.status = 'rejected'
|
||||
application.reviewed_by = request.user
|
||||
application.reviewed_at = timezone.now()
|
||||
application.save()
|
||||
|
||||
return Response({'message': 'Application rejected'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_applications(self, request):
|
||||
"""Get current user's applications."""
|
||||
applications = ModeratorApplication.objects.filter(
|
||||
applicant=request.user
|
||||
).select_related('region')
|
||||
serializer = self.get_serializer(applications, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ModeratorPermissionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for ModeratorPermission model (read-only)."""
|
||||
|
||||
queryset = ModeratorPermission.objects.select_related('moderator', 'region')
|
||||
serializer_class = ModeratorPermissionSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Moderators see their own permissions
|
||||
return self.queryset.filter(moderator=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_permissions(self, request):
|
||||
"""Get current user's moderator permissions."""
|
||||
permissions = ModeratorPermission.objects.filter(
|
||||
moderator=request.user,
|
||||
status='active'
|
||||
).select_related('region')
|
||||
serializer = self.get_serializer(permissions, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class PermissionRestrictionViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for PermissionRestriction model."""
|
||||
|
||||
queryset = PermissionRestriction.objects.select_related('operator', 'target_moderator')
|
||||
serializer_class = PermissionRestrictionSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
# Admins see all
|
||||
if self.request.user.is_admin():
|
||||
return self.queryset
|
||||
|
||||
# Moderators see restrictions on themselves
|
||||
return self.queryset.filter(target_moderator=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Only admins can create restrictions
|
||||
if not self.request.user.is_admin():
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Only admins can create restrictions")
|
||||
serializer.save(operator=self.request.user)
|
||||
1
backend/apps/regions/__init__.py
Normal file
1
backend/apps/regions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Regions app
|
||||
7
backend/apps/regions/apps.py
Normal file
7
backend/apps/regions/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RegionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.regions'
|
||||
verbose_name = '版块管理'
|
||||
54
backend/apps/regions/models.py
Normal file
54
backend/apps/regions/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Region(models.Model):
|
||||
"""Region model for hierarchical administrative divisions."""
|
||||
|
||||
LEVEL_CHOICES = [
|
||||
('province', '省'),
|
||||
('city', '市'),
|
||||
('county', '县'),
|
||||
('town', '乡镇/街道'),
|
||||
('village', '村/居委会'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', '正常'),
|
||||
('inactive', '停用'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name='版块名称')
|
||||
level = models.CharField(max_length=20, choices=LEVEL_CHOICES, verbose_name='版块级别')
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='children',
|
||||
verbose_name='上级版块'
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name='状态')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'regions'
|
||||
verbose_name = '版块'
|
||||
verbose_name_plural = '版块'
|
||||
ordering = ['level', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_full_path(self):
|
||||
"""Get the full hierarchical path of this region."""
|
||||
path = [self.name]
|
||||
parent = self.parent
|
||||
while parent:
|
||||
path.insert(0, parent.name)
|
||||
parent = parent.parent
|
||||
return ' → '.join(path)
|
||||
|
||||
def get_children(self):
|
||||
"""Get all direct children of this region."""
|
||||
return self.children.filter(status='active')
|
||||
53
backend/apps/regions/serializers.py
Normal file
53
backend/apps/regions/serializers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Region
|
||||
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Region model."""
|
||||
|
||||
level_display = serializers.CharField(source='get_level_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
parent_name = serializers.CharField(source='parent.name', read_only=True, allow_null=True)
|
||||
children_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ('id', 'name', 'level', 'level_display', 'parent', 'parent_name',
|
||||
'status', 'status_display', 'children_count', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||
|
||||
def get_children_count(self, obj):
|
||||
return obj.children.count()
|
||||
|
||||
|
||||
class RegionDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for Region model."""
|
||||
|
||||
level_display = serializers.CharField(source='get_level_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
parent = RegionSerializer(read_only=True)
|
||||
children = RegionSerializer(many=True, read_only=True)
|
||||
full_path = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ('id', 'name', 'level', 'level_display', 'parent', 'children',
|
||||
'status', 'status_display', 'full_path', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||
|
||||
def get_full_path(self, obj):
|
||||
return obj.get_full_path()
|
||||
|
||||
|
||||
class RegionTreeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Region tree structure."""
|
||||
|
||||
children = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ('id', 'name', 'level', 'status', 'children')
|
||||
|
||||
def get_children(self, obj):
|
||||
children = obj.get_children()
|
||||
return RegionTreeSerializer(children, many=True).data
|
||||
10
backend/apps/regions/urls.py
Normal file
10
backend/apps/regions/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import RegionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'regions', RegionViewSet, basename='region')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
141
backend/apps/regions/views.py
Normal file
141
backend/apps/regions/views.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from rest_framework import viewsets, permissions, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .models import Region
|
||||
from .serializers import (
|
||||
RegionSerializer,
|
||||
RegionDetailSerializer,
|
||||
RegionTreeSerializer
|
||||
)
|
||||
from apps.interactions.models import Rating, Favorite
|
||||
|
||||
|
||||
class RegionViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for Region model."""
|
||||
|
||||
queryset = Region.objects.filter(status='active')
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name']
|
||||
ordering_fields = ['name', 'level', 'created_at']
|
||||
ordering = ['level', 'name']
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
return RegionDetailSerializer
|
||||
elif self.action == 'tree':
|
||||
return RegionTreeSerializer
|
||||
return RegionSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Only admin can create regions
|
||||
if not self.request.user.is_admin():
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Only admins can create regions")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# Only admin can update regions
|
||||
if not self.request.user.is_admin():
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Only admins can update regions")
|
||||
serializer.save()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def provinces(self, request):
|
||||
"""Get all provinces (top-level regions)."""
|
||||
provinces = self.queryset.filter(parent__isnull=True)
|
||||
serializer = self.get_serializer(provinces, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def children(self, request, pk=None):
|
||||
"""Get children of a region."""
|
||||
region = self.get_object()
|
||||
children = region.get_children()
|
||||
serializer = self.get_serializer(children, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def path(self, request, pk=None):
|
||||
"""Get full path of a region."""
|
||||
region = self.get_object()
|
||||
path = []
|
||||
current = region
|
||||
while current:
|
||||
serializer = self.get_serializer(current)
|
||||
path.insert(0, serializer.data)
|
||||
current = current.parent
|
||||
return Response(path)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def tree(self, request):
|
||||
"""Get region tree structure."""
|
||||
root_regions = self.queryset.filter(parent__isnull=True)
|
||||
serializer = RegionTreeSerializer(root_regions, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def articles(self, request, pk=None):
|
||||
"""Get articles for a region."""
|
||||
region = self.get_object()
|
||||
articles = region.articles.filter(publish_status='published')
|
||||
from apps.articles.serializers import ArticleListSerializer
|
||||
serializer = ArticleListSerializer(articles, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def services(self, request, pk=None):
|
||||
"""Get featured services for a region."""
|
||||
region = self.get_object()
|
||||
services = region.featured_services.filter(publish_status='published')
|
||||
from apps.featured_services.serializers import FeaturedServiceListSerializer
|
||||
serializer = FeaturedServiceListSerializer(services, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def stats(self, request, pk=None):
|
||||
"""Get statistics for a region."""
|
||||
region = self.get_object()
|
||||
return Response({
|
||||
'articles_count': region.articles.filter(publish_status='published').count(),
|
||||
'services_count': region.featured_services.filter(publish_status='published').count(),
|
||||
'children_count': region.children.count(),
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def rate(self, request, pk=None):
|
||||
"""Rate a region."""
|
||||
region = self.get_object()
|
||||
serializer = RatingCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(target_type='region', target_id=region.id)
|
||||
return Response({'message': 'Rating saved'}, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def my_rating(self, request, pk=None):
|
||||
"""Get user's rating for a region."""
|
||||
region = self.get_object()
|
||||
try:
|
||||
rating = Rating.objects.get(
|
||||
user=request.user,
|
||||
target_type='region',
|
||||
target_id=region.id
|
||||
)
|
||||
return Response({'score': rating.score})
|
||||
except Rating.DoesNotExist:
|
||||
return Response({'score': None})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def favorite(self, request, pk=None):
|
||||
"""Favorite or unfavorite a region."""
|
||||
region = self.get_object()
|
||||
serializer = FavoriteCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
result = serializer.save(target_type='region', target_id=region.id)
|
||||
if result is None:
|
||||
return Response({'message': 'Unfavorited'}, status=200)
|
||||
return Response({'message': 'Favorited'}, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
1
backend/apps/users/__init__.py
Normal file
1
backend/apps/users/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Users app
|
||||
6
backend/apps/users/apps.py
Normal file
6
backend/apps/users/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
45
backend/apps/users/models.py
Normal file
45
backend/apps/users/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Custom user model extending AbstractUser."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
('user', '普通用户'),
|
||||
('moderator', '版主'),
|
||||
('ai_auditor', 'AI审核员'),
|
||||
('admin', '管理员'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', '正常'),
|
||||
('disabled', '禁用'),
|
||||
]
|
||||
|
||||
email = models.EmailField(unique=True)
|
||||
first_name = models.CharField(max_length=150)
|
||||
last_name = models.CharField(max_length=150)
|
||||
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username', 'first_name']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
verbose_name = 'User'
|
||||
verbose_name_plural = 'Users'
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def is_moderator(self):
|
||||
return self.role == 'moderator'
|
||||
|
||||
def is_admin(self):
|
||||
return self.role == 'admin'
|
||||
|
||||
def is_ai_auditor(self):
|
||||
return self.role == 'ai_auditor'
|
||||
38
backend/apps/users/serializers.py
Normal file
38
backend/apps/users/serializers.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for User model (basic info)."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'avatar', 'role', 'status')
|
||||
read_only_fields = ('id', 'role', 'status')
|
||||
|
||||
|
||||
class UserDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for User model."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id', 'date_joined', 'last_login')
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating user profile."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'avatar')
|
||||
|
||||
|
||||
class UserStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for user statistics."""
|
||||
articles_count = serializers.IntegerField()
|
||||
services_count = serializers.IntegerField()
|
||||
comments_count = serializers.IntegerField()
|
||||
likes_count = serializers.IntegerField()
|
||||
favorites_count = serializers.IntegerField()
|
||||
ratings_count = serializers.IntegerField()
|
||||
10
backend/apps/users/urls.py
Normal file
10
backend/apps/users/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import UserViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'users', UserViewSet, basename='user')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
153
backend/apps/users/views.py
Normal file
153
backend/apps/users/views.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Count, Q
|
||||
from .models import User
|
||||
from .serializers import (
|
||||
UserSerializer,
|
||||
UserDetailSerializer,
|
||||
UserUpdateSerializer,
|
||||
UserStatsSerializer
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for User model."""
|
||||
|
||||
queryset = User.objects.all()
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve' and self.kwargs.get('pk') == 'me':
|
||||
return UserDetailSerializer
|
||||
elif self.action in ['update', 'partial_update'] and self.kwargs.get('pk') == 'me':
|
||||
return UserUpdateSerializer
|
||||
return UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
# Only admins can see all users
|
||||
if self.request.user.is_admin():
|
||||
return User.objects.all()
|
||||
# Regular users can only see themselves
|
||||
return User.objects.filter(id=self.request.user.id)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Only admins can list all users."""
|
||||
if not request.user.is_admin():
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to perform this action.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""Get user details (me for current user)."""
|
||||
if kwargs.get('pk') == 'me':
|
||||
self.kwargs['pk'] = request.user.id
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update user details (only me for regular users)."""
|
||||
if kwargs.get('pk') == 'me':
|
||||
self.kwargs['pk'] = request.user.id
|
||||
elif not request.user.is_admin() and str(kwargs.get('pk')) != str(request.user.id):
|
||||
return Response(
|
||||
{'detail': 'You can only update your own profile.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
"""Get current user details."""
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def stats(self, request, pk=None):
|
||||
"""Get user statistics."""
|
||||
if pk == 'me':
|
||||
pk = request.user.id
|
||||
user = self.get_object()
|
||||
|
||||
if str(user.id) != str(request.user.id) and not request.user.is_admin():
|
||||
return Response(
|
||||
{'detail': 'You do not have permission to view this user\'s stats.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
from apps.articles.models import Article
|
||||
from apps.featured_services.models import FeaturedService
|
||||
from apps.interactions.models import Comment, Like, Favorite, Rating
|
||||
|
||||
return Response({
|
||||
'articles_count': Article.objects.filter(author=user).count(),
|
||||
'services_count': FeaturedService.objects.filter(submitter=user).count(),
|
||||
'comments_count': Comment.objects.filter(author=user).count(),
|
||||
'likes_count': Like.objects.filter(user=user).count(),
|
||||
'favorites_count': Favorite.objects.filter(user=user).count(),
|
||||
'ratings_count': Rating.objects.filter(user=user).count(),
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def favorites(self, request, pk=None):
|
||||
"""Get user's favorites."""
|
||||
if pk == 'me':
|
||||
pk = request.user.id
|
||||
user = self.get_object()
|
||||
|
||||
if str(user.id) != str(request.user.id):
|
||||
return Response(
|
||||
{'detail': 'You can only view your own favorites.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
from apps.interactions.serializers import FavoriteSerializer
|
||||
favorites = Favorite.objects.filter(user=user).select_related()
|
||||
serializer = FavoriteSerializer(favorites, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def ratings(self, request, pk=None):
|
||||
"""Get user's ratings."""
|
||||
if pk == 'me':
|
||||
pk = request.user.id
|
||||
user = self.get_object()
|
||||
|
||||
if str(user.id) != str(request.user.id):
|
||||
return Response(
|
||||
{'detail': 'You can only view your own ratings.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
from apps.interactions.serializers import RatingSerializer
|
||||
ratings = Rating.objects.filter(user=user).select_related()
|
||||
serializer = RatingSerializer(ratings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
"""Search users (admin only)."""
|
||||
if not request.user.is_admin():
|
||||
return Response(
|
||||
{'detail': 'Only admins can search users.'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
query = request.query_params.get('q', '')
|
||||
if query:
|
||||
users = User.objects.filter(
|
||||
Q(username__icontains=query) |
|
||||
Q(email__icontains=query) |
|
||||
Q(first_name__icontains=query)
|
||||
)
|
||||
else:
|
||||
users = User.objects.all()
|
||||
|
||||
page = self.paginate_queryset(users)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(users, many=True)
|
||||
return Response(serializer.data)
|
||||
Reference in New Issue
Block a user