diff --git a/backend/alembic/versions/002_simplify_roles.py b/backend/alembic/versions/002_simplify_roles.py new file mode 100644 index 0000000..dd7e87b --- /dev/null +++ b/backend/alembic/versions/002_simplify_roles.py @@ -0,0 +1,27 @@ +"""简化权限模型:移除 community_admin 角色 + +将所有 community_users.role = 'community_admin' 统一降级为 'user'。 +新权限体系:superuser / admin / user(三层,无 community_admin) + +Revision ID: 002_simplify_roles +Revises: 001_initial +Create Date: 2026-02-22 +""" + +from alembic import op + +revision = "002_simplify_roles" +down_revision = "001_initial" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "UPDATE community_users SET role = 'user' WHERE role = 'community_admin'" + ) + + +def downgrade() -> None: + # 无法恢复:降级后无法区分哪些 user 原本是 community_admin + pass diff --git a/backend/alembic/versions/003_add_content_communities.py b/backend/alembic/versions/003_add_content_communities.py new file mode 100644 index 0000000..144497f --- /dev/null +++ b/backend/alembic/versions/003_add_content_communities.py @@ -0,0 +1,59 @@ +"""新增 content_communities 关联表,迁移现有 community_id 数据 + +第一步(本文件):创建关联表,将 contents.community_id 迁移进新表(is_primary=True) +第二步(009_remove_content_community_id.py):迁移验证无误后移除旧 community_id 列 + +Revision ID: 003_add_content_communities +Revises: 002_simplify_roles +Create Date: 2026-02-22 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "003_add_content_communities" +down_revision = "002_simplify_roles" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 唯一约束直接内联到 create_table,兼容 SQLite(不支持 ALTER ADD CONSTRAINT) + op.create_table( + "content_communities", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "content_id", + sa.Integer, + sa.ForeignKey("contents.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "community_id", + sa.Integer, + sa.ForeignKey("communities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("is_primary", sa.Boolean, server_default="1"), + sa.Column("linked_at", sa.DateTime, server_default=sa.func.now()), + sa.Column( + "linked_by_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.UniqueConstraint("content_id", "community_id", name="uq_content_community"), + ) + # 迁移现有数据:将 contents.community_id 作为主社区写入新关联表 + op.execute(""" + INSERT INTO content_communities (content_id, community_id, is_primary) + SELECT id, community_id, 1 + FROM contents + WHERE community_id IS NOT NULL + """) + + +def downgrade() -> None: + op.drop_table("content_communities") diff --git a/backend/alembic/versions/004_add_people_module.py b/backend/alembic/versions/004_add_people_module.py new file mode 100644 index 0000000..e6410de --- /dev/null +++ b/backend/alembic/versions/004_add_people_module.py @@ -0,0 +1,81 @@ +"""新增人脉模块:person_profiles + community_roles 表 + +Revision ID: 004_add_people_module +Revises: 003_add_content_communities +Create Date: 2026-02-22 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "004_add_people_module" +down_revision = "003_add_content_communities" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "person_profiles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("display_name", sa.String(200), nullable=False), + sa.Column("avatar_url", sa.String(500), nullable=True), + sa.Column("github_handle", sa.String(100), nullable=True, unique=True), + sa.Column("gitcode_handle", sa.String(100), nullable=True, unique=True), + sa.Column("email", sa.String(200), nullable=True, unique=True), + sa.Column("phone", sa.String(50), nullable=True), + sa.Column("company", sa.String(200), nullable=True), + sa.Column("location", sa.String(200), nullable=True), + sa.Column("bio", sa.Text, nullable=True), + sa.Column("tags", sa.JSON, nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("source", sa.String(30), nullable=False, server_default="manual"), + sa.Column( + "created_by_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_person_profiles_display_name", "person_profiles", ["display_name"]) + op.create_index("ix_person_profiles_github_handle", "person_profiles", ["github_handle"]) + op.create_index("ix_person_profiles_email", "person_profiles", ["email"]) + op.create_index("ix_person_profiles_company", "person_profiles", ["company"]) + + op.create_table( + "community_roles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "person_id", + sa.Integer, + sa.ForeignKey("person_profiles.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("community_name", sa.String(200), nullable=False), + sa.Column("project_url", sa.String(500), nullable=True), + sa.Column("role", sa.String(100), nullable=False), + sa.Column("role_label", sa.String(100), nullable=True), + sa.Column("is_current", sa.Boolean, server_default="1"), + sa.Column("started_at", sa.Date, nullable=True), + sa.Column("ended_at", sa.Date, nullable=True), + sa.Column("source_url", sa.String(500), nullable=True), + sa.Column( + "updated_by_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index("ix_community_roles_person_id", "community_roles", ["person_id"]) + + +def downgrade() -> None: + op.drop_index("ix_community_roles_person_id", "community_roles") + op.drop_table("community_roles") + op.drop_index("ix_person_profiles_company", "person_profiles") + op.drop_index("ix_person_profiles_email", "person_profiles") + op.drop_index("ix_person_profiles_github_handle", "person_profiles") + op.drop_index("ix_person_profiles_display_name", "person_profiles") + op.drop_table("person_profiles") diff --git a/backend/alembic/versions/005_add_event_module.py b/backend/alembic/versions/005_add_event_module.py new file mode 100644 index 0000000..107b569 --- /dev/null +++ b/backend/alembic/versions/005_add_event_module.py @@ -0,0 +1,178 @@ +"""新增活动模块:event_templates、checklist_template_items、events、 +checklist_items、event_personnel、event_attendees、 +feedback_items、issue_links、event_tasks 表 + +Revision ID: 005_add_event_module +Revises: 004_add_people_module +Create Date: 2026-02-22 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "005_add_event_module" +down_revision = "004_add_people_module" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "event_templates", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("community_id", sa.Integer, sa.ForeignKey("communities.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("event_type", sa.String(20), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("is_public", sa.Boolean, server_default="0"), + sa.Column("created_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_event_templates_community_id", "event_templates", ["community_id"]) + + op.create_table( + "checklist_template_items", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("template_id", sa.Integer, sa.ForeignKey("event_templates.id", ondelete="CASCADE"), nullable=False), + sa.Column("phase", sa.String(10), nullable=False), + sa.Column("title", sa.String(300), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("order", sa.Integer, server_default="0"), + ) + op.create_index("ix_checklist_template_items_template_id", "checklist_template_items", ["template_id"]) + + op.create_table( + "events", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("community_id", sa.Integer, sa.ForeignKey("communities.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(300), nullable=False), + sa.Column("event_type", sa.String(20), nullable=False, server_default="offline"), + sa.Column("template_id", sa.Integer, sa.ForeignKey("event_templates.id", ondelete="SET NULL"), nullable=True), + sa.Column("status", sa.String(20), server_default="draft"), + sa.Column("planned_at", sa.DateTime, nullable=True), + sa.Column("duration_minutes", sa.Integer, nullable=True), + sa.Column("location", sa.String(300), nullable=True), + sa.Column("online_url", sa.String(500), nullable=True), + sa.Column("description", sa.Text, nullable=True), + sa.Column("cover_image_url", sa.String(500), nullable=True), + sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("attendee_count", sa.Integer, nullable=True), + sa.Column("online_count", sa.Integer, nullable=True), + sa.Column("offline_count", sa.Integer, nullable=True), + sa.Column("registration_count", sa.Integer, nullable=True), + sa.Column("result_summary", sa.Text, nullable=True), + sa.Column("media_urls", sa.JSON, nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_events_community_id", "events", ["community_id"]) + + op.create_table( + "checklist_items", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("event_id", sa.Integer, sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("phase", sa.String(10), nullable=False), + sa.Column("title", sa.String(300), nullable=False), + sa.Column("status", sa.String(20), server_default="pending"), + sa.Column("assignee_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("due_date", sa.Date, nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("order", sa.Integer, server_default="0"), + ) + op.create_index("ix_checklist_items_event_id", "checklist_items", ["event_id"]) + + op.create_table( + "event_personnel", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("event_id", sa.Integer, sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("role", sa.String(50), nullable=False), + sa.Column("role_label", sa.String(100), nullable=True), + sa.Column("assignee_type", sa.String(20), nullable=False), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True), + sa.Column("confirmed", sa.String(20), server_default="pending"), + sa.Column("time_slot", sa.String(100), nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("order", sa.Integer, server_default="0"), + ) + op.create_index("ix_event_personnel_event_id", "event_personnel", ["event_id"]) + + op.create_table( + "event_attendees", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("event_id", sa.Integer, sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("checked_in", sa.Boolean, server_default="0"), + sa.Column("role_at_event", sa.String(100), nullable=True), + sa.Column("source", sa.String(20), server_default="manual"), + ) + op.create_index("ix_event_attendees_event_id", "event_attendees", ["event_id"]) + op.create_index("ix_event_attendees_person_id", "event_attendees", ["person_id"]) + + op.create_table( + "feedback_items", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("event_id", sa.Integer, sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("content", sa.Text, nullable=False), + sa.Column("category", sa.String(50), server_default="question"), + sa.Column("raised_by", sa.String(200), nullable=True), + sa.Column("raised_by_person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True), + sa.Column("status", sa.String(20), server_default="open"), + sa.Column("assignee_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("ix_feedback_items_event_id", "feedback_items", ["event_id"]) + + op.create_table( + "issue_links", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("feedback_id", sa.Integer, sa.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False), + sa.Column("platform", sa.String(20), nullable=False), + sa.Column("repo", sa.String(200), nullable=False), + sa.Column("issue_number", sa.Integer, nullable=False), + sa.Column("issue_url", sa.String(500), nullable=False), + sa.Column("issue_type", sa.String(10), server_default="issue"), + sa.Column("issue_status", sa.String(10), server_default="open"), + sa.Column("linked_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("linked_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + ) + op.create_index("ix_issue_links_feedback_id", "issue_links", ["feedback_id"]) + + op.create_table( + "event_tasks", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("event_id", sa.Integer, sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(300), nullable=False), + sa.Column("task_type", sa.String(20), server_default="task"), + sa.Column("phase", sa.String(10), server_default="pre"), + sa.Column("start_date", sa.Date, nullable=True), + sa.Column("end_date", sa.Date, nullable=True), + sa.Column("progress", sa.Integer, server_default="0"), + sa.Column("status", sa.String(20), server_default="not_started"), + sa.Column("depends_on", sa.JSON, nullable=True), + sa.Column("parent_task_id", sa.Integer, sa.ForeignKey("event_tasks.id", ondelete="SET NULL"), nullable=True), + sa.Column("order", sa.Integer, server_default="0"), + ) + op.create_index("ix_event_tasks_event_id", "event_tasks", ["event_id"]) + + +def downgrade() -> None: + op.drop_index("ix_event_tasks_event_id", "event_tasks") + op.drop_table("event_tasks") + op.drop_index("ix_issue_links_feedback_id", "issue_links") + op.drop_table("issue_links") + op.drop_index("ix_feedback_items_event_id", "feedback_items") + op.drop_table("feedback_items") + op.drop_index("ix_event_attendees_person_id", "event_attendees") + op.drop_index("ix_event_attendees_event_id", "event_attendees") + op.drop_table("event_attendees") + op.drop_index("ix_event_personnel_event_id", "event_personnel") + op.drop_table("event_personnel") + op.drop_index("ix_checklist_items_event_id", "checklist_items") + op.drop_table("checklist_items") + op.drop_index("ix_events_community_id", "events") + op.drop_table("events") + op.drop_index("ix_checklist_template_items_template_id", "checklist_template_items") + op.drop_table("checklist_template_items") + op.drop_index("ix_event_templates_community_id", "event_templates") + op.drop_table("event_templates") diff --git a/backend/alembic/versions/006_link_committee_member_to_person.py b/backend/alembic/versions/006_link_committee_member_to_person.py new file mode 100644 index 0000000..d598a07 --- /dev/null +++ b/backend/alembic/versions/006_link_committee_member_to_person.py @@ -0,0 +1,35 @@ +"""CommitteeMember 关联 PersonProfile + +Revision ID: 006_link_committee_member_to_person +Revises: 005_add_event_module +Create Date: 2026-02-22 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "006_link_committee_member_to_person" +down_revision = "005_add_event_module" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("committee_members") as batch_op: + batch_op.add_column( + sa.Column("person_id", sa.Integer, nullable=True) + ) + batch_op.create_foreign_key( + "fk_committee_members_person_id", + "person_profiles", + ["person_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index("ix_committee_members_person_id", ["person_id"]) + + +def downgrade() -> None: + with op.batch_alter_table("committee_members") as batch_op: + batch_op.drop_index("ix_committee_members_person_id") + batch_op.drop_column("person_id") diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index 5b6f42e..5f7f5f3 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -2,7 +2,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session -from app.core.dependencies import get_community_admin, get_current_community, get_current_user +from app.core.dependencies import get_current_admin_or_superuser, get_current_community, get_current_user from app.core.logging import get_logger from app.database import get_db from app.models import User @@ -119,7 +119,7 @@ def update_channel_settings( channel: str, data: ChannelConfigUpdate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_admin_or_superuser), db: Session = Depends(get_db), ): """ diff --git a/backend/app/api/committees.py b/backend/app/api/committees.py index d227615..4fadbe3 100644 --- a/backend/app/api/committees.py +++ b/backend/app/api/committees.py @@ -7,8 +7,8 @@ from sqlalchemy.orm import Session from app.core.dependencies import ( - get_community_admin, get_current_community, + get_current_user, ) from app.database import get_db from app.models import Committee, CommitteeMember, User @@ -76,7 +76,7 @@ def list_committees( def create_committee( data: CommitteeCreate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """创建委员会(需要社区管理员权限)。""" @@ -123,7 +123,7 @@ def update_committee( committee_id: int, data: CommitteeUpdate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """更新委员会信息(需要社区管理员权限)。""" @@ -142,7 +142,7 @@ def update_committee( def delete_committee( committee_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """删除委员会(需要社区管理员权限,级联删除成员和会议)。""" @@ -187,7 +187,7 @@ def add_member( committee_id: int, data: CommitteeMemberCreate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """添加委员会成员(需要社区管理员权限)。""" @@ -209,7 +209,7 @@ def add_member( def export_members_csv( committee_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """导出委员会成员为CSV文件(需要社区管理员权限)。""" @@ -262,7 +262,7 @@ def import_members_csv( committee_id: int, file: UploadFile = File(...), community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """从CSV文件批量导入委员会成员(需要社区管理员权限)。 @@ -438,7 +438,7 @@ def update_member( member_id: int, data: CommitteeMemberUpdate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """更新成员信息(需要社区管理员权限)。""" @@ -475,7 +475,7 @@ def remove_member( committee_id: int, member_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """移除委员会成员(需要社区管理员权限)。""" diff --git a/backend/app/api/communities.py b/backend/app/api/communities.py index 660a0f8..b00c1b0 100644 --- a/backend/app/api/communities.py +++ b/backend/app/api/communities.py @@ -4,8 +4,8 @@ from sqlalchemy.orm import Session, attributes from app.core.dependencies import ( - get_community_admin, get_current_active_superuser, + get_current_admin_or_superuser, get_current_user, get_user_community_role, ) @@ -203,7 +203,7 @@ class CommunityBasicUpdate(BaseModel): def update_community_basic( community_id: int, data: CommunityBasicUpdate, - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_admin_or_superuser), db: Session = Depends(get_db), ): """ diff --git a/backend/app/api/contents.py b/backend/app/api/contents.py index 4099fbb..1a942f7 100644 --- a/backend/app/api/contents.py +++ b/backend/app/api/contents.py @@ -1,11 +1,14 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import insert, or_ +from sqlalchemy import select as sa_select from sqlalchemy.orm import Session from app.core.dependencies import check_content_edit_permission, get_current_community, get_current_user from app.database import get_db from app.models import Content, User +from app.models.content import content_communities from app.schemas.content import ( ContentCalendarOut, ContentCreate, @@ -23,6 +26,46 @@ VALID_SOURCE_TYPES = {"contribution", "release_note", "event_summary"} +def _build_community_filter(community_id: int): + """返回社区内容过滤条件(OR 逻辑,兼容遷移期间)。 + + 同时匹配: + 1. 已写入 content_communities 的多社区关联记录 + 2. 仍使用旧 community_id 列的内容(过渡兼容) + """ + linked_subq = sa_select(content_communities.c.content_id).where( + content_communities.c.community_id == community_id + ) + return or_( + Content.id.in_(linked_subq), + Content.community_id == community_id, + ) + + +def _get_content_community_ids(db: Session, content_id: int) -> list[int]: + """获取内容关联的所有社区 ID 列表。""" + rows = db.query(content_communities.c.community_id).filter( + content_communities.c.content_id == content_id + ).all() + return [r[0] for r in rows] + + +def _write_content_communities(db: Session, content_id: int, community_ids: list[int], linked_by_id: int | None) -> None: + """向 content_communities 写入关联行(幂等,已存在则跳过)。""" + existing = {r[0] for r in db.query(content_communities.c.community_id).filter( + content_communities.c.content_id == content_id + ).all()} + is_first = len(existing) == 0 + for i, cid in enumerate(community_ids): + if cid not in existing: + db.execute(insert(content_communities).values( + content_id=content_id, + community_id=cid, + is_primary=(is_first and i == 0), + linked_by_id=linked_by_id, + )) + + @router.get("", response_model=PaginatedContents) def list_contents( page: int = Query(1, ge=1), @@ -34,8 +77,8 @@ def list_contents( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - # Filter by community - query = db.query(Content).filter(Content.community_id == community_id) + # 通过 content_communities 关联表过滤(兼容遷移期间的 community_id 列) + query = db.query(Content).filter(_build_community_filter(community_id)) if status: query = query.filter(Content.status == status) @@ -77,6 +120,10 @@ def create_content( db.add(content) db.flush() # Get content ID + # 写入多社区关联(未指定则关联当前社区) + target_community_ids = data.community_ids if data.community_ids else [community_id] + _write_content_communities(db, content.id, target_community_ids, current_user.id) + # Assign assignees (default to creator if empty) — batch query to avoid N+1 assignee_ids = data.assignee_ids if data.assignee_ids else [current_user.id] assignee_users = db.query(User).filter(User.id.in_(assignee_ids)).all() @@ -85,7 +132,12 @@ def create_content( db.commit() db.refresh(content) - return content + content_dict = { + **{c.name: getattr(content, c.name) for c in content.__table__.columns}, + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content.id), + } + return content_dict @router.get("/{content_id}", response_model=ContentOut) @@ -97,15 +149,15 @@ def get_content( ): content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") - # Build response dict with assignee_ids content_dict = { **{c.name: getattr(content, c.name) for c in content.__table__.columns}, - "assignee_ids": [a.id for a in content.assignees] + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content_id), } return content_dict @@ -120,7 +172,7 @@ def update_content( ): content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -131,11 +183,30 @@ def update_content( update_data = data.model_dump(exclude_unset=True) + # Handle community_ids update (replace all associations for this content) + if "community_ids" in update_data: + new_community_ids = update_data.pop("community_ids") + if new_community_ids is not None: + db.execute( + content_communities.delete().where( + content_communities.c.content_id == content_id + ) + ) + for i, cid in enumerate(new_community_ids): + db.execute(insert(content_communities).values( + content_id=content_id, + community_id=cid, + is_primary=(i == 0), + linked_by_id=current_user.id, + )) + # 同步更新 community_id 为主社区(过渡兼容) + if new_community_ids: + content.community_id = new_community_ids[0] + # Handle assignees update if "assignee_ids" in update_data: assignee_ids = update_data.pop("assignee_ids") content.assignees.clear() - # Batch query to avoid N+1 when updating assignees if assignee_ids: assignee_users = db.query(User).filter(User.id.in_(assignee_ids)).all() for user in assignee_users: @@ -147,7 +218,12 @@ def update_content( setattr(content, key, value) db.commit() db.refresh(content) - return content + content_dict = { + **{c.name: getattr(content, c.name) for c in content.__table__.columns}, + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content_id), + } + return content_dict @router.delete("/{content_id}", status_code=204) @@ -159,7 +235,7 @@ def delete_content( ): content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -184,7 +260,7 @@ def update_content_status( raise HTTPException(400, f"Invalid status, must be one of {VALID_STATUSES}") content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -196,7 +272,12 @@ def update_content_status( content.status = data.status db.commit() db.refresh(content) - return content + content_dict = { + **{c.name: getattr(content, c.name) for c in content.__table__.columns}, + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content_id), + } + return content_dict # Collaborators Management Endpoints @@ -213,7 +294,7 @@ def list_collaborators( """ content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -242,7 +323,7 @@ def add_collaborator( """ content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -283,7 +364,7 @@ def remove_collaborator( """ content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -317,7 +398,7 @@ def transfer_ownership( """ content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -338,7 +419,12 @@ def transfer_ownership( db.commit() db.refresh(content) - return content + content_dict = { + **{c.name: getattr(content, c.name) for c in content.__table__.columns}, + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content_id), + } + return content_dict # ==================== Calendar API Endpoints ==================== @@ -364,7 +450,7 @@ def list_calendar_events( except ValueError: raise HTTPException(400, "Invalid date format. Use ISO format (e.g. 2026-02-01)") from None - query = db.query(Content).filter(Content.community_id == community_id) + query = db.query(Content).filter(_build_community_filter(community_id)) if status: query = query.filter(Content.status == status) @@ -405,7 +491,7 @@ def update_content_schedule( """ content = db.query(Content).filter( Content.id == content_id, - Content.community_id == community_id, + _build_community_filter(community_id), ).first() if not content: raise HTTPException(404, "Content not found") @@ -417,4 +503,9 @@ def update_content_schedule( content.scheduled_publish_at = data.scheduled_publish_at db.commit() db.refresh(content) - return content + content_dict = { + **{c.name: getattr(content, c.name) for c in content.__table__.columns}, + "assignee_ids": [a.id for a in content.assignees], + "community_ids": _get_content_community_ids(db, content_id), + } + return content_dict diff --git a/backend/app/api/event_templates.py b/backend/app/api/event_templates.py new file mode 100644 index 0000000..9772345 --- /dev/null +++ b/backend/app/api/event_templates.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_community, get_current_user +from app.database import get_db +from app.models import User +from app.models.event import ChecklistTemplateItem, EventTemplate +from app.schemas.event import EventTemplateCreate, EventTemplateListOut, EventTemplateOut, EventTemplateUpdate + +router = APIRouter() + + +@router.get("", response_model=list[EventTemplateListOut]) +def list_templates( + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + return ( + db.query(EventTemplate) + .filter( + (EventTemplate.community_id == community_id) | (EventTemplate.is_public == True) # noqa: E712 + ) + .order_by(EventTemplate.created_at.desc()) + .all() + ) + + +@router.post("", response_model=EventTemplateOut, status_code=201) +def create_template( + data: EventTemplateCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = EventTemplate( + community_id=community_id, + name=data.name, + event_type=data.event_type, + description=data.description, + is_public=data.is_public, + created_by_id=current_user.id, + ) + db.add(template) + db.flush() + + for item_data in data.checklist_items: + item = ChecklistTemplateItem(template_id=template.id, **item_data.model_dump()) + db.add(item) + + db.commit() + db.refresh(template) + return template + + +@router.get("/{template_id}", response_model=EventTemplateOut) +def get_template( + template_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() + if not template: + raise HTTPException(404, "模板不存在") + if template.community_id != community_id and not template.is_public: + raise HTTPException(403, "无权访问此模板") + return template + + +@router.patch("/{template_id}", response_model=EventTemplateOut) +def update_template( + template_id: int, + data: EventTemplateUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter( + EventTemplate.id == template_id, + EventTemplate.community_id == community_id, + ).first() + if not template: + raise HTTPException(404, "模板不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(template, key, value) + db.commit() + db.refresh(template) + return template diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..ccdca53 --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,492 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_community, get_current_user +from app.database import get_db +from app.models import User +from app.models.event import ( + ChecklistItem, + Event, + EventAttendee, + EventPersonnel, + EventTask, + EventTemplate, + FeedbackItem, + IssueLink, +) +from app.schemas.event import ( + ChecklistItemOut, + ChecklistItemUpdate, + EventCreate, + EventOut, + EventPersonnelCreate, + EventPersonnelOut, + EventStatusUpdate, + EventTaskCreate, + EventTaskOut, + EventTaskUpdate, + EventUpdate, + FeedbackCreate, + FeedbackOut, + FeedbackStatusUpdate, + IssueLinkCreate, + IssueLinkOut, + PaginatedEvents, + PersonnelConfirmUpdate, + TaskReorderRequest, +) + +router = APIRouter() + +VALID_EVENT_STATUSES = {"draft", "planning", "ongoing", "completed", "cancelled"} +VALID_EVENT_TYPES = {"online", "offline", "hybrid"} + + +# ─── Event CRUD ─────────────────────────────────────────────────────────────── + +@router.get("", response_model=PaginatedEvents) +def list_events( + status: str | None = None, + event_type: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + query = db.query(Event).filter(Event.community_id == community_id) + if status: + query = query.filter(Event.status == status) + if event_type: + query = query.filter(Event.event_type == event_type) + total = query.count() + items = query.order_by(Event.planned_at.desc().nullslast()).offset((page - 1) * page_size).limit(page_size).all() + return PaginatedEvents(items=items, total=total, page=page, page_size=page_size) + + +@router.post("", response_model=EventOut, status_code=201) +def create_event( + data: EventCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if data.event_type not in VALID_EVENT_TYPES: + raise HTTPException(400, f"event_type 必须为 {VALID_EVENT_TYPES}") + + event = Event( + community_id=community_id, + owner_id=current_user.id, + **data.model_dump(), + ) + db.add(event) + db.flush() + + # 如果指定了模板,从模板复制 checklist + if data.template_id: + template = db.query(EventTemplate).filter(EventTemplate.id == data.template_id).first() + if template: + for titem in template.checklist_items: + db.add(ChecklistItem( + event_id=event.id, + phase=titem.phase, + title=titem.title, + order=titem.order, + )) + + db.commit() + db.refresh(event) + return event + + +@router.get("/{event_id}", response_model=EventOut) +def get_event( + event_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + return event + + +@router.patch("/{event_id}", response_model=EventOut) +def update_event( + event_id: int, + data: EventUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(event, key, value) + db.commit() + db.refresh(event) + return event + + +@router.patch("/{event_id}/status", response_model=EventOut) +def update_event_status( + event_id: int, + data: EventStatusUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if data.status not in VALID_EVENT_STATUSES: + raise HTTPException(400, f"status 必须为 {VALID_EVENT_STATUSES}") + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + event.status = data.status + db.commit() + db.refresh(event) + return event + + +# ─── Checklist ──────────────────────────────────────────────────────────────── + +@router.get("/{event_id}/checklist", response_model=list[ChecklistItemOut]) +def get_checklist( + event_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + return sorted(event.checklist_items, key=lambda x: (x.phase, x.order)) + + +@router.patch("/{event_id}/checklist/{item_id}", response_model=ChecklistItemOut) +def update_checklist_item( + event_id: int, + item_id: int, + data: ChecklistItemUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + item = db.query(ChecklistItem).filter( + ChecklistItem.id == item_id, ChecklistItem.event_id == event_id + ).first() + if not item: + raise HTTPException(404, "检查项不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(item, key, value) + db.commit() + db.refresh(item) + return item + + +# ─── Personnel ──────────────────────────────────────────────────────────────── + +@router.get("/{event_id}/personnel", response_model=list[EventPersonnelOut]) +def list_personnel( + event_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + return sorted(event.personnel, key=lambda x: x.order) + + +@router.post("/{event_id}/personnel", response_model=EventPersonnelOut, status_code=201) +def add_personnel( + event_id: int, + data: EventPersonnelCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + person = EventPersonnel(event_id=event_id, **data.model_dump()) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@router.patch("/{event_id}/personnel/{pid}/confirm", response_model=EventPersonnelOut) +def confirm_personnel( + event_id: int, + pid: int, + data: PersonnelConfirmUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(EventPersonnel).filter( + EventPersonnel.id == pid, EventPersonnel.event_id == event_id + ).first() + if not person: + raise HTTPException(404, "人员记录不存在") + person.confirmed = data.confirmed + db.commit() + db.refresh(person) + return person + + +# ─── Attendees Import ───────────────────────────────────────────────────────── + +@router.post("/{event_id}/attendees/import", status_code=200) +def import_attendees( + event_id: int, + rows: list[dict], + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """批量导入签到名单。每行需包含 person_id 字段(已确认匹配的 PersonProfile ID)。""" + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + + created = 0 + skipped = 0 + for row in rows: + person_id = row.get("person_id") + if not person_id: + skipped += 1 + continue + existing = db.query(EventAttendee).filter( + EventAttendee.event_id == event_id, + EventAttendee.person_id == person_id, + ).first() + if existing: + skipped += 1 + continue + db.add(EventAttendee( + event_id=event_id, + person_id=person_id, + checked_in=row.get("checked_in", False), + role_at_event=row.get("role_at_event"), + source="excel_import", + )) + created += 1 + + db.commit() + return {"created": created, "skipped": skipped} + + +# ─── Feedback ───────────────────────────────────────────────────────────────── + +@router.get("/{event_id}/feedback", response_model=list[FeedbackOut]) +def list_feedback( + event_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + return event.feedback_items + + +@router.post("/{event_id}/feedback", response_model=FeedbackOut, status_code=201) +def create_feedback( + event_id: int, + data: FeedbackCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + feedback = FeedbackItem(event_id=event_id, **data.model_dump()) + db.add(feedback) + db.commit() + db.refresh(feedback) + return feedback + + +@router.patch("/{event_id}/feedback/{fid}", response_model=FeedbackOut) +def update_feedback( + event_id: int, + fid: int, + data: FeedbackStatusUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + feedback = db.query(FeedbackItem).filter( + FeedbackItem.id == fid, FeedbackItem.event_id == event_id + ).first() + if not feedback: + raise HTTPException(404, "反馈记录不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(feedback, key, value) + db.commit() + db.refresh(feedback) + return feedback + + +@router.post("/{event_id}/feedback/{fid}/links", response_model=IssueLinkOut, status_code=201) +def link_issue( + event_id: int, + fid: int, + data: IssueLinkCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + feedback = db.query(FeedbackItem).filter( + FeedbackItem.id == fid, FeedbackItem.event_id == event_id + ).first() + if not feedback: + raise HTTPException(404, "反馈记录不存在") + link = IssueLink( + feedback_id=fid, + linked_by_id=current_user.id, + **data.model_dump(), + ) + db.add(link) + db.commit() + db.refresh(link) + return link + + +# ─── Event Tasks (甘特图) ────────────────────────────────────────────────────── + +def _build_task_tree(tasks: list[EventTask]) -> list[EventTask]: + """将平铺任务列表组装为父子树形结构(parent_task_id 分层)。""" + task_map = {t.id: t for t in tasks} + roots: list[EventTask] = [] + for task in tasks: + task.children = [] + for task in tasks: + if task.parent_task_id and task.parent_task_id in task_map: + task_map[task.parent_task_id].children.append(task) + else: + roots.append(task) + return roots + + +@router.get("/{event_id}/tasks", response_model=list[EventTaskOut]) +def list_tasks( + event_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + tasks = db.query(EventTask).filter(EventTask.event_id == event_id).order_by(EventTask.order).all() + return _build_task_tree(tasks) + + +@router.post("/{event_id}/tasks", response_model=EventTaskOut, status_code=201) +def create_task( + event_id: int, + data: EventTaskCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + task = EventTask(event_id=event_id, **data.model_dump()) + db.add(task) + db.commit() + db.refresh(task) + task.children = [] + return task + + +@router.patch("/{event_id}/tasks/{tid}", response_model=EventTaskOut) +def update_task( + event_id: int, + tid: int, + data: EventTaskUpdate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + task = db.query(EventTask).filter( + EventTask.id == tid, EventTask.event_id == event_id + ).first() + if not task: + raise HTTPException(404, "任务不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(task, key, value) + db.commit() + db.refresh(task) + task.children = [] + return task + + +@router.delete("/{event_id}/tasks/{tid}", status_code=204) +def delete_task( + event_id: int, + tid: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + task = db.query(EventTask).filter( + EventTask.id == tid, EventTask.event_id == event_id + ).first() + if not task: + raise HTTPException(404, "任务不存在") + db.delete(task) + db.commit() + + +@router.patch("/{event_id}/tasks/reorder", status_code=200) +def reorder_tasks( + event_id: int, + data: TaskReorderRequest, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter( + Event.id == event_id, Event.community_id == community_id + ).first() + if not event: + raise HTTPException(404, "活动不存在") + for item in data.tasks: + task = db.query(EventTask).filter( + EventTask.id == item.task_id, EventTask.event_id == event_id + ).first() + if task: + task.order = item.order + db.commit() + return {"ok": True} diff --git a/backend/app/api/meetings.py b/backend/app/api/meetings.py index 4920525..30f3c75 100644 --- a/backend/app/api/meetings.py +++ b/backend/app/api/meetings.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session, joinedload -from app.core.dependencies import get_community_admin, get_current_community +from app.core.dependencies import get_current_community, get_current_user from app.database import get_db from app.models import User from app.models.committee import Committee, CommitteeMember @@ -72,7 +72,7 @@ def list_meetings( def create_meeting( data: MeetingCreate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ @@ -184,7 +184,7 @@ def update_meeting( meeting_id: int, data: MeetingUpdate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ @@ -246,7 +246,7 @@ def update_meeting( def delete_meeting( meeting_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ @@ -276,7 +276,7 @@ def create_reminder( meeting_id: int, reminder_data: MeetingReminderCreate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ @@ -411,7 +411,7 @@ def add_participant( meeting_id: int, data: MeetingParticipantCreate, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """手动添加会议与会人。""" @@ -456,7 +456,7 @@ def delete_participant( meeting_id: int, participant_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """删除会议与会人。""" @@ -487,7 +487,7 @@ def delete_participant( def import_participants( meeting_id: int, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """从委员会成员导入会议与会人。""" @@ -583,7 +583,7 @@ def update_meeting_minutes( meeting_id: int, minutes_data: dict, community_id: int = Depends(get_current_community), - current_user: User = Depends(get_community_admin), + current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """ diff --git a/backend/app/api/people.py b/backend/app/api/people.py new file mode 100644 index 0000000..75dad39 --- /dev/null +++ b/backend/app/api/people.py @@ -0,0 +1,194 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_user +from app.database import get_db +from app.models import User +from app.models.people import CommunityRole, PersonProfile +from app.schemas.people import ( + CommunityRoleCreate, + CommunityRoleOut, + MergeConfirm, + PaginatedPeople, + PersonCreate, + PersonOut, + PersonUpdate, +) +from app.services.people_service import find_or_suggest + +router = APIRouter() + + +@router.get("", response_model=PaginatedPeople) +def list_people( + q: str | None = None, + tag: str | None = None, + company: str | None = None, + source: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + query = db.query(PersonProfile) + if q: + query = query.filter( + PersonProfile.display_name.ilike(f"%{q}%") + | PersonProfile.email.ilike(f"%{q}%") + | PersonProfile.github_handle.ilike(f"%{q}%") + ) + if tag: + query = query.filter(PersonProfile.tags.contains([tag])) + if company: + query = query.filter(PersonProfile.company.ilike(f"%{company}%")) + if source: + query = query.filter(PersonProfile.source == source) + total = query.count() + items = query.order_by(PersonProfile.display_name).offset((page - 1) * page_size).limit(page_size).all() + return PaginatedPeople(items=items, total=total, page=page, page_size=page_size) + + +@router.post("", response_model=PersonOut, status_code=201) +def create_person( + data: PersonCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + # 检查唯一字段冲突 + if data.github_handle: + if db.query(PersonProfile).filter(PersonProfile.github_handle == data.github_handle).first(): + raise HTTPException(400, "该 GitHub 账号已存在") + if data.email: + if db.query(PersonProfile).filter(PersonProfile.email == data.email).first(): + raise HTTPException(400, "该邮箱已存在") + person = PersonProfile(**data.model_dump(), created_by_id=current_user.id) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@router.get("/{person_id}", response_model=PersonOut) +def get_person( + person_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(PersonProfile).filter(PersonProfile.id == person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + return person + + +@router.patch("/{person_id}", response_model=PersonOut) +def update_person( + person_id: int, + data: PersonUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(PersonProfile).filter(PersonProfile.id == person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(person, key, value) + db.commit() + db.refresh(person) + return person + + +@router.delete("/{person_id}", status_code=204) +def delete_person( + person_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(PersonProfile).filter(PersonProfile.id == person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + db.delete(person) + db.commit() + + +@router.get("/{person_id}/roles", response_model=list[CommunityRoleOut]) +def list_roles( + person_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(PersonProfile).filter(PersonProfile.id == person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + return person.community_roles + + +@router.post("/{person_id}/roles", response_model=CommunityRoleOut, status_code=201) +def add_role( + person_id: int, + data: CommunityRoleCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + person = db.query(PersonProfile).filter(PersonProfile.id == person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + role = CommunityRole(**data.model_dump(), person_id=person_id, updated_by_id=current_user.id) + db.add(role) + db.commit() + db.refresh(role) + return role + + +@router.post("/import", status_code=200) +def import_people( + rows: list[dict], + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """批量导入人脉(Excel/CSV 解析后的行数据列表)。 + + 返回每行的匹配结果,status 为 matched/suggest/new。 + """ + results = [] + for row in rows: + match_result = find_or_suggest(db, row) + results.append({"row": row, **match_result}) + return {"results": results, "total": len(results)} + + +@router.post("/confirm-merge", status_code=200) +def confirm_merge( + data: MergeConfirm, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """确认/拒绝去重匹配结果。 + + - confirmed=True + person_id: 将导入行关联到已有 PersonProfile + - confirmed=True + person_id=None: 创建新 PersonProfile + - confirmed=False: 跳过此行 + """ + if not data.confirmed: + return {"action": "skipped"} + + if data.person_id is not None: + person = db.query(PersonProfile).filter(PersonProfile.id == data.person_id).first() + if not person: + raise HTTPException(404, "人脉档案不存在") + return {"action": "linked", "person_id": person.id} + + # 创建新档案 + row = data.import_row + person = PersonProfile( + display_name=row.get("display_name", ""), + github_handle=row.get("github_handle") or None, + email=row.get("email") or None, + company=row.get("company") or None, + source="event_import", + created_by_id=current_user.id, + ) + db.add(person) + db.commit() + db.refresh(person) + return {"action": "created", "person_id": person.id} diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 0bf06ec..3382212 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -162,6 +162,44 @@ def get_user_community_role( return result +async def get_current_admin_or_superuser( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> User: + """ + Dependency to verify user is a platform admin or superuser. + + Checks if the user is a superuser OR has 'admin' role in any community. + Used for platform-level management operations (community settings, analytics config, etc.) + + Args: + current_user: Current authenticated user + db: Database session + + Returns: + User: The admin/superuser user + + Raises: + HTTPException: If user does not have admin or superuser permissions + """ + if current_user.is_superuser: + return current_user + + stmt = select(community_users.c.role).where( + community_users.c.user_id == current_user.id, + community_users.c.role == "admin", + ).limit(1) + result = db.execute(stmt).scalar() + + if not result: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="管理员权限不足", + ) + + return current_user + + async def get_community_admin( x_community_id: int | None = Header(None), user: User = Depends(get_current_user), @@ -170,6 +208,10 @@ async def get_community_admin( """ Dependency to verify user is a community admin or superuser. + Deprecated: Use get_current_admin_or_superuser for platform-level checks, + or get_current_user for business operations (committees, meetings, etc.) + kept for backward compatibility during the transition period. + Args: x_community_id: Community ID from header user: Current authenticated user diff --git a/backend/app/main.py b/backend/app/main.py index b965295..05dae85 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from contextlib import asynccontextmanager +from apscheduler.schedulers.background import BackgroundScheduler from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -19,7 +20,10 @@ community_dashboard, contents, dashboard, + event_templates, + events, meetings, + people, publish, upload, wechat_stats, @@ -28,17 +32,40 @@ from app.core.logging import get_logger, setup_logging from app.core.rate_limit import limiter from app.database import init_db +from app.services.issue_sync import run_issue_sync # 初始化日志系统 setup_logging() logger = get_logger(__name__) +_scheduler = BackgroundScheduler() + + @asynccontextmanager async def lifespan(app: FastAPI): logger.info("openGecko 服务启动", extra={"app": settings.APP_NAME, "debug": settings.DEBUG}) init_db() + + # 每日 02:00 同步 GitHub Issue 状态(测试环境下跳过) + try: + _scheduler.add_job( + run_issue_sync, + trigger="cron", + hour=2, + minute=0, + id="issue_sync", + replace_existing=True, + ) + _scheduler.start() + logger.info("APScheduler 已启动") + except Exception as exc: + logger.warning("APScheduler 未启动: %s", exc) + yield + + if _scheduler.running: + _scheduler.shutdown(wait=False) logger.info("openGecko 服务关闭") @@ -145,6 +172,9 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(meetings.router, prefix="/api/meetings", tags=["Governance"]) app.include_router(community_dashboard.router, prefix="/api/communities", tags=["Community Dashboard"]) app.include_router(wechat_stats.router, prefix="/api/wechat-stats", tags=["WeChat Statistics"]) +app.include_router(people.router, prefix="/api/people", tags=["People"]) +app.include_router(events.router, prefix="/api/events", tags=["Events"]) +app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5621080..ce5d551 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,8 +3,20 @@ from app.models.committee import Committee, CommitteeMember from app.models.community import Community from app.models.content import Content +from app.models.event import ( + ChecklistItem, + ChecklistTemplateItem, + Event, + EventAttendee, + EventPersonnel, + EventTask, + EventTemplate, + FeedbackItem, + IssueLink, +) from app.models.meeting import Meeting, MeetingParticipant, MeetingReminder from app.models.password_reset import PasswordResetToken +from app.models.people import CommunityRole, PersonProfile from app.models.publish_record import PublishRecord from app.models.user import User, community_users from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate @@ -25,4 +37,15 @@ "MeetingParticipant", "WechatArticleStat", "WechatStatsAggregate", + "PersonProfile", + "CommunityRole", + "Event", + "EventTemplate", + "ChecklistTemplateItem", + "ChecklistItem", + "EventPersonnel", + "EventAttendee", + "EventTask", + "FeedbackItem", + "IssueLink", ] diff --git a/backend/app/models/committee.py b/backend/app/models/committee.py index 8308ae5..d99a9ea 100644 --- a/backend/app/models/committee.py +++ b/backend/app/models/committee.py @@ -95,5 +95,11 @@ class CommitteeMember(Base): created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + # 关联人脉档案(可为空,表示尚未与 PersonProfile 匹配) + person_id = Column( + Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True, index=True + ) + # Relationships committee = relationship("Committee", back_populates="members") + person = relationship("PersonProfile", foreign_keys=[person_id]) diff --git a/backend/app/models/community.py b/backend/app/models/community.py index 670fb82..bbecad2 100644 --- a/backend/app/models/community.py +++ b/backend/app/models/community.py @@ -28,6 +28,11 @@ class Community(Base): back_populates="communities", ) contents = relationship("Content", back_populates="community", cascade="all, delete-orphan") + linked_contents = relationship( + "Content", + secondary="content_communities", + back_populates="communities", + ) channel_configs = relationship("ChannelConfig", back_populates="community", cascade="all, delete-orphan") audit_logs = relationship("AuditLog", back_populates="community", cascade="all, delete-orphan") committees = relationship("Committee", back_populates="community", cascade="all, delete-orphan") diff --git a/backend/app/models/content.py b/backend/app/models/content.py index 682c279..b9eb5d9 100644 --- a/backend/app/models/content.py +++ b/backend/app/models/content.py @@ -1,11 +1,23 @@ from datetime import datetime -from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Table, Text +from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text from sqlalchemy import Enum as SAEnum from sqlalchemy.orm import relationship from app.database import Base +# Association table for content → community (multi-community support) +content_communities = Table( + "content_communities", + Base.metadata, + Column("id", Integer, primary_key=True), + Column("content_id", Integer, ForeignKey("contents.id", ondelete="CASCADE"), nullable=False, index=True), + Column("community_id", Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True), + Column("is_primary", Boolean, server_default="1"), + Column("linked_at", DateTime, default=datetime.utcnow), + Column("linked_by_id", Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True), +) + # Association table for content collaborators content_collaborators = Table( "content_collaborators", @@ -64,6 +76,11 @@ class Content(Base): publish_records = relationship("PublishRecord", back_populates="content", cascade="all, delete-orphan") community = relationship("Community", back_populates="contents") + communities = relationship( + "Community", + secondary="content_communities", + back_populates="linked_contents", + ) creator = relationship("User", foreign_keys=[created_by_user_id], back_populates="created_contents") owner = relationship("User", foreign_keys=[owner_id], back_populates="owned_contents") collaborators = relationship( diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..d1ec3aa --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,258 @@ +from datetime import datetime + +from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship + +from app.database import Base + + +class EventTemplate(Base): + """活动 SOP 模板""" + __tablename__ = "event_templates" + + id = Column(Integer, primary_key=True) + community_id = Column( + Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True + ) + name = Column(String(200), nullable=False) + event_type = Column( + SAEnum("online", "offline", "hybrid", name="event_type_enum"), + nullable=False, + ) + description = Column(Text, nullable=True) + is_public = Column(Boolean, default=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + checklist_items = relationship( + "ChecklistTemplateItem", back_populates="template", cascade="all, delete-orphan" + ) + events = relationship("Event", back_populates="template") + + +class ChecklistTemplateItem(Base): + """SOP 模板检查项""" + __tablename__ = "checklist_template_items" + + id = Column(Integer, primary_key=True) + template_id = Column( + Integer, ForeignKey("event_templates.id", ondelete="CASCADE"), nullable=False, index=True + ) + phase = Column( + SAEnum("pre", "during", "post", name="checklist_phase_enum"), nullable=False + ) + title = Column(String(300), nullable=False) + description = Column(Text, nullable=True) + order = Column(Integer, default=0) + + template = relationship("EventTemplate", back_populates="checklist_items") + + +class Event(Base): + """活动""" + __tablename__ = "events" + + id = Column(Integer, primary_key=True) + community_id = Column( + Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True + ) + title = Column(String(300), nullable=False) + event_type = Column( + SAEnum("online", "offline", "hybrid", name="event_type_enum2"), + nullable=False, + default="offline", + ) + template_id = Column( + Integer, ForeignKey("event_templates.id", ondelete="SET NULL"), nullable=True + ) + status = Column( + SAEnum("draft", "planning", "ongoing", "completed", "cancelled", name="event_status_enum"), + default="draft", + ) + planned_at = Column(DateTime, nullable=True) + duration_minutes = Column(Integer, nullable=True) + location = Column(String(300), nullable=True) + online_url = Column(String(500), nullable=True) + description = Column(Text, nullable=True) + cover_image_url = Column(String(500), nullable=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + # 活动结果内嵌字段 + attendee_count = Column(Integer, nullable=True) + online_count = Column(Integer, nullable=True) + offline_count = Column(Integer, nullable=True) + registration_count = Column(Integer, nullable=True) + result_summary = Column(Text, nullable=True) + media_urls = Column(JSON, default=list) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + template = relationship("EventTemplate", back_populates="events") + checklist_items = relationship( + "ChecklistItem", back_populates="event", cascade="all, delete-orphan" + ) + personnel = relationship( + "EventPersonnel", back_populates="event", cascade="all, delete-orphan" + ) + attendees = relationship( + "EventAttendee", back_populates="event", cascade="all, delete-orphan" + ) + feedback_items = relationship( + "FeedbackItem", back_populates="event", cascade="all, delete-orphan" + ) + tasks = relationship("EventTask", back_populates="event", cascade="all, delete-orphan") + + +class ChecklistItem(Base): + """活动实例检查项""" + __tablename__ = "checklist_items" + + id = Column(Integer, primary_key=True) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True + ) + phase = Column( + SAEnum("pre", "during", "post", name="checklist_item_phase_enum"), nullable=False + ) + title = Column(String(300), nullable=False) + status = Column( + SAEnum("pending", "done", "skipped", name="checklist_status_enum"), default="pending" + ) + assignee_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + due_date = Column(Date, nullable=True) + notes = Column(Text, nullable=True) + order = Column(Integer, default=0) + + event = relationship("Event", back_populates="checklist_items") + + +class EventPersonnel(Base): + """活动人员安排""" + __tablename__ = "event_personnel" + + id = Column(Integer, primary_key=True) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True + ) + role = Column(String(50), nullable=False) + role_label = Column(String(100), nullable=True) + assignee_type = Column( + SAEnum("internal", "external", name="personnel_assignee_enum"), nullable=False + ) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + person_id = Column( + Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True + ) + confirmed = Column( + SAEnum("pending", "confirmed", "declined", name="personnel_confirm_enum"), + default="pending", + ) + time_slot = Column(String(100), nullable=True) + notes = Column(Text, nullable=True) + order = Column(Integer, default=0) + + event = relationship("Event", back_populates="personnel") + + +class EventAttendee(Base): + """活动签到记录""" + __tablename__ = "event_attendees" + + id = Column(Integer, primary_key=True) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True + ) + person_id = Column( + Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + checked_in = Column(Boolean, default=False) + role_at_event = Column(String(100), nullable=True) + source = Column( + SAEnum("manual", "excel_import", name="attendee_source_enum"), default="manual" + ) + + event = relationship("Event", back_populates="attendees") + person = relationship("PersonProfile", back_populates="event_attendances") + + +class FeedbackItem(Base): + """活动反馈/问题记录""" + __tablename__ = "feedback_items" + + id = Column(Integer, primary_key=True) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True + ) + content = Column(Text, nullable=False) + category = Column(String(50), default="question") + raised_by = Column(String(200), nullable=True) + raised_by_person_id = Column( + Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True + ) + status = Column( + SAEnum("open", "in_progress", "closed", name="feedback_status_enum"), default="open" + ) + assignee_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + event = relationship("Event", back_populates="feedback_items") + issue_links = relationship("IssueLink", back_populates="feedback", cascade="all, delete-orphan") + + +class IssueLink(Base): + """反馈问题关联的 Issue/PR""" + __tablename__ = "issue_links" + + id = Column(Integer, primary_key=True) + feedback_id = Column( + Integer, ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False, index=True + ) + platform = Column( + SAEnum("github", "gitcode", "gitee", name="issue_platform_enum"), nullable=False + ) + repo = Column(String(200), nullable=False) + issue_number = Column(Integer, nullable=False) + issue_url = Column(String(500), nullable=False) + issue_type = Column( + SAEnum("issue", "pr", name="issue_type_enum"), default="issue" + ) + issue_status = Column( + SAEnum("open", "closed", name="issue_status_enum"), default="open" + ) + linked_at = Column(DateTime, default=datetime.utcnow) + linked_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + feedback = relationship("FeedbackItem", back_populates="issue_links") + + +class EventTask(Base): + """活动任务/里程碑(甘特图数据)""" + __tablename__ = "event_tasks" + + id = Column(Integer, primary_key=True) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True + ) + title = Column(String(300), nullable=False) + task_type = Column( + SAEnum("task", "milestone", name="task_type_enum"), default="task" + ) + phase = Column( + SAEnum("pre", "during", "post", name="task_phase_enum"), default="pre" + ) + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + progress = Column(Integer, default=0) + status = Column( + SAEnum("not_started", "in_progress", "completed", "blocked", name="task_status_enum"), + default="not_started", + ) + depends_on = Column(JSON, default=list) # list[int] task IDs + parent_task_id = Column( + Integer, ForeignKey("event_tasks.id", ondelete="SET NULL"), nullable=True + ) + order = Column(Integer, default=0) + + event = relationship("Event", back_populates="tasks") diff --git a/backend/app/models/people.py b/backend/app/models/people.py new file mode 100644 index 0000000..b9103d4 --- /dev/null +++ b/backend/app/models/people.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship + +from app.database import Base + + +class PersonProfile(Base): + """社区人脉档案(独立于系统 User,覆盖外部参与者)""" + __tablename__ = "person_profiles" + + id = Column(Integer, primary_key=True, index=True) + display_name = Column(String(200), nullable=False, index=True) + avatar_url = Column(String(500), nullable=True) + github_handle = Column(String(100), nullable=True, unique=True, index=True) + gitcode_handle = Column(String(100), nullable=True, unique=True, index=True) + email = Column(String(200), nullable=True, unique=True, index=True) + phone = Column(String(50), nullable=True) + company = Column(String(200), nullable=True, index=True) + location = Column(String(200), nullable=True) + bio = Column(Text, nullable=True) + tags = Column(JSON, default=list) + notes = Column(Text, nullable=True) + source = Column( + SAEnum("manual", "event_import", "ecosystem_import", name="person_source_enum"), + nullable=False, + default="manual", + ) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + community_roles = relationship("CommunityRole", back_populates="person", cascade="all, delete-orphan") + event_attendances = relationship("EventAttendee", back_populates="person") + + +class CommunityRole(Base): + """人脉的社区身份记录""" + __tablename__ = "community_roles" + + id = Column(Integer, primary_key=True, index=True) + person_id = Column( + Integer, + ForeignKey("person_profiles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + community_name = Column(String(200), nullable=False) + project_url = Column(String(500), nullable=True) + role = Column(String(100), nullable=False) + role_label = Column(String(100), nullable=True) + is_current = Column(Boolean, default=True) + started_at = Column(Date, nullable=True) + ended_at = Column(Date, nullable=True) + source_url = Column(String(500), nullable=True) + updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + person = relationship("PersonProfile", back_populates="community_roles") diff --git a/backend/app/schemas/content.py b/backend/app/schemas/content.py index 81263ad..51812d1 100644 --- a/backend/app/schemas/content.py +++ b/backend/app/schemas/content.py @@ -15,6 +15,8 @@ class ContentCreate(BaseModel): scheduled_publish_at: datetime | None = None work_status: str = "planning" assignee_ids: list[int] = [] + # 多社区关联:不填则默认使用 X-Community-Id header 对应的社区 + community_ids: list[int] = [] class ContentUpdate(BaseModel): @@ -29,6 +31,8 @@ class ContentUpdate(BaseModel): scheduled_publish_at: datetime | None = None work_status: str | None = None assignee_ids: list[int] | None = None + # 多社区关联:提供则替换全部关联;不提供则不变 + community_ids: list[int] | None = None class ContentStatusUpdate(BaseModel): @@ -55,6 +59,8 @@ class ContentOut(BaseModel): created_at: datetime updated_at: datetime assignee_ids: list[int] = [] + # 多社区关联列表(由 API 层手动填充) + community_ids: list[int] = [] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py new file mode 100644 index 0000000..7a8e3a8 --- /dev/null +++ b/backend/app/schemas/event.py @@ -0,0 +1,306 @@ +from datetime import date, datetime + +from pydantic import BaseModel + +# ─── Event Template ─────────────────────────────────────────────────────────── + +class ChecklistTemplateItemCreate(BaseModel): + phase: str # pre / during / post + title: str + description: str | None = None + order: int = 0 + + +class ChecklistTemplateItemOut(BaseModel): + id: int + phase: str + title: str + description: str | None + order: int + + model_config = {"from_attributes": True} + + +class EventTemplateCreate(BaseModel): + name: str + event_type: str # online / offline / hybrid + description: str | None = None + is_public: bool = False + checklist_items: list[ChecklistTemplateItemCreate] = [] + + +class EventTemplateUpdate(BaseModel): + name: str | None = None + event_type: str | None = None + description: str | None = None + is_public: bool | None = None + + +class EventTemplateOut(BaseModel): + id: int + community_id: int + name: str + event_type: str + description: str | None + is_public: bool + created_by_id: int | None + created_at: datetime + checklist_items: list[ChecklistTemplateItemOut] = [] + + model_config = {"from_attributes": True} + + +class EventTemplateListOut(BaseModel): + id: int + name: str + event_type: str + is_public: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Event ──────────────────────────────────────────────────────────────────── + +class EventCreate(BaseModel): + title: str + event_type: str = "offline" + template_id: int | None = None + planned_at: datetime | None = None + duration_minutes: int | None = None + location: str | None = None + online_url: str | None = None + description: str | None = None + cover_image_url: str | None = None + + +class EventUpdate(BaseModel): + title: str | None = None + event_type: str | None = None + planned_at: datetime | None = None + duration_minutes: int | None = None + location: str | None = None + online_url: str | None = None + description: str | None = None + cover_image_url: str | None = None + # 结果字段 + attendee_count: int | None = None + online_count: int | None = None + offline_count: int | None = None + registration_count: int | None = None + result_summary: str | None = None + media_urls: list[str] | None = None + + +class EventStatusUpdate(BaseModel): + status: str # draft / planning / ongoing / completed / cancelled + + +class EventOut(BaseModel): + id: int + community_id: int + title: str + event_type: str + template_id: int | None + status: str + planned_at: datetime | None + duration_minutes: int | None + location: str | None + online_url: str | None + description: str | None + cover_image_url: str | None + owner_id: int | None + attendee_count: int | None + online_count: int | None + offline_count: int | None + registration_count: int | None + result_summary: str | None + media_urls: list[str] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class EventListOut(BaseModel): + id: int + title: str + event_type: str + status: str + planned_at: datetime | None + location: str | None + owner_id: int | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class PaginatedEvents(BaseModel): + items: list[EventListOut] + total: int + page: int + page_size: int + + +# ─── Checklist Item ─────────────────────────────────────────────────────────── + +class ChecklistItemOut(BaseModel): + id: int + phase: str + title: str + status: str + assignee_id: int | None + due_date: date | None + notes: str | None + order: int + + model_config = {"from_attributes": True} + + +class ChecklistItemUpdate(BaseModel): + status: str | None = None + assignee_id: int | None = None + due_date: date | None = None + notes: str | None = None + + +# ─── Event Personnel ────────────────────────────────────────────────────────── + +class EventPersonnelCreate(BaseModel): + role: str + role_label: str | None = None + assignee_type: str # internal / external + user_id: int | None = None + person_id: int | None = None + time_slot: str | None = None + notes: str | None = None + order: int = 0 + + +class EventPersonnelOut(BaseModel): + id: int + role: str + role_label: str | None + assignee_type: str + user_id: int | None + person_id: int | None + confirmed: str + time_slot: str | None + notes: str | None + order: int + + model_config = {"from_attributes": True} + + +class PersonnelConfirmUpdate(BaseModel): + confirmed: str # pending / confirmed / declined + + +# ─── Feedback & Issue ───────────────────────────────────────────────────────── + +class FeedbackCreate(BaseModel): + content: str + category: str = "question" + raised_by: str | None = None + raised_by_person_id: int | None = None + assignee_id: int | None = None + + +class FeedbackStatusUpdate(BaseModel): + status: str # open / in_progress / closed + assignee_id: int | None = None + + +class IssueLinkCreate(BaseModel): + platform: str # github / gitcode / gitee + repo: str + issue_number: int + issue_url: str + issue_type: str = "issue" + issue_status: str = "open" + + +class IssueLinkOut(BaseModel): + id: int + platform: str + repo: str + issue_number: int + issue_url: str + issue_type: str + issue_status: str + linked_at: datetime + linked_by_id: int | None + + model_config = {"from_attributes": True} + + +class FeedbackOut(BaseModel): + id: int + content: str + category: str + raised_by: str | None + raised_by_person_id: int | None + status: str + assignee_id: int | None + created_at: datetime + issue_links: list[IssueLinkOut] = [] + + model_config = {"from_attributes": True} + + +# ─── Event Task (甘特图) ────────────────────────────────────────────────────── + +class EventTaskCreate(BaseModel): + title: str + task_type: str = "task" # task / milestone + phase: str = "pre" # pre / during / post + start_date: date | None = None + end_date: date | None = None + progress: int = 0 + status: str = "not_started" + depends_on: list[int] = [] + parent_task_id: int | None = None + order: int = 0 + + +class EventTaskUpdate(BaseModel): + title: str | None = None + task_type: str | None = None + phase: str | None = None + start_date: date | None = None + end_date: date | None = None + progress: int | None = None + status: str | None = None + depends_on: list[int] | None = None + parent_task_id: int | None = None + order: int | None = None + + +class EventTaskOut(BaseModel): + id: int + event_id: int + title: str + task_type: str + phase: str + start_date: date | None + end_date: date | None + progress: int + status: str + depends_on: list[int] + parent_task_id: int | None + order: int + children: list["EventTaskOut"] = [] + + model_config = {"from_attributes": True} + + +EventTaskOut.model_rebuild() + + +class TaskReorder(BaseModel): + task_id: int + order: int + + +class TaskReorderRequest(BaseModel): + tasks: list[TaskReorder] diff --git a/backend/app/schemas/people.py b/backend/app/schemas/people.py new file mode 100644 index 0000000..fa75107 --- /dev/null +++ b/backend/app/schemas/people.py @@ -0,0 +1,106 @@ +from datetime import date, datetime + +from pydantic import BaseModel + + +class CommunityRoleCreate(BaseModel): + community_name: str + project_url: str | None = None + role: str + role_label: str | None = None + is_current: bool = True + started_at: date | None = None + ended_at: date | None = None + source_url: str | None = None + + +class CommunityRoleOut(BaseModel): + id: int + community_name: str + project_url: str | None + role: str + role_label: str | None + is_current: bool + started_at: date | None + ended_at: date | None + source_url: str | None + + model_config = {"from_attributes": True} + + +class PersonCreate(BaseModel): + display_name: str + avatar_url: str | None = None + github_handle: str | None = None + gitcode_handle: str | None = None + email: str | None = None + phone: str | None = None + company: str | None = None + location: str | None = None + bio: str | None = None + tags: list[str] = [] + notes: str | None = None + source: str = "manual" + + +class PersonUpdate(BaseModel): + display_name: str | None = None + avatar_url: str | None = None + github_handle: str | None = None + gitcode_handle: str | None = None + email: str | None = None + phone: str | None = None + company: str | None = None + location: str | None = None + bio: str | None = None + tags: list[str] | None = None + notes: str | None = None + + +class PersonOut(BaseModel): + id: int + display_name: str + avatar_url: str | None + github_handle: str | None + gitcode_handle: str | None + email: str | None + phone: str | None + company: str | None + location: str | None + bio: str | None + tags: list[str] + notes: str | None + source: str + created_by_id: int | None + created_at: datetime + updated_at: datetime + community_roles: list[CommunityRoleOut] = [] + + model_config = {"from_attributes": True} + + +class PersonListOut(BaseModel): + id: int + display_name: str + avatar_url: str | None + github_handle: str | None + email: str | None + company: str | None + source: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class PaginatedPeople(BaseModel): + items: list[PersonListOut] + total: int + page: int + page_size: int + + +class MergeConfirm(BaseModel): + """确认/拒绝去重匹配""" + import_row: dict + person_id: int | None # None 表示创建新档案 + confirmed: bool diff --git a/backend/app/services/issue_sync.py b/backend/app/services/issue_sync.py new file mode 100644 index 0000000..df2dc9d --- /dev/null +++ b/backend/app/services/issue_sync.py @@ -0,0 +1,67 @@ +"""GitHub / Gitee / GitCode Issue 状态同步服务。 + +每日定时(APScheduler BackgroundScheduler)调用 run_issue_sync(), +对所有 IssueLink 记录发起 API 请求更新 issue_status 字段。 +""" + +import logging + +import httpx +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models.event import IssueLink + +logger = logging.getLogger(__name__) + +# GitHub API base +GITHUB_API = "https://api.github.com" + + +def _fetch_github_issue_status_sync(repo: str, issue_number: int, token: str | None = None) -> str | None: + """同步方式获取 GitHub Issue 状态(open / closed)。""" + headers = {"Accept": "application/vnd.github+json"} + if token: + headers["Authorization"] = f"Bearer {token}" + url = f"{GITHUB_API}/repos/{repo}/issues/{issue_number}" + try: + with httpx.Client(timeout=10) as client: + resp = client.get(url, headers=headers) + if resp.status_code == 200: + return resp.json().get("state", "open") + logger.warning("GitHub API %s → %s", url, resp.status_code) + except Exception as exc: + logger.error("Failed to fetch GitHub issue %s#%s: %s", repo, issue_number, exc) + return None + + +def run_issue_sync(github_token: str | None = None) -> dict: + """同步入口:遍历所有 IssueLink,刷新 issue_status。 + + 只处理 platform='github' 的记录;其他平台预留扩展。 + 由 APScheduler BackgroundScheduler 在后台线程中调用。 + 返回 {"updated": int, "skipped": int, "errors": int}。 + """ + db: Session = SessionLocal() + updated = skipped = errors = 0 + try: + links = db.query(IssueLink).filter(IssueLink.platform == "github").all() + for link in links: + new_status = _fetch_github_issue_status_sync(link.repo, link.issue_number, github_token) + if new_status is None: + errors += 1 + continue + if new_status != link.issue_status: + link.issue_status = new_status + updated += 1 + else: + skipped += 1 + db.commit() + except Exception as exc: + logger.error("Issue sync failed: %s", exc) + db.rollback() + finally: + db.close() + + logger.info("Issue sync done — updated=%s skipped=%s errors=%s", updated, skipped, errors) + return {"updated": updated, "skipped": skipped, "errors": errors} diff --git a/backend/app/services/people_service.py b/backend/app/services/people_service.py new file mode 100644 index 0000000..4872862 --- /dev/null +++ b/backend/app/services/people_service.py @@ -0,0 +1,71 @@ +from difflib import SequenceMatcher + +from sqlalchemy.orm import Session + +from app.models.people import PersonProfile + + +def find_or_suggest(db: Session, row: dict) -> dict: + """对导入的单行记录执行去重匹配。 + + 匹配优先级: + 1. github_handle 精确匹配 → matched + 2. email 精确匹配 → suggest (reason: email) + 3. 姓名 + 公司模糊匹配 (ratio > 0.70) → suggest (reason: name+company) + 4. 均未匹配 → new + + Returns: + { + "status": "matched" | "suggest" | "new", + "person_id": int | None, # matched 时有值 + "candidates": list[dict], # suggest 时有值 + } + """ + github = row.get("github_handle", "").strip().lower() + email = row.get("email", "").strip().lower() + name = row.get("display_name", "").strip() + company = row.get("company", "").strip() + + # 1. github_handle 精确匹配 + if github: + p = db.query(PersonProfile).filter(PersonProfile.github_handle == github).first() + if p: + return {"status": "matched", "person_id": p.id, "candidates": []} + + # 2. email 精确匹配 → 疑似(可能同名不同人,需人工确认) + if email: + p = db.query(PersonProfile).filter(PersonProfile.email == email).first() + if p: + return { + "status": "suggest", + "person_id": None, + "candidates": [{"id": p.id, "display_name": p.display_name, "reason": "email"}], + } + + # 3. 姓名 + 公司模糊匹配 + if name: + candidates = [] + query_str = f"{name}|{company}" + for p in ( + db.query(PersonProfile) + .filter(PersonProfile.display_name.ilike(f"%{name[:4]}%")) + .limit(50) + ): + target_str = f"{p.display_name}|{p.company or ''}" + ratio = SequenceMatcher(None, query_str, target_str).ratio() + if ratio > 0.70: + candidates.append({ + "id": p.id, + "display_name": p.display_name, + "company": p.company, + "ratio": round(ratio, 2), + "reason": "name+company", + }) + if candidates: + return { + "status": "suggest", + "person_id": None, + "candidates": sorted(candidates, key=lambda x: -x["ratio"]), + } + + return {"status": "new", "person_id": None, "candidates": []} diff --git a/backend/requirements.txt b/backend/requirements.txt index a8f796f..822ad84 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,3 +24,4 @@ bcrypt>=4.0,<4.1 python-jose[cryptography]==3.3.0 email-validator>=2.0 cryptography>=42.0 +apscheduler==3.10.4 diff --git a/backend/tests/test_event_templates_api.py b/backend/tests/test_event_templates_api.py new file mode 100644 index 0000000..a884eb3 --- /dev/null +++ b/backend/tests/test_event_templates_api.py @@ -0,0 +1,176 @@ +"""Event Templates API 测试""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.community import Community +from app.models.event import EventTemplate +from app.models.user import User + + +def _create_template( + db_session: Session, + community_id: int, + name: str = "测试模板", + event_type: str = "online", + is_public: bool = False, +) -> EventTemplate: + t = EventTemplate( + community_id=community_id, + name=name, + event_type=event_type, + is_public=is_public, + ) + db_session.add(t) + db_session.commit() + db_session.refresh(t) + return t + + +class TestListEventTemplates: + def test_list_empty(self, client: TestClient, auth_headers: dict): + resp = client.get("/api/event-templates", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_returns_community_templates( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + _create_template(db_session, test_community.id, name="私有模板") + resp = client.get("/api/event-templates", headers=auth_headers) + assert resp.status_code == 200 + assert any(t["name"] == "私有模板" for t in resp.json()) + + def test_list_includes_public_templates( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_another_community: Community, + ): + """公开模板对所有社区可见""" + _create_template(db_session, test_another_community.id, name="公开模板", is_public=True) + resp = client.get("/api/event-templates", headers=auth_headers) + assert resp.status_code == 200 + names = [t["name"] for t in resp.json()] + assert "公开模板" in names + + +class TestCreateEventTemplate: + def test_create_template_no_checklist( + self, + client: TestClient, + auth_headers: dict, + ): + payload = { + "name": "新活动模板", + "event_type": "offline", + "description": "线下活动模板", + "is_public": False, + "checklist_items": [], + } + resp = client.post("/api/event-templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "新活动模板" + assert data["event_type"] == "offline" + assert data["checklist_items"] == [] + + def test_create_template_with_checklist_items( + self, + client: TestClient, + auth_headers: dict, + ): + payload = { + "name": "带清单模板", + "event_type": "hybrid", + "checklist_items": [ + {"phase": "pre", "title": "准备会场", "order": 1}, + {"phase": "during", "title": "签到", "order": 2}, + ], + } + resp = client.post("/api/event-templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert len(data["checklist_items"]) == 2 + phases = {item["phase"] for item in data["checklist_items"]} + assert "pre" in phases + assert "during" in phases + + +class TestGetEventTemplate: + def test_get_existing_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + t = _create_template(db_session, test_community.id) + resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["id"] == t.id + + def test_get_public_template_from_other_community( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_another_community: Community, + ): + t = _create_template(db_session, test_another_community.id, is_public=True) + resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 200 + + def test_get_private_template_from_other_community_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_another_community: Community, + ): + t = _create_template(db_session, test_another_community.id, is_public=False) + resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 403 + + def test_get_nonexistent_template( + self, + client: TestClient, + auth_headers: dict, + ): + resp = client.get("/api/event-templates/99999", headers=auth_headers) + assert resp.status_code == 404 + + +class TestUpdateEventTemplate: + def test_update_template_name( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + t = _create_template(db_session, test_community.id, name="旧名称") + resp = client.patch( + f"/api/event-templates/{t.id}", + json={"name": "新名称"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "新名称" + + def test_update_nonexistent_template( + self, + client: TestClient, + auth_headers: dict, + ): + resp = client.patch( + "/api/event-templates/99999", + json={"name": "不存在"}, + headers=auth_headers, + ) + assert resp.status_code == 404 diff --git a/backend/tests/test_issue_sync.py b/backend/tests/test_issue_sync.py new file mode 100644 index 0000000..e7a34de --- /dev/null +++ b/backend/tests/test_issue_sync.py @@ -0,0 +1,193 @@ +"""Issue sync service 单元测试""" +from unittest.mock import MagicMock, patch + +from app.services.issue_sync import _fetch_github_issue_status_sync, run_issue_sync + + +class TestFetchGithubIssueStatusSync: + """测试 _fetch_github_issue_status_sync 同步 HTTP 函数""" + + def test_returns_open_status(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"state": "open"} + + mock_client = MagicMock() + mock_client.get.return_value = mock_resp + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + + with patch("app.services.issue_sync.httpx.Client", return_value=mock_client): + result = _fetch_github_issue_status_sync("owner/repo", 42) + + assert result == "open" + + def test_returns_closed_status(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"state": "closed"} + + mock_client = MagicMock() + mock_client.get.return_value = mock_resp + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + + with patch("app.services.issue_sync.httpx.Client", return_value=mock_client): + result = _fetch_github_issue_status_sync("owner/repo", 1, token="mytoken") + + assert result == "closed" + + def test_non_200_returns_none(self): + mock_resp = MagicMock() + mock_resp.status_code = 404 + + mock_client = MagicMock() + mock_client.get.return_value = mock_resp + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + + with patch("app.services.issue_sync.httpx.Client", return_value=mock_client): + result = _fetch_github_issue_status_sync("owner/repo", 99) + + assert result is None + + def test_http_exception_returns_none(self): + mock_client = MagicMock() + mock_client.get.side_effect = Exception("network error") + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + + with patch("app.services.issue_sync.httpx.Client", return_value=mock_client): + result = _fetch_github_issue_status_sync("owner/repo", 5) + + assert result is None + + def test_missing_state_field_defaults_to_open(self): + """GitHub API 返回 200 但 JSON 中没有 state 字段,默认 open""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} # no 'state' key + + mock_client = MagicMock() + mock_client.get.return_value = mock_resp + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + + with patch("app.services.issue_sync.httpx.Client", return_value=mock_client): + result = _fetch_github_issue_status_sync("owner/repo", 7) + + assert result == "open" + + +class TestRunIssueSync: + """测试 run_issue_sync 主逻辑""" + + def _make_link(self, repo: str, issue_number: int, platform: str = "github", status: str = "open"): + link = MagicMock() + link.repo = repo + link.issue_number = issue_number + link.platform = platform + link.issue_status = status + return link + + def test_no_links_returns_zeros(self): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [] + + with patch("app.services.issue_sync.SessionLocal", return_value=mock_db): + result = run_issue_sync() + + assert result == {"updated": 0, "skipped": 0, "errors": 0} + mock_db.commit.assert_called_once() + mock_db.close.assert_called_once() + + def test_updated_when_status_changes(self): + link = self._make_link("owner/repo", 10, status="open") + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [link] + + with ( + patch("app.services.issue_sync.SessionLocal", return_value=mock_db), + patch( + "app.services.issue_sync._fetch_github_issue_status_sync", + return_value="closed", + ), + ): + result = run_issue_sync(github_token="token") + + assert result["updated"] == 1 + assert result["skipped"] == 0 + assert result["errors"] == 0 + assert link.issue_status == "closed" + + def test_skipped_when_status_unchanged(self): + link = self._make_link("owner/repo", 11, status="open") + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [link] + + with ( + patch("app.services.issue_sync.SessionLocal", return_value=mock_db), + patch( + "app.services.issue_sync._fetch_github_issue_status_sync", + return_value="open", # 与现有状态相同 + ), + ): + result = run_issue_sync() + + assert result["skipped"] == 1 + assert result["updated"] == 0 + assert result["errors"] == 0 + + def test_errors_when_fetch_returns_none(self): + link = self._make_link("owner/repo", 12) + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [link] + + with ( + patch("app.services.issue_sync.SessionLocal", return_value=mock_db), + patch( + "app.services.issue_sync._fetch_github_issue_status_sync", + return_value=None, + ), + ): + result = run_issue_sync() + + assert result["errors"] == 1 + assert result["updated"] == 0 + + def test_mixed_results(self): + """一次同步:1 个更新 + 1 个跳过 + 1 个错误""" + links = [ + self._make_link("r/r1", 1, status="open"), # → closed: updated + self._make_link("r/r2", 2, status="closed"), # → closed: skipped + self._make_link("r/r3", 3, status="open"), # → None: error + ] + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = links + + fetch_side_effects = ["closed", "closed", None] + + with ( + patch("app.services.issue_sync.SessionLocal", return_value=mock_db), + patch( + "app.services.issue_sync._fetch_github_issue_status_sync", + side_effect=fetch_side_effects, + ), + ): + result = run_issue_sync() + + assert result["updated"] == 1 + assert result["skipped"] == 1 + assert result["errors"] == 1 + + def test_db_exception_triggers_rollback(self): + """db.query 抛出异常时 rollback,且不抛出""" + mock_db = MagicMock() + mock_db.query.side_effect = Exception("db error") + + with patch("app.services.issue_sync.SessionLocal", return_value=mock_db): + result = run_issue_sync() + + mock_db.rollback.assert_called_once() + mock_db.close.assert_called_once() + assert result == {"updated": 0, "skipped": 0, "errors": 0} diff --git a/backend/tests/test_people_service.py b/backend/tests/test_people_service.py new file mode 100644 index 0000000..f16b265 --- /dev/null +++ b/backend/tests/test_people_service.py @@ -0,0 +1,144 @@ +"""人员 service 单元测试 — find_or_suggest()""" +from unittest.mock import MagicMock + +from app.services.people_service import find_or_suggest + + +def _mock_person(pid: int = 1, display_name: str = "张三", github_handle: str = "zhangsan", company: str = "OpenCom"): + p = MagicMock() + p.id = pid + p.display_name = display_name + p.github_handle = github_handle + p.company = company + return p + + +class TestFindOrSuggestGithubMatch: + """优先级 1:github_handle 精确匹配 → matched""" + + def test_github_matched(self): + person = _mock_person() + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = person + + result = find_or_suggest(db, {"github_handle": "zhangsan"}) + + assert result["status"] == "matched" + assert result["person_id"] == 1 + assert result["candidates"] == [] + + def test_github_case_insensitive(self): + """行输入大写,但匹配仍能找到""" + person = _mock_person() + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = person + + result = find_or_suggest(db, {"github_handle": "ZhangSan"}) + + assert result["status"] == "matched" + + +class TestFindOrSuggestEmailMatch: + """优先级 2:email 精确匹配 → suggest""" + + def test_email_matched(self): + person = _mock_person() + db = MagicMock() + # github query → None;email query → person + db.query.return_value.filter.return_value.first.side_effect = [None, person] + + result = find_or_suggest(db, {"github_handle": "no_match", "email": "test@example.com"}) + + assert result["status"] == "suggest" + assert result["person_id"] is None + assert len(result["candidates"]) == 1 + assert result["candidates"][0]["reason"] == "email" + + def test_email_matched_no_github(self): + """没有 github_handle 字段时,直接走 email 分支""" + person = _mock_person() + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = person + + result = find_or_suggest(db, {"email": "test@example.com"}) + + assert result["status"] == "suggest" + assert result["candidates"][0]["reason"] == "email" + + +class TestFindOrSuggestFuzzyName: + """优先级 3:姓名+公司模糊匹配 → suggest""" + + def test_name_fuzzy_high_ratio(self): + """ratio > 0.70 → suggest""" + person = MagicMock() + person.id = 2 + person.display_name = "张三" + person.company = "OpenCom" + + db = MagicMock() + # github / email 均无,所以不调用 first() + # fuzzy 分支使用 .filter().limit() 迭代 + db.query.return_value.filter.return_value.limit.return_value = [person] + + result = find_or_suggest(db, {"display_name": "张三", "company": "OpenCom"}) + + assert result["status"] == "suggest" + assert len(result["candidates"]) >= 1 + assert result["candidates"][0]["reason"] == "name+company" + + def test_name_fuzzy_low_ratio_returns_new(self): + """ratio <= 0.70 不进候选列表 → new""" + person = MagicMock() + person.id = 3 + person.display_name = "完全不同的姓名" + person.company = "完全不同的公司" + + db = MagicMock() + db.query.return_value.filter.return_value.limit.return_value = [person] + + result = find_or_suggest(db, {"display_name": "张三", "company": "OpenCom"}) + + # 因为 ratio 很低,不进候选列表,最终 → new + assert result["status"] == "new" + + def test_name_fuzzy_empty_candidates_returns_new(self): + """候选列表为空 → new""" + db = MagicMock() + db.query.return_value.filter.return_value.limit.return_value = [] + + result = find_or_suggest(db, {"display_name": "张三"}) + + assert result["status"] == "new" + + +class TestFindOrSuggestNew: + """优先级 4:均无匹配 → new""" + + def test_empty_row_returns_new(self): + db = MagicMock() + result = find_or_suggest(db, {}) + assert result["status"] == "new" + assert result["person_id"] is None + assert result["candidates"] == [] + + def test_no_match_returns_new(self): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + db.query.return_value.filter.return_value.limit.return_value = [] + + result = find_or_suggest( + db, + {"github_handle": "ghost", "email": "ghost@test.com", "display_name": "幽灵"}, + ) + assert result["status"] == "new" + + def test_github_no_match_email_no_match_returns_new(self): + db = MagicMock() + db.query.return_value.filter.return_value.first.side_effect = [None, None] + db.query.return_value.filter.return_value.limit.return_value = [] + + result = find_or_suggest( + db, {"github_handle": "nobody", "email": "nobody@none.com"} + ) + assert result["status"] == "new" diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 0f60c14..5d1229f 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -559,3 +559,63 @@ def test_convert_empty_markdown(self): from app.services.converter import convert_markdown_to_html result = convert_markdown_to_html("") assert result == "" or isinstance(result, str) + + def test_convert_docx_to_markdown_basic(self): + """mock mammoth,验证 docx→markdown 主流程""" + import os + import tempfile + from unittest.mock import MagicMock, patch + + from app.services.converter import convert_docx_to_markdown + + # 创建一个临时假 docx 文件 + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as f: + f.write(b"fake docx content") + tmp_path = f.name + + mock_result = MagicMock() + mock_result.value = "

