Add STAR International API apps and wire production Postgres defaults.

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>
This commit is contained in:
aifeilong 2026-10-14 17:08:01 +08:00
parent cf9f716cd1
commit 38ec52abf5
45 changed files with 4757 additions and 128 deletions

3
.gitignore vendored
View File

@ -4,3 +4,6 @@ __pycache__/**/*.pyc
uploads/2025/ uploads/2025/
media/ media/
local_settings.py local_settings.py
db.sqlite3
*.sqlite3
staticfiles/

View File

@ -1,28 +1,32 @@
### local_settings.py # STAR International API
DEBUG = True Django + DRF backend for the STAR International site (independent from the domestic Java service).
ALLOWED_HOSTS = ["*"] ## Setup
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "",
"USER": "",
"PASSWORD": "",
"HOST": "db.ctrlk.work",
"PORT": "5432",
}
}
MEDIA_ROOT = "media/"
MEDIA_ROOT = "/home/baol/projects/2024/template-api/media/"
### venv
```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate
### run pip install -r requirements.txt
python manage.py migrate
python manage.py seed_demo
python manage.py createsuperuser # optional; local seed uses admin / admin123 if created
python manage.py runserver python manage.py runserver
```
Default local DB is SQLite via `config/local_settings.py`. For PostgreSQL, override `DATABASES` in `local_settings.py` or env vars.
## API
- Public & staff CRUD: `/api/articles/`, `/api/organizes/`, `/api/subjects/`, `/api/organize-categories/`, `/api/magazine-categories/`, `/api/work-groups/`, `/api/work-group-terms/`, `/api/columns/`, …
- Auth: `POST /api/auth/login/`, `GET /api/auth/me/`
- Django admin: `/django-admin/`
- Search: `GET /api/search/?q=`
Staff write operations require `Authorization: Token <key>` and `is_staff=True`.
## Demo
```bash
python manage.py seed_demo
```

0
apps/__init__.py Normal file
View File

View File

11
apps/accounts/admin.py Normal file
View File

@ -0,0 +1,11 @@
from django.contrib import admin
from .models import Member
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
list_display = ("id", "name", "phone", "organize", "is_vip", "is_disabled", "user")
list_filter = ("is_vip", "is_disabled")
search_fields = ("name", "phone", "organize")
raw_id_fields = ("user",)

8
apps/accounts/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
verbose_name = "账号"

View File

View File

@ -0,0 +1,546 @@
from decimal import Decimal
from django.core.management.base import BaseCommand
from apps.cms.models import (
AboutInfo,
ContactUs,
CopyrightInfo,
ExtendLink,
HomeBanner,
Keyword,
News,
Publication,
)
from apps.accounts.models import Member
from apps.content.models import (
Article,
ArticleComment,
Column,
Magazine,
MagazineCategory,
NavPosition,
Organize,
OrganizeCategory,
Professor,
ReviewStatus,
RuleBand,
Specialty,
StarRule,
Subject,
WorkGroup,
WorkGroupMembership,
WorkGroupTerm,
)
def _intro_html(
disease,
domain,
population,
users,
summary,
evidence="牛津分级",
strength="仅提供分级标准无具体名称",
funding="国家自然科学基金重点项目(82030056)",
registry="PREPARE-2024CN159",
):
"""生成与线上一致的简介 HTML(<p><b>键:</b>值</p>)。"""
parts = [
("关注的疾病或主题", disease),
("领域", domain),
("目标人群", population),
("使用者", users),
("推荐意见总结", summary),
("证据质量分级", evidence),
("推荐强度分级", strength),
("资助", funding),
("注册号", registry),
]
return "".join(f"<p><b>{k}:</b>{v}</p>" for k, v in parts)
# 演示文献样本(serial_number 作为幂等键,支持 --refresh-articles)
DEMO_ARTICLES = [
{
"serial_number": "STAR-2024-001",
"title": "放射性皮肤损伤诊断和治疗指南",
"type_id": 1,
"year": 2024,
"score": "88.5",
"rank": 1,
"url": "https://www.star-guidelines.cn/example/radiation-skin",
"flags": ["STAR", "临床指南"],
"like_num": 128,
"collect_num": 56,
"share_num": 23,
"read_num": 1024,
"introduction": _intro_html(
disease="放射性皮肤损伤的诊断和治疗",
domain="诊断、治疗",
population=(
"发生放射性皮肤损伤的患者(包括肿瘤放射治疗后、核事故、放射事故、"
"长期职业暴露等情况下发生放射性皮肤损伤的患者)"
),
users="烧伤外科学、放射医学、康复医学、急救与创伤医学、循证医学等领域的医务工作者",
summary=(
"包括放射性皮肤损伤的诊断依据及分度诊断标准,治疗原则(脱离放射源、保护损伤部位、"
"消除炎症等),局部预防和治疗(糖皮质激素、非甾体类药物、外用天然制剂等的使用建议),"
"全身药物治疗(口服酶、镇痛药、抗组胺药等的使用建议),物理治疗、细胞治疗、手术治疗的适用情况,"
"以及创面清洗的方法,为放射性皮肤损伤的诊断和治疗提供依据"
),
),
"content": (
"<p>本指南围绕放射性皮肤损伤的诊断与治疗给出推荐意见,"
"涵盖分度诊断、局部与全身治疗及创面管理等内容。</p>"
),
},
{
"serial_number": "STAR-2023-002",
"title": "糖尿病诊疗共识",
"type_id": 2,
"year": 2023,
"score": "76.2",
"rank": 2,
"url": "https://www.star-guidelines.cn/example/diabetes",
"flags": ["共识", "内分泌"],
"like_num": 86,
"collect_num": 41,
"share_num": 12,
"read_num": 640,
"introduction": _intro_html(
disease="2型糖尿病的综合管理",
domain="诊断、治疗、管理",
population="成年2型糖尿病患者及高危人群",
users="内分泌科、全科、内科及相关护理人员",
summary="涵盖筛查、血糖控制目标、生活方式干预与药物治疗路径的共识推荐。",
funding="无",
registry="PREPARE-2023CN088",
),
"content": "<p>本共识总结糖尿病筛查与综合管理的关键推荐意见。</p>",
},
{
"serial_number": "STAR-2024-003",
"title": "卒中一级预防指南",
"type_id": 1,
"year": 2024,
"score": "91.0",
"rank": 3,
"url": "https://www.star-guidelines.cn/example/stroke",
"flags": ["STAR", "神经内科"],
"like_num": 210,
"collect_num": 98,
"share_num": 45,
"read_num": 1880,
"introduction": _intro_html(
disease="卒中一级预防",
domain="预防、筛查",
population="具有卒中危险因素的成年人群",
users="神经内科、心内科、全科医务工作者",
summary="针对血压、血脂、抗栓与生活方式等一级预防措施给出分级推荐。",
funding="国家重点研发计划",
registry="PREPARE-2024CN201",
),
"content": "<p>本指南聚焦卒中一级预防的危险因素干预与人群筛查策略。</p>",
},
{
"serial_number": "STAR-2022-004",
"title": "抗菌药物合理使用共识",
"type_id": 2,
"year": 2022,
"score": "69.4",
"rank": 4,
"url": "https://www.star-guidelines.cn/example/antibiotic",
"flags": ["共识", "感染"],
"like_num": 54,
"collect_num": 22,
"share_num": 9,
"read_num": 390,
"introduction": _intro_html(
disease="抗菌药物合理使用与耐药防控",
domain="治疗、管理",
population="需要抗菌药物治疗的住院与门诊患者",
users="感染科、药学、检验与临床各专科医师",
summary="明确经验性用药、降阶梯策略与病原学送检等关键共识意见。",
evidence="GRADE",
funding="无",
registry="PREPARE-2022CN045",
),
"content": "<p>本共识强调抗菌药物合理使用与耐药监测的协同管理。</p>",
},
{
"serial_number": "STAR-2023-005",
"title": "心力衰竭诊疗指南",
"type_id": 1,
"year": 2023,
"score": "84.1",
"rank": 5,
"url": "https://www.star-guidelines.cn/example/heart-failure",
"flags": ["STAR", "心血管"],
"like_num": 167,
"collect_num": 73,
"share_num": 31,
"read_num": 1210,
"introduction": _intro_html(
disease="慢性心力衰竭的诊断与治疗",
domain="诊断、治疗",
population="慢性心力衰竭患者(含射血分数降低与保留)",
users="心血管内科、老年医学、全科医务工作者",
summary="覆盖诊断评估、药物四联疗法、器械治疗与随访管理的推荐意见。",
funding="国家自然科学基金",
registry="PREPARE-2023CN156",
),
"content": "<p>本指南系统阐述慢性心力衰竭的评估流程与治疗策略。</p>",
},
]
class Command(BaseCommand):
help = "灌入国际站演示数据;可用 --refresh-articles 刷新文献演示字段"
def add_arguments(self, parser):
parser.add_argument(
"--refresh-articles",
action="store_true",
help="按 serial_number 更新/创建演示文献(含结构化简介、原文链接、标签、互动数、点评)",
)
def handle(self, *args, **options):
refresh_only = options["refresh_articles"]
if refresh_only:
self._refresh_articles()
return
if Article.objects.exists():
self.stdout.write(
self.style.WARNING(
"已有文献数据,跳过全量灌数。如需刷新文献演示数据,请执行:"
" python manage.py seed_demo --refresh-articles"
)
)
return
subject = Subject.objects.create(name="放射医学", sort=1)
Subject.objects.create(name="心血管病学", sort=2)
Subject.objects.create(name="内分泌学", sort=3)
org_cat = OrganizeCategory.objects.create(name="学术学会", sort=1)
mag_cat = MagazineCategory.objects.create(name="医学期刊", sort=1)
# 完整导航树由 sync_nav_columns 写入;此处仅在空库时调用一次
from django.core.management import call_command
call_command("sync_nav_columns")
org = Organize.objects.create(
name="STAR International Network",
description="International guideline evaluation network.",
category=org_cat,
rank=1,
flags=["STAR"],
)
mag = Magazine.objects.create(name="Global Health Review", category=mag_cat)
mag.subjects.add(subject)
committee = WorkGroup.objects.create(name="评级工作委员会", sort=1)
clinical = WorkGroup.objects.create(
name="临床评级组", parent=committee, sort=1
)
method = WorkGroup.objects.create(
name="方法学评级组", parent=committee, sort=2
)
root_term = WorkGroupTerm.objects.create(
workgroup=committee,
name="第二届",
start_year=2023,
end_year=2025,
is_current=True,
sort=1,
)
clinical_term = WorkGroupTerm.objects.create(
workgroup=clinical,
parent=root_term,
name="第二届 · 临床评级组",
start_year=2023,
end_year=2025,
is_current=True,
sort=1,
)
method_term = WorkGroupTerm.objects.create(
workgroup=method,
parent=root_term,
name="第二届 · 方法学评级组",
start_year=2023,
end_year=2025,
is_current=True,
sort=2,
)
professor = Professor.objects.create(
name="Jane Doe",
role="Chair",
job="Professor",
description="Expert in guideline methodology.",
position=1,
)
WorkGroupMembership.objects.create(
professor=professor, term=clinical_term, role="组长"
)
WorkGroupMembership.objects.create(
professor=professor, term=method_term, role="委员"
)
self._ensure_star_rules()
self._upsert_demo_articles(org=org, mag=mag, subject=subject)
News.objects.create(
title="STAR International site launches",
content="<p>Welcome to the STAR International portal.</p>",
publication_time="2024-01-15",
rank=1,
)
Publication.objects.create(
title="STAR Methodology Paper",
content="<p>Methodology overview.</p>",
url="https://example.com/paper",
publication_time="2024-03-01",
rank=1,
)
AboutInfo.objects.create(
introduction="STAR International evaluates clinical practice guidelines worldwide.",
growing_path=(
"2021年,中华医学会杂志社指南与标准研究中心联合世界卫生组织指南实施与知识转化合作中心,"
"针对现有临床实践指南评价方法和工具:条目覆盖面不足、验证不充分、维度单一和评价工作量大等问题,"
"组建了指南科学性(Scientificity)、透明性(Transparency)和适用性(Applicability)评级(Rankings)工作组,"
"研发了全面的“STAR”工具。为进一步帮助医务人员遴选和应用高质量指南,工作组以2021年中国学者牵头"
"在同行评审期刊发表的指南和共识的STAR评级为起点,持续开展大范围的系统性评级工作。"
),
work_group=[
{
"name": "研发工作组",
"content": "全国五大区域,49位临床医学、方法学、管理学、期刊编辑学等多学科专家组成",
"url": "/assets/img/about/yanfa.png",
},
{
"name": "验证工作组",
"content": "34名评级专家基于指南样本对STAR工具易用性、内在信度、评级员信度、内容效度及效标效度进行验证",
"url": "/assets/img/about/yanzheng.png",
},
{
"name": "评级工作组",
"content": "13个省级行政区、42个专业、106名专家;高级职称48名,中级职称6名,初级职称14名;项目下设指导委员会、秘书组、临床评级组、方法学评级组、质量控制组",
"url": "/assets/img/about/pingji.png",
},
],
rating_method=(
'<p style="text-align:center">'
'<img src="/assets/img/about/rating-steps.png" alt="STAR评级步骤" style="max-width:100%;height:auto" />'
"</p>"
),
)
CopyrightInfo.objects.create(
copyright="© STAR International",
responsibility="Content provided for research use.",
email="info@star-international.example",
)
ContactUs.objects.create(
serve_desc="General inquiry",
serve_email="info@star-international.example",
star_desc="STAR methodology",
star_email="star@star-international.example",
)
for i, (title, image, link) in enumerate(
[
("Banner 1", "/assets/img/banner1@2x.jpg", "/guidelineAndConsensus"),
("Banner 2", "/assets/img/banner2@2x.jpg", "/guideRanking"),
("Banner 3", "/assets/img/banner3@2x.jpg", "/about"),
],
start=1,
):
HomeBanner.objects.create(
title=title,
image_url=image,
link_url=link,
sort=i,
)
ExtendLink.objects.create(
name="STAR Guidelines CN",
link_url="https://www.star-guidelines.cn",
sort=1,
)
Keyword.objects.create(name="hypertension", search_hot=10)
Keyword.objects.create(name="diabetes", search_hot=8)
Specialty.objects.create(name="Cardiology", position=1)
Specialty.objects.create(name="Endocrinology", position=2)
self._ensure_demo_comments()
self.stdout.write(self.style.SUCCESS("演示数据灌入完成"))
def _refresh_articles(self):
"""在已有库上刷新演示文献字段,便于本地联调详情页。"""
self._ensure_star_rules()
subject, _ = Subject.objects.get_or_create(
name="放射医学", defaults={"sort": 1}
)
Subject.objects.get_or_create(name="心血管病学", defaults={"sort": 2})
Subject.objects.get_or_create(name="内分泌学", defaults={"sort": 3})
org_cat, _ = OrganizeCategory.objects.get_or_create(
name="学术学会", defaults={"sort": 1}
)
mag_cat, _ = MagazineCategory.objects.get_or_create(
name="医学期刊", defaults={"sort": 1}
)
org, _ = Organize.objects.get_or_create(
name="STAR International Network",
defaults={
"description": "International guideline evaluation network.",
"category": org_cat,
"rank": 1,
"flags": ["STAR"],
},
)
mag, _ = Magazine.objects.get_or_create(
name="Global Health Review",
defaults={"category": mag_cat},
)
mag.subjects.add(subject)
created, updated = self._upsert_demo_articles(org=org, mag=mag, subject=subject)
self._ensure_demo_comments()
self.stdout.write(
self.style.SUCCESS(
f"文献演示数据已刷新:新建 {created},更新 {updated}"
)
)
def _ensure_star_rules(self):
rule, created = StarRule.objects.get_or_create(
name="STAR 2024",
year=2024,
defaults={"is_disabled": False},
)
if created or not rule.bands.exists():
RuleBand.objects.filter(rule=rule).delete()
RuleBand.objects.create(
rule=rule,
star_low=Decimal("0"),
star_high=Decimal("30"),
star=Decimal("1"),
)
RuleBand.objects.create(
rule=rule,
star_low=Decimal("30"),
star_high=Decimal("60"),
star=Decimal("2"),
)
RuleBand.objects.create(
rule=rule,
star_low=Decimal("60"),
star_high=Decimal("100"),
star=Decimal("3"),
)
def _upsert_demo_articles(self, *, org, mag, subject):
created = 0
updated = 0
for sample in DEMO_ARTICLES:
serial = sample["serial_number"]
defaults = {
"title": sample["title"],
"type_id": sample["type_id"],
"year": sample["year"],
"score": Decimal(sample["score"]),
"rank": sample["rank"],
"url": sample["url"],
"flags": sample["flags"],
"introduction": sample["introduction"],
"content": sample["content"],
"push_date": f"{sample['year']}-06-01",
"like_num": sample["like_num"],
"collect_num": sample["collect_num"],
"share_num": sample["share_num"],
"read_num": sample["read_num"],
"is_disabled": False,
}
art, was_created = Article.objects.update_or_create(
serial_number=serial,
defaults=defaults,
)
art.organizes.set([org])
art.magazines.set([mag])
art.subjects.set([subject])
if was_created:
created += 1
else:
updated += 1
return created, updated
def _ensure_demo_comments(self):
from apps.accounts.serializers import MemberSerializer
# 国际格式;同时兼容旧的 11 位国内号演示数据
member = (
Member.objects.filter(phone="+8613800000000").first()
or Member.objects.filter(phone="13800000000").first()
)
if member is None:
member = MemberSerializer().create(
{
"name": "Demo User",
"phone": "+8613800000000",
"organize": "Demo Hospital",
"is_vip": True,
"is_disabled": False,
"password": "demo1234",
}
)
else:
# 补齐国际格式与登录账号
MemberSerializer().update(
member,
{
"phone": "+8613800000000",
"password": "demo1234",
},
)
# 优先挂到放射性皮肤损伤指南,保证详情页可见已通过点评
article = Article.objects.filter(serial_number="STAR-2024-001").first()
if not article:
article = Article.objects.order_by("id").first()
if not article:
return
comments = [
{
"star": Decimal("4.5"),
"content": "结构清晰,分度诊断与局部治疗建议可直接用于临床参考。",
"review_status": ReviewStatus.APPROVED,
},
{
"star": Decimal("4.0"),
"content": "推荐意见总结较完整,希望补充更多物理治疗适用边界。",
"review_status": ReviewStatus.APPROVED,
},
{
"star": Decimal("3.5"),
"content": "待审核示例点评,前台不应展示。",
"review_status": ReviewStatus.PENDING,
},
]
for item in comments:
exists = ArticleComment.objects.filter(
article=article,
member=member,
content=item["content"],
).exists()
if exists:
continue
ArticleComment.objects.create(
article=article,
member=member,
star=item["star"],
content=item["content"],
review_status=item["review_status"],
)

