From 38ec52abf5311a1a7513c2f5f72a2fc4cf0a73ee Mon Sep 17 00:00:00 2001 From: aifeilong <2993607249@qq.com> Date: Wed, 14 Oct 2026 17:08:01 +0800 Subject: [PATCH] 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 --- .gitignore | 5 +- README.md | 50 +- apps/__init__.py | 0 apps/accounts/__init__.py | 0 apps/accounts/admin.py | 11 + apps/accounts/apps.py | 8 + apps/accounts/management/__init__.py | 0 apps/accounts/management/commands/__init__.py | 0 .../accounts/management/commands/seed_demo.py | 546 ++++++++++++++++ .../management/commands/sync_nav_columns.py | 96 +++ apps/accounts/migrations/0001_initial.py | 39 ++ apps/accounts/migrations/__init__.py | 1 + apps/accounts/models.py | 38 ++ apps/accounts/phone.py | 34 + apps/accounts/serializers.py | 164 +++++ apps/accounts/urls.py | 27 + apps/accounts/views.py | 252 ++++++++ apps/cms/__init__.py | 0 apps/cms/admin.py | 60 ++ apps/cms/apps.py | 8 + apps/cms/migrations/0001_initial.py | 201 ++++++ apps/cms/migrations/__init__.py | 0 apps/cms/models.py | 187 ++++++ apps/cms/serializers.py | 176 ++++++ apps/cms/urls.py | 30 + apps/cms/views.py | 211 +++++++ apps/common/__init__.py | 0 apps/common/apps.py | 8 + apps/common/models.py | 43 ++ apps/common/upload.py | 89 +++ apps/common/urls.py | 7 + apps/content/__init__.py | 0 apps/content/admin.py | 156 +++++ apps/content/apps.py | 8 + apps/content/filters.py | 114 ++++ apps/content/migrations/0001_initial.py | 435 +++++++++++++ .../migrations/0002_seed_article_types.py | 38 ++ apps/content/migrations/__init__.py | 0 apps/content/models.py | 561 +++++++++++++++++ apps/content/serializers.py | 494 +++++++++++++++ apps/content/urls.py | 40 ++ apps/content/views.py | 586 ++++++++++++++++++ config/settings.py | 108 ++-- config/urls.py | 28 +- requirements.txt | 26 +- 45 files changed, 4757 insertions(+), 128 deletions(-) create mode 100644 apps/__init__.py create mode 100644 apps/accounts/__init__.py create mode 100644 apps/accounts/admin.py create mode 100644 apps/accounts/apps.py create mode 100644 apps/accounts/management/__init__.py create mode 100644 apps/accounts/management/commands/__init__.py create mode 100644 apps/accounts/management/commands/seed_demo.py create mode 100644 apps/accounts/management/commands/sync_nav_columns.py create mode 100644 apps/accounts/migrations/0001_initial.py create mode 100644 apps/accounts/migrations/__init__.py create mode 100644 apps/accounts/models.py create mode 100644 apps/accounts/phone.py create mode 100644 apps/accounts/serializers.py create mode 100644 apps/accounts/urls.py create mode 100644 apps/accounts/views.py create mode 100644 apps/cms/__init__.py create mode 100644 apps/cms/admin.py create mode 100644 apps/cms/apps.py create mode 100644 apps/cms/migrations/0001_initial.py create mode 100644 apps/cms/migrations/__init__.py create mode 100644 apps/cms/models.py create mode 100644 apps/cms/serializers.py create mode 100644 apps/cms/urls.py create mode 100644 apps/cms/views.py create mode 100644 apps/common/__init__.py create mode 100644 apps/common/apps.py create mode 100644 apps/common/models.py create mode 100644 apps/common/upload.py create mode 100644 apps/common/urls.py create mode 100644 apps/content/__init__.py create mode 100644 apps/content/admin.py create mode 100644 apps/content/apps.py create mode 100644 apps/content/filters.py create mode 100644 apps/content/migrations/0001_initial.py create mode 100644 apps/content/migrations/0002_seed_article_types.py create mode 100644 apps/content/migrations/__init__.py create mode 100644 apps/content/models.py create mode 100644 apps/content/serializers.py create mode 100644 apps/content/urls.py create mode 100644 apps/content/views.py diff --git a/.gitignore b/.gitignore index a5cc597..449e215 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ __pycache__/**/*.pyc **/__pycache__/**/*.pyc uploads/2025/ media/ -local_settings.py \ No newline at end of file +local_settings.py +db.sqlite3 +*.sqlite3 +staticfiles/ \ No newline at end of file diff --git a/README.md b/README.md index 8a99ed0..5aee7c4 100644 --- a/README.md +++ b/README.md @@ -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 = ["*"] - -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 +## Setup +```bash python3 -m venv .venv - -### run - +source .venv/bin/activate +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 +``` + +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 ` and `is_staff=True`. + +## Demo + +```bash +python manage.py seed_demo +``` diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..1601d59 --- /dev/null +++ b/apps/accounts/admin.py @@ -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",) diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..e6b12d5 --- /dev/null +++ b/apps/accounts/apps.py @@ -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 = "账号" diff --git a/apps/accounts/management/__init__.py b/apps/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/management/commands/__init__.py b/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/management/commands/seed_demo.py b/apps/accounts/management/commands/seed_demo.py new file mode 100644 index 0000000..d538096 --- /dev/null +++ b/apps/accounts/management/commands/seed_demo.py @@ -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(