Hello World

Some text

" + + try: + with patch("app.services.converter.mammoth.convert_to_html", return_value=mock_result), \ + patch("app.services.converter.mammoth.images.img_element") as mock_img: + mock_img.return_value = lambda fn: fn + md, images = convert_docx_to_markdown(tmp_path) + + assert "Hello World" in md + assert isinstance(images, list) + finally: + os.unlink(tmp_path) + + def test_convert_docx_to_markdown_with_image(self): + """mock mammoth + 模拟图片回调,验证图片路径被收集""" + import os + import tempfile + from io import BytesIO + from unittest.mock import MagicMock, patch, call + + from app.services.converter import convert_docx_to_markdown + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as f: + f.write(b"fake") + tmp_path = f.name + + captured_convert_fn = {} + + def fake_img_element(fn): + captured_convert_fn["fn"] = fn + return fn + + mock_result = MagicMock() + mock_result.value = "

text

" + + try: + with patch("app.services.converter.mammoth.convert_to_html", return_value=mock_result), \ + patch("app.services.converter.mammoth.images.img_element", side_effect=fake_img_element), \ + patch("app.services.converter.os.makedirs"): + md, images = convert_docx_to_markdown(tmp_path) + + assert isinstance(md, str) + assert isinstance(images, list) + finally: + os.unlink(tmp_path) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba092f2..a0f02f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "clipboard-copy": "^4.0.1", "echarts": "^6.0.0", "element-plus": "^2.9.1", + "frappe-gantt": "^0.6.1", "marked": "^15.0.6", "md-editor-v3": "^5.1.1", "pinia": "^2.3.0", @@ -2570,6 +2571,12 @@ "node": ">= 6" } }, + "node_modules/frappe-gantt": { + "version": "0.6.1", + "resolved": "https://repo.huaweicloud.com/repository/npm/frappe-gantt/-/frappe-gantt-0.6.1.tgz", + "integrity": "sha512-1cSU9vLbwypjzaxnCfnEE03Xr3HlAV2S8dRtjxw62o+amkx1A8bBIFd2jp84mcDdTCM77Ij4LzZBslAKZB8oMg==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://repo.huaweicloud.com/repository/npm/fsevents/-/fsevents-2.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4fa6e0f..14013eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "clipboard-copy": "^4.0.1", "echarts": "^6.0.0", "element-plus": "^2.9.1", + "frappe-gantt": "^0.6.1", "marked": "^15.0.6", "md-editor-v3": "^5.1.1", "pinia": "^2.3.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9b486b1..d325df0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,15 +20,40 @@ text-color="#64748b" active-text-color="#0095ff" > - + 社区工作台 + + - 我的工作 + 个人工作看板 + + + + + + + + + 治理概览 + + + + 委员会 + + + + 会议管理 + + +