View File

@ -0,0 +1,96 @@
"""将前台默认导航结构同步到 Column 表(可重复执行)。"""
from django.core.management.base import BaseCommand
from apps.content.models import Column, NavPosition
# 与 ui/src/config/nav.js 对齐
TOP_TREE = [
("首页", "/index", []),
(
"排名",
"/guideRanking",
[
("中国指南排名", "/guideRanking?type=1"),
("中国共识排名", "/guideRanking?type=2"),
("中国传统医学指南排名", "/guideRanking?type=1&scope=tcm"),
("中国传统医学共识排名", "/guideRanking?type=2&scope=tcm"),
("制订机构排名", "/organizationRanking"),
("发布杂志排名", "/magazineRanking"),
],
),
("数据库", "/guidelineAndConsensus", []),
("制订机构", "/organization", []),
("评级工作组", "/specialist", []),
(
"关于我们",
"/about",
[
("关于我们", "/about"),
("版权声明", "/statement"),
("联系我们", "/relation"),
],
),
]
FOOTER_TREE = [
(
"关于我们",
"/about",
[
("关于我们", "/about"),
("版权声明", "/statement"),
("联系我们", "/relation"),
],
),
(
"排名",
"/guideRanking",
[
("中国指南排名", "/guideRanking?type=1"),
("制订机构排名", "/organizationRanking"),
("发布杂志排名", "/magazineRanking"),
],
),
(
"数据库",
"/guidelineAndConsensus",
[
("指南/共识", "/guidelineAndConsensus"),
("制订机构", "/organization"),
("评级工作组", "/specialist"),
("新闻通知", "/newsList"),
],
),
]
def _create_tree(tree, position):
for sort, (name, path, children) in enumerate(tree, start=1):
parent = Column.objects.create(
name=name,
path=path,
position=position,
sort=sort,
)
for csort, (cname, cpath) in enumerate(children, start=1):
Column.objects.create(
name=cname,
path=cpath,
position=position,
parent=parent,
sort=csort,
)
class Command(BaseCommand):
help = "清空并重建顶部/底部栏目,使其与前台默认导航一致"
def handle(self, *args, **options):
deleted, _ = Column.all_objects.all().delete()
self.stdout.write(f"已清除栏目 {deleted} 条")
_create_tree(TOP_TREE, NavPosition.TOP)
_create_tree(FOOTER_TREE, NavPosition.BOTTOM)
total = Column.objects.count()
self.stdout.write(self.style.SUCCESS(f"已写入栏目 {total} 条(顶部+底部)"))

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2026-10-13 11:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Member',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='姓名')),
('phone', models.CharField(blank=True, default='', max_length=32, verbose_name='手机号')),
('organize', models.CharField(blank=True, default='', max_length=200, verbose_name='单位')),
('is_vip', models.BooleanField(default=False, verbose_name='VIP')),
('last_login_at', models.DateTimeField(blank=True, null=True, verbose_name='最后登录')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_profile', to=settings.AUTH_USER_MODEL, verbose_name='登录账号')),
],
options={
'verbose_name': '前台用户',
'verbose_name_plural': '前台用户',
'ordering': ['-id'],
'constraints': [models.UniqueConstraint(condition=models.Q(('phone', ''), _negated=True), fields=('phone',), name='accounts_member_phone_unique')],
},
),
]

View File

@ -0,0 +1 @@

38
apps/accounts/models.py Normal file
View File

