Introduce content/cms/accounts modules with DRF routes, and point default DB settings at the official starintldb instance. Co-authored-by: Cursor <cursoragent@cursor.com>
495 lines
14 KiB
Python
495 lines
14 KiB
Python
import re
|
||
from datetime import date
|
||
|
||
from django.db import transaction
|
||
from django.db.models import Max
|
||
from rest_framework import serializers
|
||
|
||
from .models import (
|
||
Article,
|
||
ArticleComment,
|
||
ArticleType,
|
||
Column,
|
||
Magazine,
|
||
MagazineCategory,
|
||
Organize,
|
||
OrganizeCategory,
|
||
Professor,
|
||
RuleBand,
|
||
Specialty,
|
||
StarRule,
|
||
Subject,
|
||
WorkGroup,
|
||
WorkGroupMembership,
|
||
WorkGroupTerm,
|
||
)
|
||
|
||
|
||
class ColumnSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = Column
|
||
fields = [
|
||
"id",
|
||
"uuid",
|
||
"name",
|
||
"description",
|
||
"note",
|
||
"template_id",
|
||
"position",
|
||
"parent",
|
||
"sort",
|
||
"is_disabled",
|
||
"path",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class TaxonomySerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
fields = [
|
||
"id",
|
||
"serial_num",
|
||
"name",
|
||
"parent",
|
||
"sort",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class SubjectSerializer(TaxonomySerializer):
|
||
article_count = serializers.IntegerField(read_only=True, required=False)
|
||
|
||
class Meta(TaxonomySerializer.Meta):
|
||
model = Subject
|
||
fields = TaxonomySerializer.Meta.fields + ["article_count"]
|
||
|
||
|
||
class OrganizeCategorySerializer(TaxonomySerializer):
|
||
class Meta(TaxonomySerializer.Meta):
|
||
model = OrganizeCategory
|
||
|
||
|
||
class MagazineCategorySerializer(TaxonomySerializer):
|
||
class Meta(TaxonomySerializer.Meta):
|
||
model = MagazineCategory
|
||
|
||
|
||
class ArticleTypeSerializer(serializers.ModelSerializer):
|
||
article_count = serializers.IntegerField(read_only=True, required=False)
|
||
|
||
class Meta:
|
||
model = ArticleType
|
||
fields = [
|
||
"id",
|
||
"serial_num",
|
||
"code",
|
||
"name",
|
||
"note",
|
||
"sort",
|
||
"is_disabled",
|
||
"article_count",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
def validate_code(self, value):
|
||
code = (value or "").strip().upper()
|
||
if not code:
|
||
raise serializers.ValidationError("请填写字母标识")
|
||
if not re.fullmatch(r"[A-Z]{1,8}", code):
|
||
raise serializers.ValidationError("字母标识仅支持 1–8 位大写英文字母")
|
||
return code
|
||
|
||
|
||
class OrganizeListSerializer(serializers.ModelSerializer):
|
||
article_count = serializers.IntegerField(read_only=True, required=False)
|
||
avg_score = serializers.DecimalField(
|
||
max_digits=10, decimal_places=2, read_only=True, required=False
|
||
)
|
||
|
||
class Meta:
|
||
model = Organize
|
||
fields = [
|
||
"id",
|
||
"serial_num",
|
||
"name",
|
||
"logo",
|
||
"description",
|
||
"url",
|
||
"category",
|
||
"flags",
|
||
"collect_num",
|
||
"like_num",
|
||
"share_num",
|
||
"rank",
|
||
"is_disabled",
|
||
"article_count",
|
||
"avg_score",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class OrganizeSerializer(OrganizeListSerializer):
|
||
pass
|
||
|
||
|
||
class MagazineSerializer(serializers.ModelSerializer):
|
||
article_count = serializers.IntegerField(read_only=True, required=False)
|
||
avg_score = serializers.DecimalField(
|
||
max_digits=10, decimal_places=2, read_only=True, required=False
|
||
)
|
||
|
||
class Meta:
|
||
model = Magazine
|
||
fields = [
|
||
"id",
|
||
"serial_num",
|
||
"name",
|
||
"logo",
|
||
"category",
|
||
"subjects",
|
||
"is_disabled",
|
||
"article_count",
|
||
"avg_score",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class SpecialtySerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = Specialty
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"type",
|
||
"description",
|
||
"logo",
|
||
"content",
|
||
"position",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class WorkGroupSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = WorkGroup
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"parent",
|
||
"sort",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class WorkGroupTermSerializer(serializers.ModelSerializer):
|
||
workgroup_name = serializers.CharField(source="workgroup.name", read_only=True)
|
||
|
||
class Meta:
|
||
model = WorkGroupTerm
|
||
fields = [
|
||
"id",
|
||
"workgroup",
|
||
"workgroup_name",
|
||
"parent",
|
||
"name",
|
||
"start_year",
|
||
"end_year",
|
||
"is_current",
|
||
"sort",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class WorkGroupMembershipSerializer(serializers.ModelSerializer):
|
||
term_detail = WorkGroupTermSerializer(source="term", read_only=True)
|
||
|
||
class Meta:
|
||
model = WorkGroupMembership
|
||
fields = ["id", "professor", "term", "term_detail", "role"]
|
||
|
||
|
||
class ProfessorSerializer(serializers.ModelSerializer):
|
||
memberships = WorkGroupMembershipSerializer(many=True, read_only=True)
|
||
terms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
|
||
|
||
class Meta:
|
||
model = Professor
|
||
fields = [
|
||
"id",
|
||
"serial_num",
|
||
"name",
|
||
"type",
|
||
"role",
|
||
"job",
|
||
"description",
|
||
"email",
|
||
"addr",
|
||
"logo",
|
||
"phone",
|
||
"position",
|
||
"terms",
|
||
"memberships",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class RuleBandSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = RuleBand
|
||
fields = [
|
||
"id",
|
||
"rule",
|
||
"star_low",
|
||
"star_high",
|
||
"star",
|
||
"is_disabled",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
def validate_star(self, value):
|
||
if value is None:
|
||
return value
|
||
num = float(value)
|
||
if num < 0.5 or num > 5:
|
||
raise serializers.ValidationError("星级须在 0.5~5 之间")
|
||
# 仅允许 0.5 步进(如 1、1.5、2…)
|
||
if abs(num * 2 - round(num * 2)) > 1e-9:
|
||
raise serializers.ValidationError("星级须为 0.5 的倍数(如 1、1.5、2)")
|
||
return value
|
||
|
||
def validate_star_low(self, value):
|
||
if value is not None and float(value) < 0:
|
||
raise serializers.ValidationError("最低分不能为负数")
|
||
return value
|
||
|
||
def validate_star_high(self, value):
|
||
if value is not None and float(value) < 0:
|
||
raise serializers.ValidationError("最高分不能为负数")
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
instance = getattr(self, "instance", None)
|
||
low = attrs.get("star_low", getattr(instance, "star_low", None))
|
||
high = attrs.get("star_high", getattr(instance, "star_high", None))
|
||
star = attrs.get("star", getattr(instance, "star", None))
|
||
rule = attrs.get("rule", getattr(instance, "rule", None))
|
||
is_disabled = attrs.get(
|
||
"is_disabled",
|
||
getattr(instance, "is_disabled", False) if instance else False,
|
||
)
|
||
|
||
if low is not None and high is not None and float(low) > float(high):
|
||
raise serializers.ValidationError({"star_high": "最高分不能低于最低分"})
|
||
|
||
if rule is None or is_disabled:
|
||
return attrs
|
||
|
||
rule_id = getattr(rule, "pk", rule)
|
||
siblings = RuleBand.objects.filter(rule_id=rule_id, is_disabled=False)
|
||
if instance is not None:
|
||
siblings = siblings.exclude(pk=instance.pk)
|
||
|
||
if star is not None:
|
||
if siblings.filter(star=star).exists():
|
||
raise serializers.ValidationError(
|
||
{"star": f"该规则下已存在星级 {star} 的分档"}
|
||
)
|
||
|
||
if low is not None and high is not None:
|
||
low_f, high_f = float(low), float(high)
|
||
for other in siblings:
|
||
o_low, o_high = float(other.star_low), float(other.star_high)
|
||
# 闭区间重叠:[a,b] 与 [c,d] 当 a<=d 且 c<=b
|
||
if low_f <= o_high and o_low <= high_f:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"star_low": (
|
||
f"分数区间与已有分档 {other.star_low}~{other.star_high} "
|
||
f"({other.star} 星)重叠"
|
||
)
|
||
}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class StarRuleSerializer(serializers.ModelSerializer):
|
||
bands = RuleBandSerializer(many=True, read_only=True)
|
||
|
||
class Meta:
|
||
model = StarRule
|
||
fields = [
|
||
"id",
|
||
"name",
|
||
"year",
|
||
"is_disabled",
|
||
"bands",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
def validate_year(self, value):
|
||
if value is None:
|
||
return value
|
||
if value < 1900 or value > 2100:
|
||
raise serializers.ValidationError("请输入合理年份(1900~2100)")
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
instance = getattr(self, "instance", None)
|
||
year = attrs.get("year", getattr(instance, "year", None))
|
||
is_disabled = attrs.get(
|
||
"is_disabled",
|
||
getattr(instance, "is_disabled", False) if instance else False,
|
||
)
|
||
if year is None or is_disabled:
|
||
return attrs
|
||
qs = StarRule.objects.filter(year=year, is_disabled=False)
|
||
if instance is not None:
|
||
qs = qs.exclude(pk=instance.pk)
|
||
if qs.exists():
|
||
raise serializers.ValidationError(
|
||
{"year": f"已存在启用中的 {year} 年评分规则,请先禁用或改用其他年份"}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class ArticleListSerializer(serializers.ModelSerializer):
|
||
organizes = OrganizeListSerializer(many=True, read_only=True)
|
||
magazines = MagazineSerializer(many=True, read_only=True)
|
||
subjects = SubjectSerializer(many=True, read_only=True)
|
||
type_name = serializers.CharField(source="type.name", read_only=True)
|
||
|
||
class Meta:
|
||
model = Article
|
||
fields = [
|
||
"id",
|
||
"serial_number",
|
||
"title",
|
||
"type",
|
||
"type_name",
|
||
"year",
|
||
"score",
|
||
"url",
|
||
"flags",
|
||
"introduction",
|
||
"push_date",
|
||
"read_num",
|
||
"collect_num",
|
||
"like_num",
|
||
"share_num",
|
||
"rank",
|
||
"is_disabled",
|
||
"organizes",
|
||
"magazines",
|
||
"subjects",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
|
||
|
||
class ArticleSerializer(ArticleListSerializer):
|
||
class Meta(ArticleListSerializer.Meta):
|
||
fields = ArticleListSerializer.Meta.fields + ["content"]
|
||
|
||
|
||
def _next_article_serial(article_type, year=None):
|
||
"""
|
||
生成文献编号:{类型字母标识}{年份}{5位录入序号}
|
||
例:指南(G) + 2024 + 第 1 条 → G202400001
|
||
序号按「同类型字母 + 同年」的录入顺序递增。
|
||
"""
|
||
code = (getattr(article_type, "code", None) or "").strip().upper()
|
||
if not code:
|
||
raise serializers.ValidationError({"type": "该文献类型未配置字母标识"})
|
||
year = year or date.today().year
|
||
prefix = f"{code}{year}"
|
||
latest = (
|
||
Article.objects.filter(serial_number__startswith=prefix)
|
||
.aggregate(Max("serial_number"))
|
||
.get("serial_number__max")
|
||
)
|
||
seq = 1
|
||
if latest and latest.startswith(prefix):
|
||
tail = latest[len(prefix) :]
|
||
if tail.isdigit():
|
||
seq = int(tail) + 1
|
||
if seq > 99999:
|
||
raise serializers.ValidationError(
|
||
{"serial_number": f"{prefix} 当年序号已满,请联系管理员"}
|
||
)
|
||
return f"{prefix}{seq:05d}"
|
||
|
||
|
||
class ArticleWriteSerializer(serializers.ModelSerializer):
|
||
serial_number = serializers.CharField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Article
|
||
fields = [
|
||
"serial_number",
|
||
"title",
|
||
"type",
|
||
"year",
|
||
"score",
|
||
"url",
|
||
"flags",
|
||
"content",
|
||
"introduction",
|
||
"push_date",
|
||
"rank",
|
||
"is_disabled",
|
||
"organizes",
|
||
"magazines",
|
||
"subjects",
|
||
]
|
||
|
||
@transaction.atomic
|
||
def create(self, validated_data):
|
||
article_type = validated_data["type"]
|
||
validated_data["serial_number"] = _next_article_serial(
|
||
article_type, validated_data.get("year")
|
||
)
|
||
return super().create(validated_data)
|
||
|
||
|
||
class ArticleCommentSerializer(serializers.ModelSerializer):
|
||
member_name = serializers.CharField(source="member.name", read_only=True)
|
||
member_phone = serializers.CharField(source="member.phone", read_only=True)
|
||
member_organize = serializers.CharField(source="member.organize", read_only=True)
|
||
article_title = serializers.CharField(source="article.title", read_only=True)
|
||
|
||
class Meta:
|
||
model = ArticleComment
|
||
fields = [
|
||
"id",
|
||
"article",
|
||
"article_title",
|
||
"member",
|
||
"member_name",
|
||
"member_phone",
|
||
"member_organize",
|
||
"star",
|
||
"content",
|
||
"review_status",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|