Move Django apps out of apps/ and mount them under auth/, common/, content/, and cms/ so endpoint paths map directly to the owning app. Co-authored-by: Cursor <cursoragent@cursor.com>
562 lines
18 KiB
Python
562 lines
18 KiB
Python
from django.db import models
|
||
|
||
from common.models import SoftDeleteModel, TimeStampedModel
|
||
|
||
|
||
class NavPosition(models.IntegerChoices):
|
||
TOP = 1, "顶部"
|
||
MIDDLE = 2, "中部"
|
||
BOTTOM = 3, "底部"
|
||
|
||
|
||
class Column(SoftDeleteModel):
|
||
"""导航栏目。"""
|
||
|
||
uuid = models.CharField(max_length=64, blank=True, default="", verbose_name="栏目 UUID")
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
description = models.TextField(blank=True, default="", verbose_name="描述")
|
||
note = models.TextField(blank=True, default="", verbose_name="备注")
|
||
template_id = models.IntegerField(null=True, blank=True, verbose_name="页面模板")
|
||
position = models.IntegerField(
|
||
choices=NavPosition.choices, default=NavPosition.TOP, verbose_name="位置"
|
||
)
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="父栏目",
|
||
)
|
||
sort = models.IntegerField(default=0, verbose_name="排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
path = models.CharField(max_length=200, blank=True, default="", verbose_name="前端路径")
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "栏目"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class TaxonomyBase(SoftDeleteModel):
|
||
"""分类字典公共字段。"""
|
||
|
||
serial_num = models.CharField(max_length=64, blank=True, default="", verbose_name="编号")
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
sort = models.IntegerField(default=0, verbose_name="排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
abstract = True
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Subject(TaxonomyBase):
|
||
"""学科。"""
|
||
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="父学科",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "学科"
|
||
verbose_name_plural = verbose_name
|
||
|
||
|
||
class OrganizeCategory(TaxonomyBase):
|
||
"""机构类别。"""
|
||
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="父类别",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "机构类别"
|
||
verbose_name_plural = verbose_name
|
||
|
||
|
||
class MagazineCategory(TaxonomyBase):
|
||
"""杂志类别。"""
|
||
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="父类别",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "杂志类别"
|
||
verbose_name_plural = verbose_name
|
||
|
||
|
||
class ArticleType(TaxonomyBase):
|
||
"""文献类型(指南、共识等,可后台维护)。"""
|
||
|
||
code = models.CharField(
|
||
max_length=8,
|
||
unique=True,
|
||
verbose_name="字母标识",
|
||
help_text="用于文献编号前缀,如 G、C",
|
||
)
|
||
note = models.TextField(blank=True, default="", verbose_name="备注")
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "文献类型"
|
||
verbose_name_plural = verbose_name
|
||
|
||
|
||
class Organize(SoftDeleteModel):
|
||
"""制订机构。"""
|
||
|
||
serial_num = models.CharField(max_length=64, blank=True, default="", verbose_name="编号")
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
logo = models.URLField(blank=True, default="", verbose_name="Logo")
|
||
description = models.TextField(blank=True, default="", verbose_name="描述")
|
||
url = models.URLField(blank=True, default="", verbose_name="官网")
|
||
category = models.ForeignKey(
|
||
OrganizeCategory,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="organizes",
|
||
verbose_name="机构类别",
|
||
)
|
||
flags = models.JSONField(default=list, blank=True, verbose_name="标签")
|
||
collect_num = models.PositiveIntegerField(default=0, verbose_name="收藏数")
|
||
like_num = models.PositiveIntegerField(default=0, verbose_name="点赞数")
|
||
share_num = models.PositiveIntegerField(default=0, verbose_name="分享数")
|
||
rank = models.IntegerField(null=True, blank=True, verbose_name="首页排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["rank", "id"]
|
||
verbose_name = "机构"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Magazine(SoftDeleteModel):
|
||
"""杂志。"""
|
||
|
||
serial_num = models.CharField(max_length=64, blank=True, default="", verbose_name="编号")
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
logo = models.URLField(blank=True, default="", verbose_name="Logo")
|
||
category = models.ForeignKey(
|
||
MagazineCategory,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="magazines",
|
||
verbose_name="杂志类别",
|
||
)
|
||
subjects = models.ManyToManyField(
|
||
Subject,
|
||
blank=True,
|
||
related_name="magazines",
|
||
verbose_name="学科",
|
||
)
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["id"]
|
||
verbose_name = "杂志"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Specialty(SoftDeleteModel):
|
||
"""专科/专长。"""
|
||
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
type = models.IntegerField(null=True, blank=True, verbose_name="类型")
|
||
description = models.TextField(blank=True, default="", verbose_name="描述")
|
||
logo = models.URLField(blank=True, default="", verbose_name="Logo")
|
||
content = models.JSONField(default=list, blank=True, verbose_name="内容块")
|
||
position = models.IntegerField(default=0, verbose_name="排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["position", "id"]
|
||
verbose_name = "专科"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class WorkGroup(SoftDeleteModel):
|
||
"""工作组模板(可含分委会层级)。"""
|
||
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
description = models.TextField(blank=True, default="", verbose_name="描述")
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="上级工作组",
|
||
)
|
||
sort = models.IntegerField(default=0, verbose_name="排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["sort", "id"]
|
||
verbose_name = "工作组"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class WorkGroupTerm(SoftDeleteModel):
|
||
"""工作组某一届;parent 表示同届内的分委会层级。"""
|
||
|
||
workgroup = models.ForeignKey(
|
||
WorkGroup,
|
||
on_delete=models.CASCADE,
|
||
related_name="terms",
|
||
verbose_name="工作组",
|
||
)
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
verbose_name="同届上级",
|
||
)
|
||
name = models.CharField(max_length=200, verbose_name="届次名称")
|
||
start_year = models.IntegerField(null=True, blank=True, verbose_name="起始年")
|
||
end_year = models.IntegerField(null=True, blank=True, verbose_name="结束年")
|
||
is_current = models.BooleanField(default=False, verbose_name="现任届")
|
||
sort = models.IntegerField(default=0, verbose_name="排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["-is_current", "-start_year", "sort", "id"]
|
||
verbose_name = "工作组届次"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return f"{self.workgroup.name} · {self.name}"
|
||
|
||
|
||
class WorkGroupMembership(models.Model):
|
||
"""专家在某一届/分委会中的任职。"""
|
||
|
||
professor = models.ForeignKey(
|
||
"Professor",
|
||
on_delete=models.CASCADE,
|
||
related_name="memberships",
|
||
verbose_name="专家",
|
||
)
|
||
term = models.ForeignKey(
|
||
WorkGroupTerm,
|
||
on_delete=models.CASCADE,
|
||
related_name="memberships",
|
||
verbose_name="届次",
|
||
)
|
||
role = models.CharField(max_length=200, blank=True, default="", verbose_name="组内职务")
|
||
|
||
class Meta:
|
||
unique_together = [("professor", "term")]
|
||
verbose_name = "工作组任职"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return f"{self.professor_id}@{self.term_id}"
|
||
|
||
|
||
class Professor(SoftDeleteModel):
|
||
"""专家。"""
|
||
|
||
serial_num = models.CharField(max_length=64, blank=True, default="", verbose_name="编号")
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
type = models.IntegerField(null=True, blank=True, verbose_name="分类")
|
||
role = models.CharField(max_length=200, blank=True, default="", verbose_name="身份")
|
||
job = models.CharField(max_length=200, blank=True, default="", verbose_name="职称")
|
||
description = models.TextField(blank=True, default="", verbose_name="简介")
|
||
email = models.EmailField(blank=True, default="", verbose_name="邮箱")
|
||
addr = models.CharField(max_length=500, blank=True, default="", verbose_name="地址")
|
||
logo = models.URLField(blank=True, default="", verbose_name="头像")
|
||
phone = models.CharField(max_length=50, blank=True, default="", verbose_name="电话")
|
||
position = models.IntegerField(default=0, verbose_name="排序")
|
||
terms = models.ManyToManyField(
|
||
WorkGroupTerm,
|
||
through="WorkGroupMembership",
|
||
blank=True,
|
||
related_name="professors",
|
||
verbose_name="所属届次",
|
||
)
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["position", "id"]
|
||
verbose_name = "专家"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class StarRule(SoftDeleteModel):
|
||
"""STAR 评分规则。"""
|
||
|
||
name = models.CharField(max_length=200, verbose_name="名称")
|
||
year = models.IntegerField(verbose_name="年份")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["-year", "id"]
|
||
verbose_name = "STAR 规则"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.year})"
|
||
|
||
|
||
class RuleBand(SoftDeleteModel):
|
||
"""评分分档明细。"""
|
||
|
||
rule = models.ForeignKey(
|
||
StarRule, on_delete=models.CASCADE, related_name="bands", verbose_name="规则"
|
||
)
|
||
star_low = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="最低分")
|
||
star_high = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="最高分")
|
||
star = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="星级")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
class Meta:
|
||
ordering = ["star_low", "id"]
|
||
verbose_name = "评分分档"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return f"{self.rule_id}: {self.star_low}-{self.star_high}"
|
||
|
||
|
||
class Article(SoftDeleteModel):
|
||
"""指南 / 共识文献。"""
|
||
|
||
serial_number = models.CharField(max_length=64, blank=True, default="", verbose_name="编号")
|
||
title = models.CharField(max_length=500, verbose_name="标题")
|
||
type = models.ForeignKey(
|
||
ArticleType,
|
||
on_delete=models.PROTECT,
|
||
related_name="articles",
|
||
verbose_name="类型",
|
||
)
|
||
year = models.IntegerField(null=True, blank=True, verbose_name="年份")
|
||
score = models.DecimalField(
|
||
max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="STAR 得分"
|
||
)
|
||
url = models.URLField(blank=True, default="", verbose_name="原文地址")
|
||
flags = models.JSONField(default=list, blank=True, verbose_name="标签")
|
||
content = models.TextField(blank=True, default="", verbose_name="正文")
|
||
introduction = models.TextField(blank=True, default="", verbose_name="简介")
|
||
push_date = models.CharField(max_length=32, blank=True, default="", verbose_name="发布日期")
|
||
read_num = models.PositiveIntegerField(default=0, verbose_name="阅读数")
|
||
collect_num = models.PositiveIntegerField(default=0, verbose_name="收藏数")
|
||
like_num = models.PositiveIntegerField(default=0, verbose_name="点赞数")
|
||
share_num = models.PositiveIntegerField(default=0, verbose_name="分享数")
|
||
rank = models.IntegerField(null=True, blank=True, verbose_name="首页排序")
|
||
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
|
||
|
||
organizes = models.ManyToManyField(
|
||
Organize, blank=True, related_name="articles", verbose_name="机构"
|
||
)
|
||
magazines = models.ManyToManyField(
|
||
Magazine, blank=True, related_name="articles", verbose_name="杂志"
|
||
)
|
||
subjects = models.ManyToManyField(
|
||
Subject,
|
||
blank=True,
|
||
related_name="articles",
|
||
verbose_name="学科",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["-year", "-score", "id"]
|
||
verbose_name = "文献"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
class ReviewStatus(models.IntegerChoices):
|
||
PENDING = 1, "待审核"
|
||
REJECTED = 2, "不通过"
|
||
APPROVED = 3, "通过"
|
||
|
||
|
||
class ArticleComment(SoftDeleteModel):
|
||
"""指南/共识用户评价。"""
|
||
|
||
article = models.ForeignKey(
|
||
Article,
|
||
on_delete=models.CASCADE,
|
||
related_name="comments",
|
||
verbose_name="文献",
|
||
)
|
||
member = models.ForeignKey(
|
||
"accounts.Member",
|
||
on_delete=models.CASCADE,
|
||
related_name="comments",
|
||
verbose_name="会员",
|
||
)
|
||
star = models.DecimalField(max_digits=3, decimal_places=1, verbose_name="评价星级")
|
||
content = models.TextField(blank=True, default="", verbose_name="评价内容")
|
||
review_status = models.IntegerField(
|
||
choices=ReviewStatus.choices,
|
||
default=ReviewStatus.PENDING,
|
||
verbose_name="审核状态",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["-id"]
|
||
verbose_name = "文献评价"
|
||
verbose_name_plural = verbose_name
|
||
|
||
def __str__(self):
|
||
return f"Comment#{self.pk} on Article#{self.article_id}"
|
||
|
||
|
||
class ArticleLike(TimeStampedModel):
|
||
"""会员点赞文献(需登录;取消则硬删除)。"""
|
||
|
||
article = models.ForeignKey(
|
||
Article,
|
||
on_delete=models.CASCADE,
|
||
related_name="likes",
|
||
verbose_name="文献",
|
||
)
|
||
member = models.ForeignKey(
|
||
"accounts.Member",
|
||
on_delete=models.CASCADE,
|
||
related_name="article_likes",
|
||
verbose_name="会员",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["-id"]
|
||
verbose_name = "文献点赞"
|
||
verbose_name_plural = verbose_name
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["article", "member"],
|
||
name="content_articlelike_article_member_uniq",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"Like member={self.member_id} article={self.article_id}"
|
||
|
||
|
||
class ArticleCollect(TimeStampedModel):
|
||
"""会员收藏文献(需登录;取消则硬删除)。"""
|
||
|
||
article = models.ForeignKey(
|
||
Article,
|
||
on_delete=models.CASCADE,
|
||
related_name="collections",
|
||
verbose_name="文献",
|
||
)
|
||
member = models.ForeignKey(
|
||
"accounts.Member",
|
||
on_delete=models.CASCADE,
|
||
related_name="article_collections",
|
||
verbose_name="会员",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["-id"]
|
||
verbose_name = "文献收藏"
|
||
verbose_name_plural = verbose_name
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["article", "member"],
|
||
name="content_articlecollect_article_member_uniq",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"Collect member={self.member_id} article={self.article_id}"
|
||
|
||
|
||
class ArticleShare(TimeStampedModel):
|
||
"""
|
||
文献分享去重记录:同一会员或同一访客对同一文献只计一次。
|
||
- 已登录:按 member 去重
|
||
- 未登录:按前端 visitor_key(如 localStorage UUID)去重
|
||
"""
|
||
|
||
article = models.ForeignKey(
|
||
Article,
|
||
on_delete=models.CASCADE,
|
||
related_name="shares",
|
||
verbose_name="文献",
|
||
)
|
||
member = models.ForeignKey(
|
||
"accounts.Member",
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name="article_shares",
|
||
verbose_name="会员",
|
||
)
|
||
visitor_key = models.CharField(
|
||
max_length=64,
|
||
blank=True,
|
||
default="",
|
||
verbose_name="访客标识",
|
||
)
|
||
|
||
class Meta:
|
||
ordering = ["-id"]
|
||
verbose_name = "文献分享"
|
||
verbose_name_plural = verbose_name
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["article", "member"],
|
||
condition=models.Q(member__isnull=False),
|
||
name="content_articleshare_article_member_uniq",
|
||
),
|
||
models.UniqueConstraint(
|
||
fields=["article", "visitor_key"],
|
||
condition=~models.Q(visitor_key=""),
|
||
name="content_articleshare_article_visitor_uniq",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
who = f"member={self.member_id}" if self.member_id else f"visitor={self.visitor_key}"
|
||
return f"Share {who} article={self.article_id}"
|