@ -0,0 +1,38 @@
from django.conf import settings
from django.db import models
from apps.common.models import SoftDeleteModel
class Member(SoftDeleteModel):
"""前台会员(与后台 staff 分离)。"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="member_profile",
verbose_name="登录账号",
)
name = models.CharField(max_length=200, verbose_name="姓名")
phone = models.CharField(max_length=32, blank=True, default="", verbose_name="手机号")
organize = models.CharField(max_length=200, blank=True, default="", verbose_name="单位")
is_vip = models.BooleanField(default=False, verbose_name="VIP")
last_login_at = models.DateTimeField(null=True, blank=True, verbose_name="最后登录")
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
class Meta:
ordering = ["-id"]
verbose_name = "前台用户"
verbose_name_plural = verbose_name
constraints = [
models.UniqueConstraint(
fields=["phone"],
condition=~models.Q(phone=""),
name="accounts_member_phone_unique",
)
]
def __str__(self):
return self.name or self.phone or f"Member#{self.pk}"

34
apps/accounts/phone.py Normal file
View File

@ -0,0 +1,34 @@
"""手机号国际格式规范化。"""
from __future__ import annotations
import re
from rest_framework import serializers
_NON_DIGIT = re.compile(r"\D+")
def normalize_phone(value: str | None, *, required: bool = False) -> str:
"""
规范化为 E.164 风格:+ 后仅数字,例如 +8613800138000。
兼容历史国内 11 位号:1 开头则补 +86。
"""
raw = (value or "").strip().replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
if not raw:
if required:
raise serializers.ValidationError("手机号必填")
return ""
if raw.startswith("+"):
digits = _NON_DIGIT.sub("", raw[1:])
elif raw.isdigit() and len(raw) == 11 and raw.startswith("1"):
# 兼容旧数据 / 未选区号的国内号
digits = "86" + raw
else:
raise serializers.ValidationError("请使用国际格式手机号,如 +8613800138000")
if not digits or len(digits) < 6 or len(digits) > 15:
raise serializers.ValidationError("手机号格式不正确")
return f"+{digits}"

View File

@ -0,0 +1,164 @@
from django.contrib.auth.models import User
from django.db import transaction
from rest_framework import serializers
from .models import Member
from .phone import normalize_phone
class StaffUserSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False, allow_blank=True)
class Meta:
model = User
fields = [
"id",
"username",
"first_name",
"email",
"is_superuser",
"is_active",
"last_login",
"date_joined",
"password",
]
read_only_fields = ["last_login", "date_joined"]
def create(self, validated_data):
password = validated_data.pop("password", None) or ""
if not password:
raise serializers.ValidationError({"password": "新建时密码必填"})
user = User(
username=validated_data["username"],
first_name=validated_data.get("first_name", ""),
email=validated_data.get("email", ""),
is_superuser=validated_data.get("is_superuser", False),
is_active=validated_data.get("is_active", True),
is_staff=True,
)
user.set_password(password)
user.save()
return user
def update(self, instance, validated_data):
password = validated_data.pop("password", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.is_staff = True
if password:
instance.set_password(password)
instance.save()
return instance
def _ensure_member_user(member, phone, password=None, name=""):
"""为会员确保绑定非 staff 的 User;手机号作为 username。"""
if not phone:
return member
user = member.user
if user is None:
# 复用同名非 staff 账号,避免唯一约束冲突
user = User.objects.filter(username=phone, is_staff=False).first()
if user is None:
user = User(username=phone, is_staff=False, is_active=True)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save()
member.user = user
else:
if user.username != phone:
if User.objects.filter(username=phone).exclude(pk=user.pk).exists():
raise serializers.ValidationError({"phone": "该手机号已被占用"})
user.username = phone
if password:
user.set_password(password)
user.is_staff = False
user.is_active = not member.is_disabled
user.save()
if name and not user.first_name:
user.first_name = name
user.save(update_fields=["first_name"])
return member
class MemberSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False, allow_blank=True)
class Meta:
model = Member
fields = [
"id",
"name",
"phone",
"organize",
"is_vip",
"last_login_at",
"is_disabled",
"password",
"created_at",
"updated_at",
]
read_only_fields = ["last_login_at", "created_at", "updated_at"]
def validate_phone(self, value):
# 更新时允许留空(不改手机号);新建在 create 中再强制必填
if value in (None, "") and self.instance:
return self.instance.phone
phone = normalize_phone(value, required=not bool(self.instance))
if not phone:
return phone
qs = Member.objects.filter(phone=phone)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError("该手机号已被注册")
return phone
@transaction.atomic
def create(self, validated_data):
password = validated_data.pop("password", None) or ""
phone = validated_data.get("phone") or ""
if not phone:
raise serializers.ValidationError({"phone": "手机号必填"})
if not password:
raise serializers.ValidationError({"password": "新建时密码必填"})
member = Member(**validated_data)
member.save()
_ensure_member_user(member, phone, password=password, name=member.name)
member.save(update_fields=["user", "updated_at"])
return member
@transaction.atomic
def update(self, instance, validated_data):
password = validated_data.pop("password", None) or ""
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if instance.phone:
_ensure_member_user(
instance,
instance.phone,
password=password or None,
name=instance.name,
)
instance.save(update_fields=["user", "updated_at"])
elif password:
raise serializers.ValidationError({"phone": "设置密码前需填写手机号"})
return instance
class MemberPublicSerializer(serializers.ModelSerializer):
"""前台登录/注册返回的会员信息(不含禁用标记写入)。"""
class Meta:
model = Member
fields = [
"id",
"name",
"phone",
"organize",
"is_vip",
"last_login_at",
]

27
apps/accounts/urls.py Normal file
View File

@ -0,0 +1,27 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
LoginView,
MeView,
MemberLoginView,
MemberMeView,
MemberRegisterView,
MemberViewSet,
RegisterView,
StaffUserViewSet,
)
router = DefaultRouter()
router.register("staff", StaffUserViewSet, basename="staff")
router.register("members", MemberViewSet, basename="members")
urlpatterns = [
path("login/", LoginView.as_view(), name="login"),
path("register/", RegisterView.as_view(), name="register"),
path("me/", MeView.as_view(), name="me"),
path("member/login/", MemberLoginView.as_view(), name="member-login"),
path("member/register/", MemberRegisterView.as_view(), name="member-register"),
path("member/me/", MemberMeView.as_view(), name="member-me"),
path("", include(router.urls)),
]

252
apps/accounts/views.py Normal file
View File

@ -0,0 +1,252 @@
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, serializers, status, viewsets
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Member
from .phone import normalize_phone
from .serializers import (
MemberPublicSerializer,
MemberSerializer,
StaffUserSerializer,
)
def _validation_detail(exc):
detail = getattr(exc, "detail", None)
if isinstance(detail, (list, tuple)) and detail:
return str(detail[0])
if isinstance(detail, dict):
for value in detail.values():
if isinstance(value, (list, tuple)) and value:
return str(value[0])
return str(value)
return str(detail or exc)
class IsStaff(permissions.BasePermission):
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated and request.user.is_staff)
class LoginView(APIView):
"""后台登录,返回 Token。"""
permission_classes = [permissions.AllowAny]
def post(self, request):
username = request.data.get("username") or request.data.get("account")
password = request.data.get("password") or request.data.get("pwd")
user = authenticate(username=username, password=password)
if not user or not user.is_staff:
return Response(
{"detail": "用户名或密码错误,或无后台权限"},
status=status.HTTP_400_BAD_REQUEST,
)
token, _ = Token.objects.get_or_create(user=user)
return Response(
{
"token": token.key,
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
}
)
class MeView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
user = request.user
return Response(
{
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
"is_staff": user.is_staff,
}
)
class RegisterView(APIView):
"""仅 staff 可创建其他 staff(替代开放注册)。"""
permission_classes = [IsStaff]
def post(self, request):
serializer = StaffUserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
token, _ = Token.objects.get_or_create(user=user)
return Response(
{
"token": token.key,
"username": user.username,
"roles": ["admin"] if user.is_superuser else ["staff"],
},
status=status.HTTP_201_CREATED,
)
class StaffUserViewSet(viewsets.ModelViewSet):
"""系统人员:Django User 且 is_staff=True。"""
serializer_class = StaffUserSerializer
permission_classes = [IsStaff]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_active", "is_superuser"]
def get_queryset(self):
return User.objects.filter(is_staff=True).order_by("id")
def perform_destroy(self, instance):
# 禁止删除自己;软禁用更安全,这里改为 is_active=False
if instance.pk == self.request.user.pk:
raise PermissionDenied("不能删除当前登录账号")
instance.is_active = False
instance.save(update_fields=["is_active"])
class MemberViewSet(viewsets.ModelViewSet):
"""前台用户管理。"""
queryset = Member.objects.all()
serializer_class = MemberSerializer
permission_classes = [IsStaff]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_vip", "is_disabled"]
def get_queryset(self):
qs = super().get_queryset()
q = self.request.query_params.get("search") or self.request.query_params.get("q")
if q:
qs = qs.filter(Q(name__icontains=q) | Q(phone__icontains=q))
return qs
@action(detail=False, methods=["post"])
def batch_vip(self, request):
ids = request.data.get("ids") or []
is_vip = bool(request.data.get("is_vip"))
if not ids:
return Response({"detail": "ids 必填"}, status=status.HTTP_400_BAD_REQUEST)
updated = Member.objects.filter(id__in=ids).update(is_vip=is_vip)
return Response({"updated": updated, "is_vip": is_vip})
def _member_login_response(member):
if not member.user_id:
return None
token, _ = Token.objects.get_or_create(user=member.user)
member.last_login_at = timezone.now()
member.save(update_fields=["last_login_at", "updated_at"])
return {
"token": token.key,
"member": MemberPublicSerializer(member).data,
}
class MemberLoginView(APIView):
"""前台会员登录(手机号 + 密码)。"""
permission_classes = [permissions.AllowAny]
def post(self, request):
raw_phone = request.data.get("phone") or request.data.get("username") or ""
password = request.data.get("password") or request.data.get("pwd") or ""
try:
phone = normalize_phone(raw_phone, required=True)
except serializers.ValidationError as exc:
return Response(
{"detail": _validation_detail(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
if not password:
return Response(
{"detail": "请输入手机号和密码"},
status=status.HTTP_400_BAD_REQUEST,
)
member = Member.objects.filter(phone=phone).select_related("user").first()
# 兼容尚未迁移的国内 11 位旧数据
if not member and phone.startswith("+86") and len(phone) == 14:
member = (
Member.objects.filter(phone=phone[3:])
.select_related("user")
.first()
)
if not member or member.is_disabled or not member.user_id:
return Response(
{"detail": "手机号或密码错误"},
status=status.HTTP_400_BAD_REQUEST,
)
user = authenticate(username=member.user.username, password=password)
if not user or user.is_staff:
return Response(
{"detail": "手机号或密码错误"},
status=status.HTTP_400_BAD_REQUEST,
)
payload = _member_login_response(member)
return Response(payload)
class MemberRegisterView(APIView):
"""前台会员注册。"""
permission_classes = [permissions.AllowAny]
@transaction.atomic
def post(self, request):
try:
phone = normalize_phone(request.data.get("phone"), required=True)
except serializers.ValidationError as exc:
return Response(
{"detail": _validation_detail(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
data = {
"name": (request.data.get("name") or "").strip(),
"phone": phone,
"organize": (request.data.get("organize") or "").strip(),
"password": request.data.get("password") or "",
"is_vip": False,
"is_disabled": False,
}
if not data["name"]:
return Response({"detail": "请输入姓名"}, status=status.HTTP_400_BAD_REQUEST)
if len(data["password"]) < 6:
return Response(
{"detail": "密码至少 6 位"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = MemberSerializer(data=data)
serializer.is_valid(raise_exception=True)
member = serializer.save()
payload = _member_login_response(member)
return Response(payload, status=status.HTTP_201_CREATED)
class MemberMeView(APIView):
"""当前前台会员信息。"""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
user = request.user
if user.is_staff:
return Response(
{"detail": "当前为后台账号,请使用前台会员登录"},
status=status.HTTP_403_FORBIDDEN,
)
member = getattr(user, "member_profile", None)
if not member or member.is_disabled:
return Response(
{"detail": "会员不存在或已禁用"},
status=status.HTTP_403_FORBIDDEN,
)
return Response(MemberPublicSerializer(member).data)

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

60
apps/cms/admin.py Normal file
View File

@ -0,0 +1,60 @@
from django.contrib import admin
from .models import (
AboutInfo,
ContactUs,
CopyrightInfo,
ExtendLink,
HomeBanner,
Keyword,
News,
Publication,
Report,
)
@admin.register(News)
class NewsAdmin(admin.ModelAdmin):
list_display = ("id", "title", "publication_time", "rank", "is_disabled")
search_fields = ("title",)
@admin.register(Publication)
class PublicationAdmin(admin.ModelAdmin):
list_display = ("id", "title", "publication_time", "rank", "is_disabled")
search_fields = ("title",)
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
list_display = ("id", "name", "year", "is_disabled")
@admin.register(AboutInfo)
class AboutInfoAdmin(admin.ModelAdmin):
list_display = ("id", "created_at")
@admin.register(CopyrightInfo)
class CopyrightInfoAdmin(admin.ModelAdmin):
list_display = ("id", "email", "created_at")
@admin.register(ContactUs)
class ContactUsAdmin(admin.ModelAdmin):
list_display = ("id", "serve_email", "star_email", "created_at")
@admin.register(ExtendLink)
class ExtendLinkAdmin(admin.ModelAdmin):
list_display = ("id", "name", "link_url", "sort", "is_disabled")
@admin.register(HomeBanner)
class HomeBannerAdmin(admin.ModelAdmin):
list_display = ("id", "title", "sort", "is_disabled")
@admin.register(Keyword)
class KeywordAdmin(admin.ModelAdmin):
list_display = ("id", "name", "search_hot", "is_show")

8
apps/cms/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.cms"
label = "cms"
verbose_name = "站点内容"

View File

@ -0,0 +1,201 @@
# Generated by Django 5.2.8 on 2026-10-13 11:36
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AboutInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('introduction', models.TextField(blank=True, default='', verbose_name='简介')),
('work_group', models.JSONField(blank=True, default=list, verbose_name='工作组')),
('product', models.JSONField(blank=True, default=list, verbose_name='产品')),
('growing_path', models.TextField(blank=True, default='', verbose_name='成长路径')),
('img_path', models.URLField(blank=True, default='', verbose_name='图片')),
('rating_method', models.TextField(blank=True, default='', verbose_name='评价方法')),
],
options={
'verbose_name': '关于我们',
'verbose_name_plural': '关于我们',
'ordering': ['-id'],
},
),
migrations.CreateModel(
name='ContactUs',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serve_icon', models.URLField(blank=True, default='', verbose_name='服务图标')),
('serve_desc', models.TextField(blank=True, default='', verbose_name='服务说明')),
('serve_phone', models.CharField(blank=True, default='', max_length=100, verbose_name='服务电话')),
('serve_email', models.EmailField(blank=True, default='', max_length=254, verbose_name='服务邮箱')),
('serve_address', models.CharField(blank=True, default='', max_length=500, verbose_name='服务地址')),
('media_icon', models.URLField(blank=True, default='', verbose_name='媒体图标')),
('media_desc', models.TextField(blank=True, default='', verbose_name='媒体说明')),
('media_phone', models.CharField(blank=True, default='', max_length=100, verbose_name='媒体电话')),
('media_email', models.EmailField(blank=True, default='', max_length=254, verbose_name='媒体邮箱')),
('media_address', models.CharField(blank=True, default='', max_length=500, verbose_name='媒体地址')),
('star_icon', models.URLField(blank=True, default='', verbose_name='STAR 图标')),
('star_desc', models.TextField(blank=True, default='', verbose_name='STAR 说明')),
('star_phone', models.CharField(blank=True, default='', max_length=100, verbose_name='STAR 电话')),
('star_email', models.EmailField(blank=True, default='', max_length=254, verbose_name='STAR 邮箱')),
('star_address', models.CharField(blank=True, default='', max_length=500, verbose_name='STAR 地址')),
],
options={
'verbose_name': '联系我们',
'verbose_name_plural': '联系我们',
'ordering': ['-id'],
},
),
migrations.CreateModel(
name='CopyrightInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('copyright', models.TextField(blank=True, default='', verbose_name='版权')),
('responsibility', models.TextField(blank=True, default='', verbose_name='免责')),
('email', models.EmailField(blank=True, default='', max_length=254, verbose_name='邮箱')),
('remark', models.TextField(blank=True, default='', verbose_name='备注')),
],
options={
'verbose_name': '版权声明',
'verbose_name_plural': '版权声明',
'ordering': ['-id'],
},
),
migrations.CreateModel(
name='ExtendLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('link_url', models.URLField(verbose_name='链接')),
('sort', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '外链',
'verbose_name_plural': '外链',
'ordering': ['sort', 'id'],
},
),
migrations.CreateModel(
name='HomeBanner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('title', models.CharField(blank=True, default='', max_length=200, verbose_name='标题')),
('image_url', models.CharField(max_length=500, verbose_name='图片地址')),
('link_url', models.CharField(blank=True, default='', max_length=500, verbose_name='跳转链接')),
('sort', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '首页轮播',
'verbose_name_plural': '首页轮播',
'ordering': ['sort', 'id'],
},
),
migrations.CreateModel(
name='Keyword',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='关键词')),
('search_hot', models.PositiveIntegerField(default=0, verbose_name='热度')),
('is_show', models.BooleanField(default=True, verbose_name='展示')),
],
options={
'verbose_name': '热搜词',
'verbose_name_plural': '热搜词',
'ordering': ['-search_hot', 'id'],
},
),
migrations.CreateModel(
name='News',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('title', models.CharField(max_length=500, verbose_name='标题')),
('content', models.TextField(blank=True, default='', verbose_name='正文')),
('publication_time', models.CharField(blank=True, default='', max_length=32, verbose_name='发布时间')),
('rank', models.IntegerField(blank=True, null=True, 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='分享数')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '新闻',
'verbose_name_plural': '新闻',
'ordering': ['rank', '-id'],
},
),
migrations.CreateModel(
name='Publication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('title', models.CharField(max_length=500, verbose_name='标题')),
('content', models.TextField(blank=True, default='', verbose_name='正文')),
('url', models.URLField(blank=True, default='', verbose_name='链接')),
('publication_time', models.CharField(blank=True, default='', max_length=32, verbose_name='发布时间')),
('rank', models.IntegerField(blank=True, null=True, 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='分享数')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '发表资源',
'verbose_name_plural': '发表资源',
'ordering': ['rank', '-id'],
},
),
migrations.CreateModel(
name='Report',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('year', models.IntegerField(verbose_name='年份')),
('logo', models.URLField(blank=True, default='', verbose_name='封面')),
('file_path', models.URLField(blank=True, default='', verbose_name='文件地址')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '年度报告',
'verbose_name_plural': '年度报告',
'ordering': ['-year', 'id'],
},
),
]

View File

187
apps/cms/models.py Normal file
View File

@ -0,0 +1,187 @@
from django.db import models
from apps.common.models import SoftDeleteModel
class News(SoftDeleteModel):
"""新闻。"""
title = models.CharField(max_length=500, verbose_name="标题")
content = models.TextField(blank=True, default="", verbose_name="正文")
publication_time = models.CharField(
max_length=32, blank=True, default="", verbose_name="发布时间"
)
rank = models.IntegerField(null=True, blank=True, 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="分享数")
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.title
class Publication(SoftDeleteModel):
"""最新发表 / 资源。"""
title = models.CharField(max_length=500, verbose_name="标题")
content = models.TextField(blank=True, default="", verbose_name="正文")
url = models.URLField(blank=True, default="", verbose_name="链接")
publication_time = models.CharField(
max_length=32, blank=True, default="", verbose_name="发布时间"
)
rank = models.IntegerField(null=True, blank=True, 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="分享数")
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.title
class Report(SoftDeleteModel):
"""年度报告。"""
name = models.CharField(max_length=200, verbose_name="名称")
year = models.IntegerField(verbose_name="年份")
logo = models.URLField(blank=True, default="", verbose_name="封面")
file_path = models.URLField(blank=True, default="", verbose_name="文件地址")
is_disabled = models.BooleanField(default=False, verbose_name="禁用")
class Meta:
ordering = ["-year", "id"]
verbose_name = "年度报告"
verbose_name_plural = verbose_name
def __str__(self):
return f"{self.name} ({self.year})"
class AboutInfo(SoftDeleteModel):
"""关于我们(单例式配置,可多条取最新)。"""
introduction = models.TextField(blank=True, default="", verbose_name="简介")
work_group = models.JSONField(default=list, blank=True, verbose_name="工作组")
product = models.JSONField(default=list, blank=True, verbose_name="产品")
growing_path = models.TextField(blank=True, default="", verbose_name="成长路径")
img_path = models.URLField(blank=True, default="", verbose_name="图片")
rating_method = models.TextField(blank=True, default="", verbose_name="评价方法")
class Meta:
ordering = ["-id"]
verbose_name = "关于我们"
verbose_name_plural = verbose_name
def __str__(self):
return f"About#{self.pk}"
class CopyrightInfo(SoftDeleteModel):
"""版权声明。"""
copyright = models.TextField(blank=True, default="", verbose_name="版权")
responsibility = models.TextField(blank=True, default="", verbose_name="免责")
email = models.EmailField(blank=True, default="", verbose_name="邮箱")
remark = models.TextField(blank=True, default="", verbose_name="备注")
class Meta:
ordering = ["-id"]
verbose_name = "版权声明"
verbose_name_plural = verbose_name
def __str__(self):
return f"Copyright#{self.pk}"
class ContactUs(SoftDeleteModel):
"""联系我们。"""
serve_icon = models.URLField(blank=True, default="", verbose_name="服务图标")
serve_desc = models.TextField(blank=True, default="", verbose_name="服务说明")
serve_phone = models.CharField(max_length=100, blank=True, default="", verbose_name="服务电话")
serve_email = models.EmailField(blank=True, default="", verbose_name="服务邮箱")
serve_address = models.CharField(max_length=500, blank=True, default="", verbose_name="服务地址")
media_icon = models.URLField(blank=True, default="", verbose_name="媒体图标")
media_desc = models.TextField(blank=True, default="", verbose_name="媒体说明")
media_phone = models.CharField(max_length=100, blank=True, default="", verbose_name="媒体电话")
media_email = models.EmailField(blank=True, default="", verbose_name="媒体邮箱")
media_address = models.CharField(max_length=500, blank=True, default="", verbose_name="媒体地址")
star_icon = models.URLField(blank=True, default="", verbose_name="STAR 图标")
star_desc = models.TextField(blank=True, default="", verbose_name="STAR 说明")
star_phone = models.CharField(max_length=100, blank=True, default="", verbose_name="STAR 电话")
star_email = models.EmailField(blank=True, default="", verbose_name="STAR 邮箱")
star_address = models.CharField(max_length=500, blank=True, default="", verbose_name="STAR 地址")
class Meta:
ordering = ["-id"]
verbose_name = "联系我们"
verbose_name_plural = verbose_name
def __str__(self):
return f"Contact#{self.pk}"
class ExtendLink(SoftDeleteModel):
"""外部链接。"""
name = models.CharField(max_length=200, verbose_name="名称")
link_url = models.URLField(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 HomeBanner(SoftDeleteModel):
"""首页轮播 / 图片位。"""
title = models.CharField(max_length=200, blank=True, default="", verbose_name="标题")
image_url = models.CharField(max_length=500, verbose_name="图片地址")
link_url = models.CharField(max_length=500, blank=True, default="", 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.title or self.image_url
class Keyword(SoftDeleteModel):
"""热搜词。"""
name = models.CharField(max_length=200, verbose_name="关键词")
search_hot = models.PositiveIntegerField(default=0, verbose_name="热度")
is_show = models.BooleanField(default=True, verbose_name="展示")
class Meta:
ordering = ["-search_hot", "id"]
verbose_name = "热搜词"
verbose_name_plural = verbose_name
def __str__(self):
return self.name

176
apps/cms/serializers.py Normal file
View File

@ -0,0 +1,176 @@
from rest_framework import serializers
from .models import (
AboutInfo,
ContactUs,
CopyrightInfo,
ExtendLink,
HomeBanner,
Keyword,
News,
Publication,
Report,
)
class NewsSerializer(serializers.ModelSerializer):
class Meta:
model = News
fields = [
"id",
"title",
"content",
"publication_time",
"rank",
"read_num",
"collect_num",
"like_num",
"share_num",
"is_disabled",
"created_at",
"updated_at",
]
class NewsListSerializer(serializers.ModelSerializer):
class Meta:
model = News
fields = [
"id",
"title",
"publication_time",
"rank",
"read_num",
"is_disabled",
"created_at",
"updated_at",
]
class PublicationSerializer(serializers.ModelSerializer):
class Meta:
model = Publication
fields = [
"id",
"title",
"content",
"url",
"publication_time",
"rank",
"read_num",
"collect_num",
"like_num",
"share_num",
"is_disabled",
"created_at",
"updated_at",
]
class PublicationListSerializer(serializers.ModelSerializer):
class Meta:
model = Publication
fields = [
"id",
"title",
"url",
"publication_time",
"rank",
"read_num",
"is_disabled",
"created_at",
"updated_at",
]
class ReportSerializer(serializers.ModelSerializer):
class Meta:
model = Report
fields = [
"id",
"name",
"year",
"logo",
"file_path",
"is_disabled",
"created_at",
"updated_at",
]
class AboutInfoSerializer(serializers.ModelSerializer):
class Meta:
model = AboutInfo
fields = [
"id",
"introduction",
"work_group",
"product",
"growing_path",
"img_path",
"rating_method",
"created_at",
"updated_at",
]
class CopyrightInfoSerializer(serializers.ModelSerializer):
class Meta:
model = CopyrightInfo
fields = [
"id",
"copyright",
"responsibility",
"email",
"remark",
"created_at",
"updated_at",
]
class ContactUsSerializer(serializers.ModelSerializer):
class Meta:
model = ContactUs
fields = "__all__"
class ExtendLinkSerializer(serializers.ModelSerializer):
class Meta:
model = ExtendLink
fields = [
"id",
"name",
"link_url",
"sort",
"is_disabled",
"created_at",
"updated_at",
]
class HomeBannerSerializer(serializers.ModelSerializer):
class Meta:
model = HomeBanner
fields = [
"id",
"title",
"image_url",
"link_url",
"sort",
"is_disabled",
"created_at",
"updated_at",
]
class KeywordSerializer(serializers.ModelSerializer):
class Meta:
model = Keyword
fields = [
"id",
"name",
"search_hot",
"is_show",
"created_at",
"updated_at",
]

30
apps/cms/urls.py Normal file
View File

@ -0,0 +1,30 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import (
AboutInfoViewSet,
ContactUsViewSet,
CopyrightInfoViewSet,
ExtendLinkViewSet,
HomeBannerViewSet,
KeywordViewSet,
NewsViewSet,
PublicationViewSet,
ReportViewSet,
SearchViewSet,
)
router = DefaultRouter()
router.register("news", NewsViewSet)
router.register("publications", PublicationViewSet)
router.register("reports", ReportViewSet)
router.register("about", AboutInfoViewSet, basename="about")
router.register("copyright", CopyrightInfoViewSet, basename="copyright")
router.register("contact", ContactUsViewSet, basename="contact")
router.register("links", ExtendLinkViewSet)
router.register("banners", HomeBannerViewSet)
router.register("keywords", KeywordViewSet)
urlpatterns = router.urls + [
path("search/", SearchViewSet.as_view({"get": "list"}), name="search"),
]

211
apps/cms/views.py Normal file
View File

@ -0,0 +1,211 @@
import uuid
from pathlib import Path
from django.conf import settings
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from apps.content.models import Article, Organize, Professor
from apps.content.serializers import (
ArticleListSerializer,
OrganizeListSerializer,
ProfessorSerializer,
)
from apps.content.views import IsAdminOrReadOnly, PublicMixin
ALLOWED_IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
from .models import (
AboutInfo,
ContactUs,
CopyrightInfo,
ExtendLink,
HomeBanner,
Keyword,
News,
Publication,
Report,
)
from .serializers import (
AboutInfoSerializer,
ContactUsSerializer,
CopyrightInfoSerializer,
ExtendLinkSerializer,
HomeBannerSerializer,
KeywordSerializer,
NewsListSerializer,
NewsSerializer,
PublicationListSerializer,
PublicationSerializer,
ReportSerializer,
)
class NewsViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = News.objects.all()
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_disabled"]
def get_serializer_class(self):
if self.action == "list":
return NewsListSerializer
return NewsSerializer
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
News.objects.filter(pk=instance.pk).update(read_num=instance.read_num + 1)
instance.refresh_from_db()
return Response(self.get_serializer(instance).data)
class PublicationViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Publication.objects.all()
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_disabled"]
def get_serializer_class(self):
if self.action == "list":
return PublicationListSerializer
return PublicationSerializer
class ReportViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Report.objects.all()
serializer_class = ReportSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["year", "is_disabled"]
class AboutInfoViewSet(viewsets.ModelViewSet):
queryset = AboutInfo.objects.all()
serializer_class = AboutInfoSerializer
permission_classes = [IsAdminOrReadOnly]
@action(detail=False, methods=["get"])
def current(self, request):
obj = self.get_queryset().first()
if not obj:
return Response({})
return Response(self.get_serializer(obj).data)
class CopyrightInfoViewSet(viewsets.ModelViewSet):
queryset = CopyrightInfo.objects.all()
serializer_class = CopyrightInfoSerializer
permission_classes = [IsAdminOrReadOnly]
@action(detail=False, methods=["get"])
def current(self, request):
obj = self.get_queryset().first()
if not obj:
return Response({})
return Response(self.get_serializer(obj).data)
class ContactUsViewSet(viewsets.ModelViewSet):
queryset = ContactUs.objects.all()
serializer_class = ContactUsSerializer
permission_classes = [IsAdminOrReadOnly]
@action(detail=False, methods=["get"])
def current(self, request):
obj = self.get_queryset().first()
if not obj:
return Response({})
return Response(self.get_serializer(obj).data)
class ExtendLinkViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = ExtendLink.objects.all()
serializer_class = ExtendLinkSerializer
permission_classes = [IsAdminOrReadOnly]
class HomeBannerViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = HomeBanner.objects.all()
serializer_class = HomeBannerSerializer
permission_classes = [IsAdminOrReadOnly]
@action(
detail=False,
methods=["post"],
url_path="upload",
parser_classes=[MultiPartParser, FormParser],
permission_classes=[permissions.IsAdminUser],
)
def upload(self, request):
"""上传轮播图片,返回可直接写入 image_url 的路径。"""
file = request.FILES.get("file")
if not file:
return Response({"detail": "请选择图片文件"}, status=status.HTTP_400_BAD_REQUEST)
if file.size > MAX_UPLOAD_BYTES:
return Response({"detail": "图片不能超过 8MB"}, status=status.HTTP_400_BAD_REQUEST)
ext = Path(file.name).suffix.lower()
if ext not in ALLOWED_IMAGE_EXT:
return Response(
{"detail": f"不支持的格式,请上传 {', '.join(sorted(ALLOWED_IMAGE_EXT))}"},
status=status.HTTP_400_BAD_REQUEST,
)
filename = f"{uuid.uuid4().hex}{ext}"
rel_dir = Path("banners")
abs_dir = Path(settings.MEDIA_ROOT) / rel_dir
abs_dir.mkdir(parents=True, exist_ok=True)
abs_path = abs_dir / filename
with open(abs_path, "wb") as out:
for chunk in file.chunks():
out.write(chunk)
url = f"/{settings.MEDIA_URL.strip('/')}/{rel_dir.as_posix()}/{filename}"
return Response({"url": url, "name": file.name})
class KeywordViewSet(viewsets.ModelViewSet):
queryset = Keyword.objects.all()
serializer_class = KeywordSerializer
permission_classes = [IsAdminOrReadOnly]
def get_queryset(self):
qs = super().get_queryset()
if self.action in ("list", "retrieve"):
user = self.request.user
if not (user and user.is_staff):
qs = qs.filter(is_show=True)
return qs
class SearchViewSet(viewsets.ViewSet):
"""全站搜索(文献 / 机构 / 专家 / 新闻)。"""
permission_classes = [permissions.AllowAny]
def list(self, request):
q = (request.query_params.get("q") or "").strip()
if not q:
return Response({"articles": [], "organizes": [], "professors": [], "news": []})
articles = Article.objects.filter(
is_disabled=False, is_deleted=False
).filter(Q(title__icontains=q) | Q(introduction__icontains=q))[:20]
organizes = Organize.objects.filter(
is_disabled=False, name__icontains=q
)[:10]
professors = Professor.objects.filter(
is_disabled=False, name__icontains=q
)[:10]
news = News.objects.filter(is_disabled=False, title__icontains=q)[:10]
return Response(
{
"articles": ArticleListSerializer(articles, many=True).data,
"organizes": OrganizeListSerializer(organizes, many=True).data,
"professors": ProfessorSerializer(professors, many=True).data,
"news": NewsListSerializer(news, many=True).data,
}
)

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

8
apps/common/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
label = "common"
verbose_name = "公共"

43
apps/common/models.py Normal file
View File

@ -0,0 +1,43 @@
from django.db import models
class TimeStampedModel(models.Model):
"""带创建/更新时间的基类。"""
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
class Meta:
abstract = True
class SoftDeleteQuerySet(models.QuerySet):
def alive(self):
return self.filter(is_deleted=False)
def delete(self):
return super().update(is_deleted=True)
def hard_delete(self):
return super().delete()
class SoftDeleteManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
class SoftDeleteModel(TimeStampedModel):
"""软删除基类。"""
is_deleted = models.BooleanField(default=False, db_index=True, verbose_name="已删除")
objects = SoftDeleteManager()
all_objects = models.Manager()
class Meta:
abstract = True
def delete(self, using=None, keep_parents=False):
self.is_deleted = True
self.save(update_fields=["is_deleted", "updated_at"])

89
apps/common/upload.py Normal file
View File

@ -0,0 +1,89 @@
"""通用媒体上传:图片与文件统一入口,按 category 分子目录存储。"""
import re
import uuid
from pathlib import Path
from django.conf import settings
from rest_framework import permissions, status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
ALLOWED_IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
ALLOWED_FILE_EXT = ALLOWED_IMAGE_EXT | {
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".zip",
".rar",
".7z",
".txt",
".csv",
}
MAX_IMAGE_BYTES = 8 * 1024 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
# 仅允许字母数字、下划线、连字符,防止路径穿越
CATEGORY_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
class MediaUploadView(APIView):
"""POST /api/uploads/ 表单字段: file, category(可选), kind=image|file(可选)"""
permission_classes = [permissions.IsAdminUser]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
file = request.FILES.get("file")
if not file:
return Response({"detail": "请选择文件"}, status=status.HTTP_400_BAD_REQUEST)
category = (request.data.get("category") or "misc").strip()
if not CATEGORY_RE.match(category):
return Response(
{"detail": "category 仅允许字母、数字、下划线与连字符"},
status=status.HTTP_400_BAD_REQUEST,
)
kind = (request.data.get("kind") or "").strip().lower()
ext = Path(file.name).suffix.lower()
if not kind:
kind = "image" if ext in ALLOWED_IMAGE_EXT else "file"
if kind == "image":
allowed = ALLOWED_IMAGE_EXT
max_bytes = MAX_IMAGE_BYTES
size_label = "8MB"
else:
allowed = ALLOWED_FILE_EXT
max_bytes = MAX_FILE_BYTES
size_label = "50MB"
if file.size > max_bytes:
return Response(
{"detail": f"文件不能超过 {size_label}"},
status=status.HTTP_400_BAD_REQUEST,
)
if ext not in allowed:
return Response(
{
"detail": f"不支持的格式,请上传 {', '.join(sorted(allowed))}",
},
status=status.HTTP_400_BAD_REQUEST,
)
filename = f"{uuid.uuid4().hex}{ext}"
rel_dir = Path(category)
abs_dir = Path(settings.MEDIA_ROOT) / rel_dir
abs_dir.mkdir(parents=True, exist_ok=True)
abs_path = abs_dir / filename
with open(abs_path, "wb") as out:
for chunk in file.chunks():
out.write(chunk)
url = f"/{settings.MEDIA_URL.strip('/')}/{rel_dir.as_posix()}/{filename}"
return Response({"url": url, "name": file.name, "kind": kind})

7
apps/common/urls.py Normal file
View File

@ -0,0 +1,7 @@
from django.urls import path
from .upload import MediaUploadView
urlpatterns = [
path("uploads/", MediaUploadView.as_view(), name="media-upload"),
]

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

156
apps/content/admin.py Normal file
View File

@ -0,0 +1,156 @@
from django.contrib import admin
from .models import (
Article,
ArticleCollect,
ArticleShare,
ArticleComment,
ArticleLike,
ArticleType,
Column,
Magazine,
MagazineCategory,
Organize,
OrganizeCategory,
Professor,
RuleBand,
Specialty,
StarRule,
Subject,
WorkGroup,
WorkGroupMembership,
WorkGroupTerm,
)
@admin.register(Column)
class ColumnAdmin(admin.ModelAdmin):
list_display = ("id", "name", "position", "sort", "path", "is_disabled")
list_filter = ("position", "is_disabled")
search_fields = ("name",)
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
list_display = ("id", "name", "parent", "sort", "is_disabled")
list_filter = ("is_disabled",)
search_fields = ("name",)
@admin.register(OrganizeCategory)
class OrganizeCategoryAdmin(admin.ModelAdmin):
list_display = ("id", "name", "parent", "sort", "is_disabled")
list_filter = ("is_disabled",)
search_fields = ("name",)
@admin.register(MagazineCategory)
class MagazineCategoryAdmin(admin.ModelAdmin):
list_display = ("id", "name", "parent", "sort", "is_disabled")
list_filter = ("is_disabled",)
search_fields = ("name",)
@admin.register(ArticleType)
class ArticleTypeAdmin(admin.ModelAdmin):
list_display = ("id", "code", "name", "sort", "is_disabled")
list_filter = ("is_disabled",)
search_fields = ("name", "code", "note")
@admin.register(Organize)
class OrganizeAdmin(admin.ModelAdmin):
list_display = ("id", "name", "category", "rank", "is_disabled")
search_fields = ("name",)
@admin.register(Magazine)
class MagazineAdmin(admin.ModelAdmin):
list_display = ("id", "name", "category", "is_disabled")
search_fields = ("name",)
filter_horizontal = ("subjects",)
class RuleBandInline(admin.TabularInline):
model = RuleBand
extra = 1
@admin.register(StarRule)
class StarRuleAdmin(admin.ModelAdmin):
list_display = ("id", "name", "year", "is_disabled")
inlines = [RuleBandInline]
@admin.register(WorkGroup)
class WorkGroupAdmin(admin.ModelAdmin):
list_display = ("id", "name", "parent", "sort", "is_disabled")
list_filter = ("is_disabled",)
search_fields = ("name",)
@admin.register(WorkGroupTerm)
class WorkGroupTermAdmin(admin.ModelAdmin):
list_display = (
"id",
"workgroup",
"name",
"parent",
"start_year",
"end_year",
"is_current",
"is_disabled",
)
list_filter = ("is_current", "is_disabled", "workgroup")
search_fields = ("name", "workgroup__name")
class WorkGroupMembershipInline(admin.TabularInline):
model = WorkGroupMembership
extra = 1
@admin.register(Professor)
class ProfessorAdmin(admin.ModelAdmin):
list_display = ("id", "name", "role", "job", "is_disabled")
search_fields = ("name",)
inlines = [WorkGroupMembershipInline]
@admin.register(Specialty)
class SpecialtyAdmin(admin.ModelAdmin):
list_display = ("id", "name", "position", "is_disabled")
search_fields = ("name",)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("id", "title", "type", "year", "score", "rank", "is_disabled")
list_filter = ("type", "year", "is_disabled")
search_fields = ("title", "serial_number")
filter_horizontal = ("organizes", "magazines", "subjects")
@admin.register(ArticleComment)
class ArticleCommentAdmin(admin.ModelAdmin):
list_display = ("id", "article", "member", "star", "review_status", "created_at")
list_filter = ("review_status",)
search_fields = ("content", "member__name", "article__title")
@admin.register(ArticleLike)
class ArticleLikeAdmin(admin.ModelAdmin):
list_display = ("id", "article", "member", "created_at")
search_fields = ("member__name", "member__phone", "article__title")
@admin.register(ArticleCollect)
class ArticleCollectAdmin(admin.ModelAdmin):
list_display = ("id", "article", "member", "created_at")
search_fields = ("member__name", "member__phone", "article__title")
@admin.register(ArticleShare)
class ArticleShareAdmin(admin.ModelAdmin):
list_display = ("id", "article", "member", "visitor_key", "created_at")
search_fields = ("member__name", "member__phone", "visitor_key", "article__title")

8
apps/content/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.content"
label = "content"
verbose_name = "内容"

114
apps/content/filters.py Normal file
View File

@ -0,0 +1,114 @@
import django_filters
from .models import (
Article,
Magazine,
MagazineCategory,
Organize,
OrganizeCategory,
Professor,
Subject,
WorkGroup,
WorkGroupTerm,
)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
"""支持 ?year=2023,2024 或多值 in 查询。"""
class ArticleFilter(django_filters.FilterSet):
type = NumberInFilter(field_name="type")
year = NumberInFilter(field_name="year")
year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte")
organize = NumberInFilter(field_name="organizes")
magazine = NumberInFilter(field_name="magazines")
subject = NumberInFilter(field_name="subjects")
search = django_filters.CharFilter(field_name="title", lookup_expr="icontains")
ordering = django_filters.OrderingFilter(
fields=(
("score", "score"),
("year", "year"),
("rank", "rank"),
("push_date", "push_date"),
("created_at", "created_at"),
)
)
class Meta:
model = Article
fields = ["type", "year", "is_disabled"]
class OrganizeFilter(django_filters.FilterSet):
search = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
category = django_filters.NumberFilter(field_name="category_id")
class Meta:
model = Organize
fields = ["category", "is_disabled"]
class MagazineFilter(django_filters.FilterSet):
search = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
subject = django_filters.NumberFilter(field_name="subjects")
category = django_filters.NumberFilter(field_name="category_id")
class Meta:
model = Magazine
fields = ["category", "is_disabled"]
class ProfessorFilter(django_filters.FilterSet):
search = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
term = django_filters.NumberFilter(method="filter_term_tree")
workgroup = django_filters.NumberFilter(field_name="terms__workgroup")
class Meta:
model = Professor
fields = ["type", "is_disabled"]
def filter_term_tree(self, queryset, name, value):
"""包含该届及其同届下级分委会。"""
ids = {int(value)}
frontier = [int(value)]
while frontier:
children = list(
WorkGroupTerm.objects.filter(parent_id__in=frontier).values_list(
"id", flat=True
)
)
frontier = [cid for cid in children if cid not in ids]
ids.update(frontier)
return queryset.filter(terms__in=ids).distinct()
class WorkGroupFilter(django_filters.FilterSet):
class Meta:
model = WorkGroup
fields = ["parent", "is_disabled"]
class WorkGroupTermFilter(django_filters.FilterSet):
class Meta:
model = WorkGroupTerm
fields = ["workgroup", "parent", "is_current", "is_disabled"]
class SubjectFilter(django_filters.FilterSet):
class Meta:
model = Subject
fields = ["parent", "is_disabled"]
class OrganizeCategoryFilter(django_filters.FilterSet):
class Meta:
model = OrganizeCategory
fields = ["parent", "is_disabled"]
class MagazineCategoryFilter(django_filters.FilterSet):
class Meta:
model = MagazineCategory
fields = ["parent", "is_disabled"]

View File

@ -0,0 +1,435 @@
# Generated by Django 5.2.8 on 2026-10-13 11:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_number', models.CharField(blank=True, default='', max_length=64, verbose_name='编号')),
('title', models.CharField(max_length=500, verbose_name='标题')),
('year', models.IntegerField(blank=True, null=True, verbose_name='年份')),
('score', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='STAR 得分')),
('url', models.URLField(blank=True, default='', verbose_name='原文地址')),
('flags', models.JSONField(blank=True, default=list, verbose_name='标签')),
('content', models.TextField(blank=True, default='', verbose_name='正文')),
('introduction', models.TextField(blank=True, default='', verbose_name='简介')),
('push_date', models.CharField(blank=True, default='', max_length=32, 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(blank=True, null=True, verbose_name='首页排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '文献',
'verbose_name_plural': '文献',
'ordering': ['-year', '-score', 'id'],
},
),
migrations.CreateModel(
name='ArticleType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, 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='禁用')),
('code', models.CharField(help_text='用于文献编号前缀,如 G、C', max_length=8, unique=True, verbose_name='字母标识')),
('note', models.TextField(blank=True, default='', verbose_name='备注')),
],
options={
'verbose_name': '文献类型',
'verbose_name_plural': '文献类型',
'ordering': ['sort', 'id'],
},
),
migrations.CreateModel(
name='Magazine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, verbose_name='编号')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('logo', models.URLField(blank=True, default='', verbose_name='Logo')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '杂志',
'verbose_name_plural': '杂志',
'ordering': ['id'],
},
),
migrations.CreateModel(
name='Organize',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, 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='官网')),
('flags', models.JSONField(blank=True, default=list, 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(blank=True, null=True, verbose_name='首页排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '机构',
'verbose_name_plural': '机构',
'ordering': ['rank', 'id'],
},
),
migrations.CreateModel(
name='Professor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, verbose_name='编号')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('type', models.IntegerField(blank=True, null=True, verbose_name='分类')),
('role', models.CharField(blank=True, default='', max_length=200, verbose_name='身份')),
('job', models.CharField(blank=True, default='', max_length=200, verbose_name='职称')),
('description', models.TextField(blank=True, default='', verbose_name='简介')),
('email', models.EmailField(blank=True, default='', max_length=254, verbose_name='邮箱')),
('addr', models.CharField(blank=True, default='', max_length=500, verbose_name='地址')),
('logo', models.URLField(blank=True, default='', verbose_name='头像')),
('phone', models.CharField(blank=True, default='', max_length=50, verbose_name='电话')),
('position', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '专家',
'verbose_name_plural': '专家',
'ordering': ['position', 'id'],
},
),
migrations.CreateModel(
name='Specialty',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('type', models.IntegerField(blank=True, null=True, verbose_name='类型')),
('description', models.TextField(blank=True, default='', verbose_name='描述')),
('logo', models.URLField(blank=True, default='', verbose_name='Logo')),
('content', models.JSONField(blank=True, default=list, verbose_name='内容块')),
('position', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': '专科',
'verbose_name_plural': '专科',
'ordering': ['position', 'id'],
},
),
migrations.CreateModel(
name='StarRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('year', models.IntegerField(verbose_name='年份')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
],
options={
'verbose_name': 'STAR 规则',
'verbose_name_plural': 'STAR 规则',
'ordering': ['-year', 'id'],
},
),
migrations.CreateModel(
name='ArticleComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('star', models.DecimalField(decimal_places=1, max_digits=3, verbose_name='评价星级')),
('content', models.TextField(blank=True, default='', verbose_name='评价内容')),
('review_status', models.IntegerField(choices=[(1, '待审核'), (2, '不通过'), (3, '通过')], default=1, verbose_name='审核状态')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='content.article', verbose_name='文献')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='accounts.member', verbose_name='会员')),
],
options={
'verbose_name': '文献评价',
'verbose_name_plural': '文献评价',
'ordering': ['-id'],
},
),
migrations.AddField(
model_name='article',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='articles', to='content.articletype', verbose_name='类型'),
),
migrations.CreateModel(
name='Column',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('uuid', models.CharField(blank=True, default='', max_length=64, 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(blank=True, null=True, verbose_name='页面模板')),
('position', models.IntegerField(choices=[(1, '顶部'), (2, '中部'), (3, '底部')], default=1, verbose_name='位置')),
('sort', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
('path', models.CharField(blank=True, default='', max_length=200, verbose_name='前端路径')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.column', verbose_name='父栏目')),
],
options={
'verbose_name': '栏目',
'verbose_name_plural': '栏目',
'ordering': ['sort', 'id'],
},
),
migrations.AddField(
model_name='article',
name='magazines',
field=models.ManyToManyField(blank=True, related_name='articles', to='content.magazine', verbose_name='杂志'),
),
migrations.CreateModel(
name='MagazineCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, 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='禁用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.magazinecategory', verbose_name='父类别')),
],
options={
'verbose_name': '杂志类别',
'verbose_name_plural': '杂志类别',
'ordering': ['sort', 'id'],
},
),
migrations.AddField(
model_name='magazine',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='magazines', to='content.magazinecategory', verbose_name='杂志类别'),
),
migrations.AddField(
model_name='article',
name='organizes',
field=models.ManyToManyField(blank=True, related_name='articles', to='content.organize', verbose_name='机构'),
),
migrations.CreateModel(
name='OrganizeCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, 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='禁用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.organizecategory', verbose_name='父类别')),
],
options={
'verbose_name': '机构类别',
'verbose_name_plural': '机构类别',
'ordering': ['sort', 'id'],
},
),
migrations.AddField(
model_name='organize',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organizes', to='content.organizecategory', verbose_name='机构类别'),
),
migrations.CreateModel(
name='RuleBand',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('star_low', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='最低分')),
('star_high', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='最高分')),
('star', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='星级')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bands', to='content.starrule', verbose_name='规则')),
],
options={
'verbose_name': '评分分档',
'verbose_name_plural': '评分分档',
'ordering': ['star_low', 'id'],
},
),
migrations.CreateModel(
name='Subject',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('serial_num', models.CharField(blank=True, default='', max_length=64, 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='禁用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.subject', verbose_name='父学科')),
],
options={
'verbose_name': '学科',
'verbose_name_plural': '学科',
'ordering': ['sort', 'id'],
},
),
migrations.AddField(
model_name='magazine',
name='subjects',
field=models.ManyToManyField(blank=True, related_name='magazines', to='content.subject', verbose_name='学科'),
),
migrations.AddField(
model_name='article',
name='subjects',
field=models.ManyToManyField(blank=True, related_name='articles', to='content.subject', verbose_name='学科'),
),
migrations.CreateModel(
name='WorkGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('description', models.TextField(blank=True, default='', verbose_name='描述')),
('sort', models.IntegerField(default=0, verbose_name='排序')),
('is_disabled', models.BooleanField(default=False, verbose_name='禁用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.workgroup', verbose_name='上级工作组')),
],
options={
'verbose_name': '工作组',
'verbose_name_plural': '工作组',
'ordering': ['sort', 'id'],
},
),
migrations.CreateModel(
name='WorkGroupTerm',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='已删除')),
('name', models.CharField(max_length=200, verbose_name='届次名称')),
('start_year', models.IntegerField(blank=True, null=True, verbose_name='起始年')),
('end_year', models.IntegerField(blank=True, null=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='禁用')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='content.workgroupterm', verbose_name='同届上级')),
('workgroup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terms', to='content.workgroup', verbose_name='工作组')),
],
options={
'verbose_name': '工作组届次',
'verbose_name_plural': '工作组届次',
'ordering': ['-is_current', '-start_year', 'sort', 'id'],
},
),
migrations.CreateModel(
name='WorkGroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(blank=True, default='', max_length=200, verbose_name='组内职务')),
('professor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='content.professor', verbose_name='专家')),
('term', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='content.workgroupterm', verbose_name='届次')),
],
options={
'verbose_name': '工作组任职',
'verbose_name_plural': '工作组任职',
'unique_together': {('professor', 'term')},
},
),
migrations.AddField(
model_name='professor',
name='terms',
field=models.ManyToManyField(blank=True, related_name='professors', through='content.WorkGroupMembership', to='content.workgroupterm', verbose_name='所属届次'),
),
migrations.CreateModel(
name='ArticleCollect',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to='content.article', verbose_name='文献')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_collections', to='accounts.member', verbose_name='会员')),
],
options={
'verbose_name': '文献收藏',
'verbose_name_plural': '文献收藏',
'ordering': ['-id'],
'constraints': [models.UniqueConstraint(fields=('article', 'member'), name='content_articlecollect_article_member_uniq')],
},
),
migrations.CreateModel(
name='ArticleLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='content.article', verbose_name='文献')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to='accounts.member', verbose_name='会员')),
],
options={
'verbose_name': '文献点赞',
'verbose_name_plural': '文献点赞',
'ordering': ['-id'],
'constraints': [models.UniqueConstraint(fields=('article', 'member'), name='content_articlelike_article_member_uniq')],
},
),
migrations.CreateModel(
name='ArticleShare',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('visitor_key', models.CharField(blank=True, default='', max_length=64, verbose_name='访客标识')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='content.article', verbose_name='文献')),
('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='article_shares', to='accounts.member', verbose_name='会员')),
],
options={
'verbose_name': '文献分享',
'verbose_name_plural': '文献分享',
'ordering': ['-id'],
'constraints': [models.UniqueConstraint(condition=models.Q(('member__isnull', False)), fields=('article', 'member'), name='content_articleshare_article_member_uniq'), models.UniqueConstraint(condition=models.Q(('visitor_key', ''), _negated=True), fields=('article', 'visitor_key'), name='content_articleshare_article_visitor_uniq')],
},
),
]

View File

@ -0,0 +1,38 @@
from django.db import migrations
def seed_article_types(apps, schema_editor):
"""预置指南 / 共识,保证编号字母标识可用。"""
ArticleType = apps.get_model("content", "ArticleType")
defaults = [
{"id": 1, "name": "指南", "code": "G", "sort": 0, "note": ""},
{"id": 2, "name": "共识", "code": "C", "sort": 1, "note": ""},
]
for item in defaults:
ArticleType.objects.update_or_create(
id=item["id"],
defaults={
"name": item["name"],
"code": item["code"],
"sort": item["sort"],
"note": item["note"],
"serial_num": "",
"is_disabled": False,
"is_deleted": False,
},
)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("content", "0001_initial"),
]
operations = [
migrations.RunPython(seed_article_types, noop_reverse),
]

View File

561
apps/content/models.py Normal file
View File

@ -0,0 +1,561 @@
from django.db import models
from apps.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}"

494
apps/content/serializers.py Normal file
View File

@ -0,0 +1,494 @@
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",
]

40
apps/content/urls.py Normal file
View File

@ -0,0 +1,40 @@
from rest_framework.routers import DefaultRouter
from .views import (
ArticleCommentViewSet,
ArticleTypeViewSet,
ArticleViewSet,
ColumnViewSet,
MagazineCategoryViewSet,
MagazineViewSet,
OrganizeCategoryViewSet,
OrganizeViewSet,
ProfessorViewSet,
RuleBandViewSet,
SpecialtyViewSet,
StarRuleViewSet,
SubjectViewSet,
WorkGroupMembershipViewSet,
WorkGroupTermViewSet,
WorkGroupViewSet,
)
router = DefaultRouter()
router.register("columns", ColumnViewSet)
router.register("subjects", SubjectViewSet)
router.register("organize-categories", OrganizeCategoryViewSet)
router.register("magazine-categories", MagazineCategoryViewSet)
router.register("article-types", ArticleTypeViewSet)
router.register("organizes", OrganizeViewSet)
router.register("magazines", MagazineViewSet)
router.register("work-groups", WorkGroupViewSet)
router.register("work-group-terms", WorkGroupTermViewSet)
router.register("work-group-memberships", WorkGroupMembershipViewSet)
router.register("professors", ProfessorViewSet)
router.register("specialties", SpecialtyViewSet)
router.register("star-rules", StarRuleViewSet)
router.register("rule-bands", RuleBandViewSet)
router.register("articles", ArticleViewSet)
router.register("article-comments", ArticleCommentViewSet)
urlpatterns = router.urls

586
apps/content/views.py Normal file
View File

@ -0,0 +1,586 @@
from django.db.models import Avg, Count, F, Q
from django.db.models.functions import Greatest
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .filters import (
ArticleFilter,
MagazineCategoryFilter,
MagazineFilter,
OrganizeCategoryFilter,
OrganizeFilter,
ProfessorFilter,
SubjectFilter,
WorkGroupFilter,
WorkGroupTermFilter,
)
from .models import (
Article,
ArticleCollect,
ArticleComment,
ArticleLike,
ArticleShare,
ArticleType,
Column,
Magazine,
MagazineCategory,
NavPosition,
Organize,
OrganizeCategory,
Professor,
RuleBand,
Specialty,
StarRule,
Subject,
WorkGroup,
WorkGroupMembership,
WorkGroupTerm,
)
from .serializers import (
ArticleCommentSerializer,
ArticleListSerializer,
ArticleSerializer,
ArticleTypeSerializer,
ArticleWriteSerializer,
ColumnSerializer,
MagazineCategorySerializer,
MagazineSerializer,
OrganizeCategorySerializer,
OrganizeListSerializer,
OrganizeSerializer,
ProfessorSerializer,
RuleBandSerializer,
SpecialtySerializer,
StarRuleSerializer,
SubjectSerializer,
WorkGroupMembershipSerializer,
WorkGroupSerializer,
WorkGroupTermSerializer,
)
class IsAdminOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return bool(request.user and request.user.is_staff)
class IsMember(permissions.BasePermission):
"""前台会员(非 staff、已绑定 Member 且未禁用)。"""
def has_permission(self, request, view):
user = request.user
if not user or not user.is_authenticated or user.is_staff:
return False
member = getattr(user, "member_profile", None)
return bool(member and not member.is_disabled)
def _request_member(request):
return getattr(request.user, "member_profile", None)
# 统计未禁用文献数(反向 M2M Count 不会走 SoftDeleteManager)
_ACTIVE_ARTICLE_COUNT = Count(
"articles",
filter=Q(articles__is_disabled=False, articles__is_deleted=False),
distinct=True,
)
class PublicMixin:
"""公众只读:非管理员默认过滤禁用项;管理员可看全量以便后台管理。"""
def get_queryset(self):
qs = super().get_queryset()
user = getattr(self.request, "user", None)
if user and user.is_staff:
return qs
if self.action in (
"list",
"retrieve",
"ranking",
"nav",
"home",
"facets",
"record_read",
"record_share",
"toggle_like",
"toggle_collect",
"my_collections",
"my_engagement",
) and hasattr(qs.model, "is_disabled"):
qs = qs.filter(is_disabled=False)
return qs
class ColumnViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Column.objects.all()
serializer_class = ColumnSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["position", "parent", "is_disabled"]
ordering_fields = ["sort", "id"]
ordering = ["position", "sort", "id"]
@action(detail=False, methods=["get"])
def nav(self, request):
position = request.query_params.get("position", NavPosition.TOP)
qs = self.get_queryset().filter(position=position, parent__isnull=True)
return Response(ColumnSerializer(qs, many=True).data)
class SubjectViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Subject.objects.all()
serializer_class = SubjectSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = SubjectFilter
def get_queryset(self):
qs = super().get_queryset()
if self.action == "list":
qs = qs.annotate(article_count=_ACTIVE_ARTICLE_COUNT)
return qs
class OrganizeCategoryViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = OrganizeCategory.objects.all()
serializer_class = OrganizeCategorySerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = OrganizeCategoryFilter
class MagazineCategoryViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = MagazineCategory.objects.all()
serializer_class = MagazineCategorySerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = MagazineCategoryFilter
class OrganizeViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Organize.objects.all()
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = OrganizeFilter
def get_queryset(self):
qs = super().get_queryset()
if self.action in ("list", "ranking"):
qs = qs.annotate(
article_count=_ACTIVE_ARTICLE_COUNT,
avg_score=Avg(
"articles__score",
filter=Q(articles__is_disabled=False, articles__is_deleted=False),
),
)
return qs
def get_serializer_class(self):
if self.action == "list":
return OrganizeListSerializer
return OrganizeSerializer
@action(detail=False, methods=["get"])
def ranking(self, request):
qs = self.get_queryset().order_by("-avg_score", "-article_count")
page = self.paginate_queryset(qs)
data = OrganizeListSerializer(page or qs, many=True).data
if page is not None:
return self.get_paginated_response(data)
return Response(data)
class MagazineViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Magazine.objects.prefetch_related("subjects").all()
serializer_class = MagazineSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = MagazineFilter
def get_queryset(self):
qs = super().get_queryset()
if self.action in ("list", "ranking"):
qs = qs.annotate(
article_count=_ACTIVE_ARTICLE_COUNT,
avg_score=Avg(
"articles__score",
filter=Q(articles__is_disabled=False, articles__is_deleted=False),
),
)
return qs
@action(detail=False, methods=["get"])
def ranking(self, request):
qs = self.get_queryset().order_by("-avg_score", "-article_count")
page = self.paginate_queryset(qs)
data = MagazineSerializer(page or qs, many=True).data
if page is not None:
return self.get_paginated_response(data)
return Response(data)
class WorkGroupViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = WorkGroup.objects.all()
serializer_class = WorkGroupSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = WorkGroupFilter
class WorkGroupTermViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = WorkGroupTerm.objects.select_related("workgroup").all()
serializer_class = WorkGroupTermSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = WorkGroupTermFilter
class WorkGroupMembershipViewSet(viewsets.ModelViewSet):
queryset = WorkGroupMembership.objects.select_related(
"professor", "term", "term__workgroup"
).all()
serializer_class = WorkGroupMembershipSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["professor", "term"]
class ProfessorViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Professor.objects.prefetch_related(
"terms", "memberships__term__workgroup"
).all()
serializer_class = ProfessorSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = ProfessorFilter
class SpecialtyViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Specialty.objects.all()
serializer_class = SpecialtySerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["type", "is_disabled"]
class StarRuleViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = StarRule.objects.prefetch_related("bands").all()
serializer_class = StarRuleSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["year", "is_disabled"]
class RuleBandViewSet(viewsets.ModelViewSet):
queryset = RuleBand.objects.all()
serializer_class = RuleBandSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["rule", "is_disabled"]
class ArticleTypeViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = ArticleType.objects.all()
serializer_class = ArticleTypeSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["is_disabled"]
class ArticleViewSet(PublicMixin, viewsets.ModelViewSet):
queryset = Article.objects.select_related("type").prefetch_related(
"organizes", "magazines", "subjects"
).all()
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_class = ArticleFilter
def get_serializer_class(self):
if self.action in ("create", "update", "partial_update"):
return ArticleWriteSerializer
if self.action == "list":
return ArticleListSerializer
return ArticleSerializer
def _bump_counter(self, field):
"""前台互动计数 +1;先 get_object 以尊重禁用过滤。"""
instance = self.get_object()
Article.objects.filter(pk=instance.pk).update(**{field: F(field) + 1})
instance.refresh_from_db(fields=[field])
return Response({"id": instance.id, field: getattr(instance, field)})
def _toggle_relation(self, model, counter_field, active_key):
"""会员点赞/收藏切换;同步维护计数。"""
article = self.get_object()
member = _request_member(self.request)
if not member:
return Response(
{"detail": "请先登录前台会员"},
status=status.HTTP_401_UNAUTHORIZED,
)
existing = model.objects.filter(article=article, member=member).first()
if existing:
existing.delete()
Article.objects.filter(pk=article.pk).update(
**{counter_field: Greatest(F(counter_field) - 1, 0)}
)
active = False
else:
model.objects.create(article=article, member=member)
Article.objects.filter(pk=article.pk).update(
**{counter_field: F(counter_field) + 1}
)
active = True
article.refresh_from_db(fields=[counter_field])
return Response(
{
"id": article.id,
active_key: active,
counter_field: getattr(article, counter_field),
}
)
@action(
detail=True,
methods=["post"],
url_path="record-read",
permission_classes=[permissions.AllowAny],
)
def record_read(self, request, pk=None):
"""前台详情页显式上报阅读,避免 GET 详情误增。"""
return self._bump_counter("read_num")
@action(
detail=True,
methods=["post"],
url_path="toggle-like",
permission_classes=[IsMember],
)
def toggle_like(self, request, pk=None):
"""会员点赞 / 取消点赞。"""
return self._toggle_relation(ArticleLike, "like_num", "liked")
@action(
detail=True,
methods=["post"],
url_path="toggle-collect",
permission_classes=[IsMember],
)
def toggle_collect(self, request, pk=None):
"""会员收藏 / 取消收藏。"""
return self._toggle_relation(ArticleCollect, "collect_num", "collected")
@action(
detail=True,
methods=["post"],
url_path="record-share",
permission_classes=[permissions.AllowAny],
)
def record_share(self, request, pk=None):
"""
前台分享计数:同一人对同一文献只 +1 一次。
已登录按会员去重;未登录按 visitor_key 去重。
"""
from django.db import IntegrityError
article = self.get_object()
member = _request_member(request)
raw_key = (request.data.get("visitor_key") or "").strip()
# 限制长度,避免异常长串
visitor_key = raw_key[:64] if raw_key else ""
if member:
exists = ArticleShare.objects.filter(
article=article, member=member
).exists()
if exists:
return Response(
{
"id": article.id,
"share_num": article.share_num,
"counted": False,
}
)
try:
ArticleShare.objects.create(article=article, member=member)
except IntegrityError:
article.refresh_from_db(fields=["share_num"])
return Response(
{
"id": article.id,
"share_num": article.share_num,
"counted": False,
}
)
elif visitor_key:
exists = ArticleShare.objects.filter(
article=article, visitor_key=visitor_key
).exists()
if exists:
return Response(
{
"id": article.id,
"share_num": article.share_num,
"counted": False,
}
)
try:
ArticleShare.objects.create(
article=article, visitor_key=visitor_key
)
except IntegrityError:
article.refresh_from_db(fields=["share_num"])
return Response(
{
"id": article.id,
"share_num": article.share_num,
"counted": False,
}
)
else:
# 无身份标识时无法去重,仍允许计数(兼容旧客户端)
Article.objects.filter(pk=article.pk).update(share_num=F("share_num") + 1)
article.refresh_from_db(fields=["share_num"])
return Response(
{"id": article.id, "share_num": article.share_num, "counted": True}
)
Article.objects.filter(pk=article.pk).update(share_num=F("share_num") + 1)
article.refresh_from_db(fields=["share_num"])
return Response(
{"id": article.id, "share_num": article.share_num, "counted": True}
)
@action(
detail=False,
methods=["get"],
url_path="my-collections",
permission_classes=[IsMember],
)
def my_collections(self, request):
"""当前会员的收藏列表。"""
member = _request_member(request)
qs = (
self.get_queryset()
.filter(collections__member=member)
.order_by("-collections__created_at", "-id")
.distinct()
)
page = self.paginate_queryset(qs)
data = ArticleListSerializer(page or qs, many=True).data
if page is not None:
return self.get_paginated_response(data)
return Response(data)
@action(
detail=False,
methods=["get"],
url_path="my-engagement",
permission_classes=[IsMember],
)
def my_engagement(self, request):
"""批量查询当前会员对若干文献的点赞/收藏状态。"""
member = _request_member(request)
raw = request.query_params.get("ids") or ""
ids = [x.strip() for x in raw.split(",") if x.strip().isdigit()]
if not ids:
return Response({"liked": [], "collected": []})
liked = list(
ArticleLike.objects.filter(member=member, article_id__in=ids).values_list(
"article_id", flat=True
)
)
collected = list(
ArticleCollect.objects.filter(
member=member, article_id__in=ids
).values_list("article_id", flat=True)
)
return Response({"liked": liked, "collected": collected})
@action(detail=False, methods=["get"])
def ranking(self, request):
article_type = request.query_params.get("type")
year = request.query_params.get("year")
subject = request.query_params.get("subject")
qs = self.get_queryset().order_by("-score", "-year")
if article_type:
qs = qs.filter(type=article_type)
if year:
qs = qs.filter(year=year)
if subject:
qs = qs.filter(subjects=subject)
page = self.paginate_queryset(qs)
data = ArticleListSerializer(page or qs, many=True).data
if page is not None:
return self.get_paginated_response(data)
return Response(data)
@action(detail=False, methods=["get"])
def home(self, request):
"""首页精选:按 rank 展示。"""
qs = self.get_queryset().filter(rank__isnull=False).order_by("rank")[:20]
return Response(ArticleListSerializer(qs, many=True).data)
@action(detail=False, methods=["get"])
def facets(self, request):
"""筛选面板用:文献总量、按类型计数、实际存在的年份列表。"""
qs = self.get_queryset()
by_type = {
row["type_id"]: row["count"]
for row in qs.values("type_id").annotate(count=Count("id"))
}
type_rows = list(
ArticleType.objects.filter(is_disabled=False).order_by("sort", "id")
)
type_map = {str(t.id): by_type.get(t.id, 0) for t in type_rows}
years = list(
qs.exclude(year__isnull=True)
.values_list("year", flat=True)
.distinct()
.order_by("-year")
)
return Response(
{
"total": qs.count(),
"type": type_map,
"types": [
{
"id": t.id,
"name": t.name,
"count": by_type.get(t.id, 0),
}
for t in type_rows
],
"years": years,
}
)
class ArticleCommentViewSet(viewsets.ModelViewSet):
"""文献用户评价(后台审核)。"""
queryset = ArticleComment.objects.select_related("article", "member").all()
serializer_class = ArticleCommentSerializer
permission_classes = [IsAdminOrReadOnly]
filter_backends = [DjangoFilterBackend]
filterset_fields = ["review_status", "article", "member"]
def get_queryset(self):
qs = super().get_queryset()
user = getattr(self.request, "user", None)
# 公众只看已通过;管理员看全量
if not (user and user.is_staff):
qs = qs.filter(review_status=3)
start = self.request.query_params.get("start_date")
end = self.request.query_params.get("end_date")
if start:
qs = qs.filter(created_at__date__gte=start)
if end:
qs = qs.filter(created_at__date__lte=end)
return qs

View File

@ -1,13 +1,5 @@
""" """
Django settings for config project. Django settings for STAR International API.
Generated by 'django-admin startproject' using Django 5.0.1.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
""" """
import os import os
@ -16,20 +8,16 @@ from pathlib import Path
FILE_CHARSET = "utf-8" FILE_CHARSET = "utf-8"
DEFAULT_CHARSET = "utf-8" DEFAULT_CHARSET = "utf-8"
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"django-insecure-$oy9z2rxl7os0fj04*0*_ctf9b16fp@&8+2t^d2s)%e#dhbm9*",
)
# Quick-start development settings - unsuitable for production DEBUG = os.environ.get("DEBUG", "True").lower() in ("1", "true", "yes")
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! ALLOWED_HOSTS = ["localhost", "127.0.0.1", "10.0.24.8", "192.168.195.22", "*"]
SECRET_KEY = "django-insecure-$oy^2rxl^6os0fj04*0*_ctf9b16fp@&8+2t^d2s)%e#dhbm9*"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get("DEBUG", True)
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "10.0.24.8", "192.168.195.22"]
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:5173", "http://127.0.0.1:5173",
@ -39,9 +27,10 @@ CORS_ALLOWED_ORIGINS = [
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1:5173"] CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:5173",
# Application definition "http://localhost:5173",
]
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@ -52,9 +41,14 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework", "rest_framework",
"rest_framework.authtoken", "rest_framework.authtoken",
"django_filters",
"django_extensions", "django_extensions",
"simple_history", "simple_history",
"corsheaders", "corsheaders",
"apps.common",
"apps.content",
"apps.cms",
"apps.accounts",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -89,88 +83,64 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "需要补充"), "NAME": os.environ.get("DB_NAME", "starintldb"),
"USER": os.environ.get("DB_USER", "需要补充"), "USER": os.environ.get("DB_USER", "starintl"),
"PASSWORD": os.environ.get("DB_PASSWORD", "需要补充"), "PASSWORD": os.environ.get("DB_PASSWORD", "starintlpassword"),
"HOST": os.environ.get("DB_HOST", "db.ctrlk.work"), "HOST": os.environ.get("DB_HOST", "192.168.0.23"),
"PORT": os.environ.get("DB_PORT", "5432"), "PORT": os.environ.get("DB_PORT", "5432"),
} }
} }
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
] ]
LANGUAGE_CODE = "zh-hans"
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = "zh-CN"
TIME_ZONE = "Asia/Shanghai" TIME_ZONE = "Asia/Shanghai"
USE_I18N = True USE_I18N = True
USE_TZ = False USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = os.environ.get("MEDIA_ROOT", str(BASE_DIR / "media"))
MEDIA_ROOT = os.environ.get("MEDIA_ROOT", "需要补充")
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny",
],
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
], ],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 50, "PAGE_SIZE": 20,
"DATE_FORMAT": "%Y-%m-%d", "DATE_FORMAT": "%Y-%m-%d",
"DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", "DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S",
"UPLOADED_FILES_USE_URL": False, "UPLOADED_FILES_USE_URL": False,
} }
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "")
# Celery settings
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "需要补充")
CELERY_ACCEPT_CONTENT = ["json"] CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_BACKEND = "rpc" CELERY_RESULT_BACKEND = "rpc"
CELERY_TIMEZONE = "Asia/Shanghai" CELERY_TIMEZONE = "Asia/Shanghai"
try: try:
from .local_settings import * from .local_settings import * # noqa: F401,F403
except ImportError: except ImportError:
pass pass

View File

@ -1,21 +1,15 @@
"""config URL Configuration from django.conf import settings
from django.conf.urls.static import static
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("django-admin/", admin.site.urls),
path("api/auth/", include("apps.accounts.urls")),
path("api/", include("apps.common.urls")),
path("api/", include("apps.content.urls")),
path("api/", include("apps.cms.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -1,18 +1,8 @@
Django Django>=5.0,<6
djangorestframework djangorestframework>=3.15
django-filter django-filter>=24.0
psycopg[binary] psycopg[binary]>=3.1
django-extensions django-extensions>=3.2
django-cors-headers django-cors-headers>=4.0
amqp django-simple-history>=3.4
celery[amqp] requests>=2.31
eventlet
pypdfium2
chardet
openai
tiktoken
django-simple-history
requests
tencentcloud-sdk-python
llama-index
chromadb