键:值

)。""" + parts = [ + ("关注的疾病或主题", disease), + ("领域", domain), + ("目标人群", population), + ("使用者", users), + ("推荐意见总结", summary), + ("证据质量分级", evidence), + ("推荐强度分级", strength), + ("资助", funding), + ("注册号", registry), + ] + return "".join(f"

{k}:{v}

" 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": ( + "

本指南围绕放射性皮肤损伤的诊断与治疗给出推荐意见," + "涵盖分度诊断、局部与全身治疗及创面管理等内容。

" + ), + }, + { + "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": "

本共识总结糖尿病筛查与综合管理的关键推荐意见。

", + }, + { + "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": "

本指南聚焦卒中一级预防的危险因素干预与人群筛查策略。

", + }, + { + "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": "

本共识强调抗菌药物合理使用与耐药监测的协同管理。

", + }, + { + "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": "

本指南系统阐述慢性心力衰竭的评估流程与治疗策略。

", + }, +] + + +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="

Welcome to the STAR International portal.

", + publication_time="2024-01-15", + rank=1, + ) + Publication.objects.create( + title="STAR Methodology Paper", + content="

Methodology overview.

", + 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=( + '

' + 'STAR评级步骤' + "

" + ), + ) + 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"], + ) diff --git a/apps/accounts/management/commands/sync_nav_columns.py b/apps/accounts/management/commands/sync_nav_columns.py new file mode 100644 index 0000000..56ea5dd --- /dev/null +++ b/apps/accounts/management/commands/sync_nav_columns.py @@ -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} 条(顶部+底部)")) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..c4f3caf --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/accounts/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..4bf2fea --- /dev/null +++ b/apps/accounts/models.py @@ -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}" diff --git a/apps/accounts/phone.py b/apps/accounts/phone.py new file mode 100644 index 0000000..6b0f413 --- /dev/null +++ b/apps/accounts/phone.py @@ -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}" diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py new file mode 100644 index 0000000..17ec15f --- /dev/null +++ b/apps/accounts/serializers.py @@ -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", + ] diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..b33ebfb --- /dev/null +++ b/apps/accounts/urls.py @@ -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)), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..42ad7a0 --- /dev/null +++ b/apps/accounts/views.py @@ -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) diff --git a/apps/cms/__init__.py b/apps/cms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cms/admin.py b/apps/cms/admin.py new file mode 100644 index 0000000..bc699c8 --- /dev/null +++ b/apps/cms/admin.py @@ -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") diff --git a/apps/cms/apps.py b/apps/cms/apps.py new file mode 100644 index 0000000..6cf3480 --- /dev/null +++ b/apps/cms/apps.py @@ -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 = "站点内容" diff --git a/apps/cms/migrations/0001_initial.py b/apps/cms/migrations/0001_initial.py new file mode 100644 index 0000000..a80d7d9 --- /dev/null +++ b/apps/cms/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/cms/migrations/__init__.py b/apps/cms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cms/models.py b/apps/cms/models.py new file mode 100644 index 0000000..0f86613 --- /dev/null +++ b/apps/cms/models.py @@ -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 diff --git a/apps/cms/serializers.py b/apps/cms/serializers.py new file mode 100644 index 0000000..5785e5e --- /dev/null +++ b/apps/cms/serializers.py @@ -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", + ] diff --git a/apps/cms/urls.py b/apps/cms/urls.py new file mode 100644 index 0000000..ee7cecd --- /dev/null +++ b/apps/cms/urls.py @@ -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"), +] diff --git a/apps/cms/views.py b/apps/cms/views.py new file mode 100644 index 0000000..107bee9 --- /dev/null +++ b/apps/cms/views.py @@ -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, + } + ) diff --git a/apps/common/__init__.py b/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/apps.py b/apps/common/apps.py new file mode 100644 index 0000000..18d1fa7 --- /dev/null +++ b/apps/common/apps.py @@ -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 = "公共" diff --git a/apps/common/models.py b/apps/common/models.py new file mode 100644 index 0000000..2870ddc --- /dev/null +++ b/apps/common/models.py @@ -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"]) diff --git a/apps/common/upload.py b/apps/common/upload.py new file mode 100644 index 0000000..7d007b1 --- /dev/null +++ b/apps/common/upload.py @@ -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}) diff --git a/apps/common/urls.py b/apps/common/urls.py new file mode 100644 index 0000000..eddd784 --- /dev/null +++ b/apps/common/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .upload import MediaUploadView + +urlpatterns = [ + path("uploads/", MediaUploadView.as_view(), name="media-upload"), +] diff --git a/apps/content/__init__.py b/apps/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/content/admin.py b/apps/content/admin.py new file mode 100644 index 0000000..5405db4 --- /dev/null +++ b/apps/content/admin.py @@ -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") diff --git a/apps/content/apps.py b/apps/content/apps.py new file mode 100644 index 0000000..989f7b4 --- /dev/null +++ b/apps/content/apps.py @@ -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 = "内容" diff --git a/apps/content/filters.py b/apps/content/filters.py new file mode 100644 index 0000000..d513d50 --- /dev/null +++ b/apps/content/filters.py @@ -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"] diff --git a/apps/content/migrations/0001_initial.py b/apps/content/migrations/0001_initial.py new file mode 100644 index 0000000..bc8aec1 --- /dev/null +++ b/apps/content/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/content/migrations/0002_seed_article_types.py b/apps/content/migrations/0002_seed_article_types.py new file mode 100644 index 0000000..21bf492 --- /dev/null +++ b/apps/content/migrations/0002_seed_article_types.py @@ -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), + ] diff --git a/apps/content/migrations/__init__.py b/apps/content/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/content/models.py b/apps/content/models.py new file mode 100644 index 0000000..d29f4d1 --- /dev/null +++ b/apps/content/models.py @@ -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}" diff --git a/apps/content/serializers.py b/apps/content/serializers.py new file mode 100644 index 0000000..e01bc51 --- /dev/null +++ b/apps/content/serializers.py @@ -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", + ] diff --git a/apps/content/urls.py b/apps/content/urls.py new file mode 100644 index 0000000..5bb5037 --- /dev/null +++ b/apps/content/urls.py @@ -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 diff --git a/apps/content/views.py b/apps/content/views.py new file mode 100644 index 0000000..1829b0d --- /dev/null +++ b/apps/content/views.py @@ -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 diff --git a/config/settings.py b/config/settings.py index e966e0d..2075483 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,13 +1,5 @@ """ -Django settings for config project. - -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/ +Django settings for STAR International API. """ import os @@ -16,20 +8,16 @@ from pathlib import Path FILE_CHARSET = "utf-8" DEFAULT_CHARSET = "utf-8" -# Build paths inside the project like this: BASE_DIR / 'subdir'. 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 -# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ +DEBUG = os.environ.get("DEBUG", "True").lower() in ("1", "true", "yes") -# SECURITY WARNING: keep the secret key used in production secret! -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"] +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "10.0.24.8", "192.168.195.22", "*"] CORS_ALLOWED_ORIGINS = [ "http://127.0.0.1:5173", @@ -39,9 +27,10 @@ CORS_ALLOWED_ORIGINS = [ CORS_ALLOW_ALL_ORIGINS = True -CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1:5173"] - -# Application definition +CSRF_TRUSTED_ORIGINS = [ + "http://127.0.0.1:5173", + "http://localhost:5173", +] INSTALLED_APPS = [ "django.contrib.admin", @@ -52,9 +41,14 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "rest_framework", "rest_framework.authtoken", + "django_filters", "django_extensions", "simple_history", "corsheaders", + "apps.common", + "apps.content", + "apps.cms", + "apps.accounts", ] MIDDLEWARE = [ @@ -89,88 +83,64 @@ TEMPLATES = [ WSGI_APPLICATION = "config.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/5.0/ref/settings/#databases - DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("DB_NAME", "需要补充"), - "USER": os.environ.get("DB_USER", "需要补充"), - "PASSWORD": os.environ.get("DB_PASSWORD", "需要补充"), - "HOST": os.environ.get("DB_HOST", "db.ctrlk.work"), + "NAME": os.environ.get("DB_NAME", "starintldb"), + "USER": os.environ.get("DB_USER", "starintl"), + "PASSWORD": os.environ.get("DB_PASSWORD", "starintlpassword"), + "HOST": os.environ.get("DB_HOST", "192.168.0.23"), "PORT": os.environ.get("DB_PORT", "5432"), } } - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ - { - "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.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ - -LANGUAGE_CODE = "zh-CN" - +LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" - USE_I18N = True - USE_TZ = False - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ - STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" MEDIA_URL = "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 +MEDIA_ROOT = os.environ.get("MEDIA_ROOT", str(BASE_DIR / "media")) DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "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", - "PAGE_SIZE": 50, + "PAGE_SIZE": 20, "DATE_FORMAT": "%Y-%m-%d", "DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", "UPLOADED_FILES_USE_URL": False, } - -# Celery settings -CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "需要补充") +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "") CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_BACKEND = "rpc" CELERY_TIMEZONE = "Asia/Shanghai" - try: - from .local_settings import * + from .local_settings import * # noqa: F401,F403 except ImportError: pass diff --git a/config/urls.py b/config/urls.py index d52e577..0ca063b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,21 +1,15 @@ -"""config URL Configuration - -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.conf import settings +from django.conf.urls.static import static from django.contrib import admin -from django.urls import path +from django.urls import include, path 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) diff --git a/requirements.txt b/requirements.txt index 3437316..dc429f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,8 @@ -Django -djangorestframework -django-filter -psycopg[binary] -django-extensions -django-cors-headers -amqp -celery[amqp] -eventlet -pypdfium2 -chardet -openai -tiktoken -django-simple-history -requests -tencentcloud-sdk-python -llama-index -chromadb \ No newline at end of file +Django>=5.0,<6 +djangorestframework>=3.15 +django-filter>=24.0 +psycopg[binary]>=3.1 +django-extensions>=3.2 +django-cors-headers>=4.0 +django-simple-history>=3.4 +requests>=2.31