From f4aae08b836fb2a3d190684e38a028dd70ba7884 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 12 Apr 2026 18:15:38 +0800 Subject: [PATCH] first commit --- .claude/settings.local.json | 21 + .gitignore | 41 ++ backend/.dockerignore | 10 + backend/.env.example | 33 + backend/Dockerfile | 17 + backend/alembic.ini | 36 + backend/alembic/env.py | 57 ++ backend/alembic/script.py.mako | 26 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/announcements.py | 121 +++ backend/app/api/assignments.py | 276 +++++++ backend/app/api/auth.py | 93 +++ backend/app/api/classes.py | 270 +++++++ backend/app/api/directory.py | 57 ++ backend/app/api/notifications.py | 74 ++ backend/app/api/resources.py | 153 ++++ backend/app/api/schedule.py | 104 +++ backend/app/api/timeline.py | 262 +++++++ backend/app/api/upload.py | 24 + backend/app/api/users.py | 123 ++++ backend/app/api/votes.py | 177 +++++ backend/app/config.py | 43 ++ backend/app/core/__init__.py | 0 backend/app/core/auth.py | 31 + backend/app/core/deps.py | 57 ++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 5 + backend/app/db/database.py | 17 + backend/app/db/models.py | 416 +++++++++++ backend/app/main.py | 99 +++ backend/app/schemas/__init__.py | 0 backend/app/schemas/announcement.py | 27 + backend/app/schemas/assignment.py | 63 ++ backend/app/schemas/auth.py | 19 + backend/app/schemas/class_.py | 27 + backend/app/schemas/common.py | 24 + backend/app/schemas/notification.py | 17 + backend/app/schemas/resource.py | 24 + backend/app/schemas/roster.py | 15 + backend/app/schemas/schedule.py | 35 + backend/app/schemas/timeline.py | 43 ++ backend/app/schemas/user.py | 86 +++ backend/app/schemas/vote.py | 51 ++ backend/app/services/__init__.py | 0 backend/app/services/announcement_service.py | 79 ++ backend/app/services/assignment_service.py | 179 +++++ backend/app/services/class_service.py | 79 ++ backend/app/services/cos_service.py | 75 ++ backend/app/services/directory_service.py | 77 ++ backend/app/services/email_service.py | 95 +++ backend/app/services/notification_service.py | 132 ++++ backend/app/services/resource_service.py | 75 ++ backend/app/services/roster_service.py | 125 ++++ backend/app/services/schedule_service.py | 93 +++ backend/app/services/timeline_service.py | 177 +++++ backend/app/services/user_service.py | 80 ++ backend/app/services/vote_service.py | 141 ++++ backend/requirements.txt | 13 + docker-compose.yml | 42 ++ frontend/.dockerignore | 7 + frontend/.gitignore | 41 ++ frontend/AGENTS.md | 5 + frontend/CLAUDE.md | 1 + frontend/Dockerfile | 34 + frontend/README.md | 36 + frontend/components.json | 25 + frontend/eslint.config.mjs | 18 + frontend/next.config.ts | 8 + frontend/package.json | 35 + frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/(app)/admin/classes/page.tsx | 218 ++++++ frontend/src/app/(app)/admin/members/page.tsx | 549 ++++++++++++++ frontend/src/app/(app)/admin/page.tsx | 49 ++ frontend/src/app/(app)/announcements/page.tsx | 245 +++++++ .../src/app/(app)/assignments/[id]/page.tsx | 586 +++++++++++++++ frontend/src/app/(app)/assignments/page.tsx | 334 +++++++++ frontend/src/app/(app)/dashboard/page.tsx | 238 ++++++ .../src/app/(app)/directory/[id]/page.tsx | 111 +++ frontend/src/app/(app)/directory/page.tsx | 156 ++++ frontend/src/app/(app)/layout.tsx | 25 + frontend/src/app/(app)/profile/page.tsx | 222 ++++++ frontend/src/app/(app)/resources/page.tsx | 293 ++++++++ frontend/src/app/(app)/schedule/page.tsx | 405 +++++++++++ frontend/src/app/(app)/timeline/page.tsx | 688 ++++++++++++++++++ frontend/src/app/(app)/votes/page.tsx | 623 ++++++++++++++++ frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend/src/app/globals.css | 130 ++++ frontend/src/app/layout.tsx | 43 ++ frontend/src/app/login/page.tsx | 84 +++ frontend/src/app/page.tsx | 27 + frontend/src/app/pending/page.tsx | 37 + frontend/src/app/register/page.tsx | 152 ++++ frontend/src/components/auth-guard.tsx | 33 + frontend/src/components/calendar-view.tsx | 190 +++++ frontend/src/components/confirm-dialog.tsx | 57 ++ frontend/src/components/error-state.tsx | 22 + frontend/src/components/header.tsx | 243 +++++++ frontend/src/components/pagination.tsx | 67 ++ frontend/src/components/role-guard.tsx | 16 + frontend/src/components/sidebar.tsx | 127 ++++ frontend/src/components/ui/alert-dialog.tsx | 187 +++++ frontend/src/components/ui/avatar.tsx | 109 +++ frontend/src/components/ui/badge.tsx | 52 ++ frontend/src/components/ui/button.tsx | 58 ++ frontend/src/components/ui/card.tsx | 103 +++ frontend/src/components/ui/dialog.tsx | 160 ++++ frontend/src/components/ui/dropdown-menu.tsx | 268 +++++++ frontend/src/components/ui/input.tsx | 20 + frontend/src/components/ui/label.tsx | 20 + frontend/src/components/ui/popover.tsx | 90 +++ frontend/src/components/ui/select.tsx | 201 +++++ frontend/src/components/ui/separator.tsx | 25 + frontend/src/components/ui/sheet.tsx | 138 ++++ frontend/src/components/ui/skeleton.tsx | 13 + frontend/src/components/ui/sonner.tsx | 49 ++ frontend/src/components/ui/switch.tsx | 32 + frontend/src/components/ui/tabs.tsx | 82 +++ frontend/src/components/ui/textarea.tsx | 18 + frontend/src/hooks/use-active-class.tsx | 102 +++ frontend/src/hooks/use-auth.tsx | 85 +++ frontend/src/hooks/use-notifications.tsx | 94 +++ frontend/src/hooks/use-sidebar.tsx | 31 + frontend/src/lib/api.ts | 97 +++ frontend/src/lib/constants.ts | 33 + frontend/src/lib/types.ts | 217 ++++++ frontend/src/lib/utils.ts | 6 + frontend/tsconfig.json | 34 + nginx/nginx.conf | 29 + 134 files changed, 13081 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/announcements.py create mode 100644 backend/app/api/assignments.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/classes.py create mode 100644 backend/app/api/directory.py create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/api/resources.py create mode 100644 backend/app/api/schedule.py create mode 100644 backend/app/api/timeline.py create mode 100644 backend/app/api/upload.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/api/votes.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/database.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/main.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/announcement.py create mode 100644 backend/app/schemas/assignment.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/class_.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/schemas/resource.py create mode 100644 backend/app/schemas/roster.py create mode 100644 backend/app/schemas/schedule.py create mode 100644 backend/app/schemas/timeline.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/schemas/vote.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/announcement_service.py create mode 100644 backend/app/services/assignment_service.py create mode 100644 backend/app/services/class_service.py create mode 100644 backend/app/services/cos_service.py create mode 100644 backend/app/services/directory_service.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/resource_service.py create mode 100644 backend/app/services/roster_service.py create mode 100644 backend/app/services/schedule_service.py create mode 100644 backend/app/services/timeline_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/app/services/vote_service.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/(app)/admin/classes/page.tsx create mode 100644 frontend/src/app/(app)/admin/members/page.tsx create mode 100644 frontend/src/app/(app)/admin/page.tsx create mode 100644 frontend/src/app/(app)/announcements/page.tsx create mode 100644 frontend/src/app/(app)/assignments/[id]/page.tsx create mode 100644 frontend/src/app/(app)/assignments/page.tsx create mode 100644 frontend/src/app/(app)/dashboard/page.tsx create mode 100644 frontend/src/app/(app)/directory/[id]/page.tsx create mode 100644 frontend/src/app/(app)/directory/page.tsx create mode 100644 frontend/src/app/(app)/layout.tsx create mode 100644 frontend/src/app/(app)/profile/page.tsx create mode 100644 frontend/src/app/(app)/resources/page.tsx create mode 100644 frontend/src/app/(app)/schedule/page.tsx create mode 100644 frontend/src/app/(app)/timeline/page.tsx create mode 100644 frontend/src/app/(app)/votes/page.tsx create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/pending/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/components/auth-guard.tsx create mode 100644 frontend/src/components/calendar-view.tsx create mode 100644 frontend/src/components/confirm-dialog.tsx create mode 100644 frontend/src/components/error-state.tsx create mode 100644 frontend/src/components/header.tsx create mode 100644 frontend/src/components/pagination.tsx create mode 100644 frontend/src/components/role-guard.tsx create mode 100644 frontend/src/components/sidebar.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/hooks/use-active-class.tsx create mode 100644 frontend/src/hooks/use-auth.tsx create mode 100644 frontend/src/hooks/use-notifications.tsx create mode 100644 frontend/src/hooks/use-sidebar.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/constants.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/tsconfig.json create mode 100644 nginx/nginx.conf diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bf5e79e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "permissions": { + "allow": [ + "Bash(# Kill all existing uvicorn processes and restart fresh pkill -f \"\"uvicorn app.main\"\" ; sleep 1 rm -f /Users/aaron/source_code/hku-icb-class/backend/classhub.db source .venv/bin/activate && uvicorn app.main:app --host 127.0.0.1 --port 8001 & sleep 3 echo \"\"=== 1. Health ===\"\" curl -s http://127.0.0.1:8001/api/health)", + "Bash(__NEW_LINE_aa4d9e75e4b994e8__ echo \"=== 2. Get Classes ===\")", + "Bash(__NEW_LINE_43deffae9fb2cfb0__ echo \"=== 3. Register ===\")", + "Bash(__NEW_LINE_efb029665c5ac199__ echo \"=== 4. Login as Admin ===\")", + "Bash(pkill:*)", + "Bash(npx shadcn@latest init:*)", + "Bash(npx shadcn@latest add:*)", + "Bash(npm --prefix /Users/aaron/source_code/hku-icb-class/frontend run build)", + "Bash(/Users/aaron/source_code/hku-icb-class/frontend/node_modules/.bin/tsc --noEmit --project /Users/aaron/source_code/hku-icb-class/frontend/tsconfig.json)", + "Bash(test:*)", + "Bash(/Users/aaron/source_code/hku-icb-class/backend/.venv/bin/python:*)", + "Bash(.venv/bin/python:*)", + "Bash(backend/.venv/bin/pip show:*)", + "Bash(uv run python:*)", + "Bash(grep:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47d36d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +.venv/ +venv/ +env/ + +# Backend +backend/.env +backend/*.db + +# Node +frontend/node_modules/ +frontend/.next/ +frontend/out/ + +# Frontend env +frontend/.env +frontend/.env.local +frontend/.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# TypeScript build cache +frontend/tsconfig.tsbuildinfo + +# Logs +*.log diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b6795e5 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.env +*.db +.git +.gitignore +*.md +alembic/versions/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..367fb5c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,33 @@ +# Service +CH_HOST=0.0.0.0 +CH_PORT=8000 +CH_DEBUG=false + +# Database (Docker 部署时使用 volume 路径) +CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db + +# JWT (务必修改 secret) +CH_JWT_SECRET=change-me-in-production +CH_JWT_EXPIRY_HOURS=72 + +# Tencent COS (对象存储) +CH_COS_SECRET_ID=your-cos-secret-id +CH_COS_SECRET_KEY=your-cos-secret-key +CH_COS_REGION=ap-guangzhou +CH_COS_BUCKET=your-bucket-name +CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com + +# SMTP Email (邮件通知) +CH_SMTP_HOST=smtp.example.com +CH_SMTP_PORT=465 +CH_SMTP_USER=noreply@example.com +CH_SMTP_PASSWORD=your-smtp-password +CH_SMTP_FROM_EMAIL=noreply@example.com +CH_SMTP_FROM_NAME=HKU ICB Class Hub + +# Frontend URL (CORS 用) +CH_FRONTEND_URL=http://your-server-ip + +# Super Admin Seed (首次启动自动创建) +CH_SUPER_ADMIN_EMAIL=admin@example.com +CH_SUPER_ADMIN_PASSWORD=change-me-please diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..613e19f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory for SQLite +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..14d27ab --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite+aiosqlite:///./classhub.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..21a77bf --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,57 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.config import settings +from app.db.base import Base +from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py new file mode 100644 index 0000000..e8a2671 --- /dev/null +++ b/backend/app/api/announcements.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut +from app.schemas.common import PageResponse +from app.services.announcement_service import ( + create_announcement, + update_announcement, + delete_announcement, + get_announcement_by_id, + list_announcements, +) + +router = APIRouter(prefix="/api/announcements", tags=["announcements"]) + + +@router.get("/", response_model=PageResponse[AnnouncementOut]) +async def get_announcements( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + announcements, total = await list_announcements(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for a in announcements: + items.append( + AnnouncementOut( + id=a.id, + class_id=a.class_id, + author_id=a.author_id, + author_name=a.author.name if a.author else "Unknown", + title=a.title, + content=a.content, + is_pinned=a.is_pinned, + created_at=a.created_at, + updated_at=a.updated_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=AnnouncementOut) +async def create_new_announcement( + data: AnnouncementCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + announcement = await create_announcement(db, effective_class_id, user.id, data) + return AnnouncementOut( + id=announcement.id, + class_id=announcement.class_id, + author_id=announcement.author_id, + author_name=user.name, + title=announcement.title, + content=announcement.content, + is_pinned=announcement.is_pinned, + created_at=announcement.created_at, + updated_at=announcement.updated_at, + ) + + +@router.put("/{announcement_id}", response_model=AnnouncementOut) +async def update_existing_announcement( + announcement_id: int, + data: AnnouncementUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_announcement(db, announcement, data) + return AnnouncementOut( + id=updated.id, + class_id=updated.class_id, + author_id=updated.author_id, + author_name=updated.author.name if updated.author else user.name, + title=updated.title, + content=updated.content, + is_pinned=updated.is_pinned, + created_at=updated.created_at, + updated_at=updated.updated_at, + ) + + +@router.delete("/{announcement_id}") +async def delete_existing_announcement( + announcement_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_announcement(db, announcement) + return {"message": "Announcement deleted"} diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py new file mode 100644 index 0000000..a7ec865 --- /dev/null +++ b/backend/app/api/assignments.py @@ -0,0 +1,276 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User, Class_ +from app.schemas.assignment import ( + AssignmentCreate, AssignmentUpdate, AssignmentOut, + AssignmentDetailOut, SubmissionGrade, SubmissionOut, +) +from app.schemas.common import PageResponse +from app.services.assignment_service import ( + create_assignment, + update_assignment, + delete_assignment, + get_assignment_by_id, + list_assignments, + add_attachments, + create_submission, + get_submission_by_student, + grade_submission, + list_submissions, +) +from app.services.cos_service import upload_file + +router = APIRouter(prefix="/api/assignments", tags=["assignments"]) + + +async def _get_roster_count(db: AsyncSession, class_id: int) -> int: + from app.db.models import StudentRoster + result = await db.execute( + select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id) + ) + return result.scalar() or 0 + + +def _build_assignment_out(a: any, user_id: int, total_members: int = 0) -> AssignmentOut: + submission_count = len(a.submissions) if a.submissions else 0 + my_submitted = any(s.student_id == user_id for s in (a.submissions or [])) + return AssignmentOut( + id=a.id, + class_id=a.class_id, + creator_id=a.creator_id, + creator_name=a.creator.name if a.creator else "Unknown", + title=a.title, + description=a.description, + deadline=a.deadline, + attachment_urls=a.get_attachment_urls_list(), + status=a.status, + submission_count=submission_count, + total_members=total_members, + my_submitted=my_submitted, + created_at=a.created_at, + updated_at=a.updated_at, + ) + + +def _build_submission_out(s: any) -> SubmissionOut: + return SubmissionOut( + id=s.id, + assignment_id=s.assignment_id, + student_id=s.student_id, + student_name=s.student.name if s.student else "Unknown", + notes=s.notes, + file_url=s.file_url, + file_name=s.file_name, + file_type=s.file_type, + file_size=s.file_size, + grade=s.grade, + feedback=s.feedback, + graded_at=s.graded_at, + created_at=s.created_at, + updated_at=s.updated_at, + ) + + +@router.get("/", response_model=PageResponse[AssignmentOut]) +async def get_assignments( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + assignments, total = await list_assignments(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + roster_count = await _get_roster_count(db, effective_class_id) + items = [_build_assignment_out(a, user.id, roster_count) for a in assignments] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=AssignmentOut) +async def create_new_assignment( + data: AssignmentCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + assignment = await create_assignment(db, effective_class_id, user.id, data) + roster_count = await _get_roster_count(db, effective_class_id) + return _build_assignment_out(assignment, user.id, roster_count) + + +@router.post("/{assignment_id}/attachments") +async def upload_assignment_attachments( + assignment_id: int, + files: list[UploadFile] = File(...), + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + urls = [] + for f in files: + contents = await f.read() + if len(contents) > 20 * 1024 * 1024: # 20MB limit + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 20MB)") + url = upload_file( + f"assignments/{assignment_id}", f.filename or "file", contents, + f.content_type or "application/octet-stream", + ) + urls.append(url) + + await add_attachments(db, assignment, urls) + return {"attachment_urls": urls} + + +@router.get("/{assignment_id}", response_model=AssignmentDetailOut) +async def get_assignment_detail( + assignment_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id)) + + # Student only sees their own submission + if user.role == "student": + my_submission = None + for s in (assignment.submissions or []): + if s.student_id == user.id: + my_submission = _build_submission_out(s) + break + return AssignmentDetailOut(**base.model_dump(), submissions=[my_submission] if my_submission else []) + + # Admin sees all submissions + submissions = [_build_submission_out(s) for s in (assignment.submissions or [])] + return AssignmentDetailOut(**base.model_dump(), submissions=submissions) + + +@router.put("/{assignment_id}", response_model=AssignmentOut) +async def update_existing_assignment( + assignment_id: int, + data: AssignmentUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_assignment(db, assignment, data) + return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id)) + + +@router.delete("/{assignment_id}") +async def delete_existing_assignment( + assignment_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_assignment(db, assignment) + return {"message": "Assignment deleted"} + + +@router.post("/{assignment_id}/submit", response_model=SubmissionOut) +async def submit_assignment( + assignment_id: int, + notes: str = "", + file: UploadFile = File(...), + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + # Upload file + file_url = None + file_name = None + file_type = None + file_size = None + if file.filename: + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: # 50MB limit + raise HTTPException(status_code=400, detail="File too large (max 50MB)") + file_url = upload_file( + f"submissions/{assignment_id}/{user.id}", file.filename, contents, + file.content_type or "application/octet-stream", + ) + file_name = file.filename + file_type = file.content_type + file_size = len(contents) + + try: + submission = await create_submission( + db, assignment_id, user.id, + notes=notes or None, + file_url=file_url, + file_name=file_name, + file_type=file_type, + file_size=file_size, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return _build_submission_out(submission) + + +@router.put("/submissions/{submission_id}/grade", response_model=SubmissionOut) +async def grade_assignment_submission( + submission_id: int, + data: SubmissionGrade, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + from sqlalchemy import select + from app.db.models import AssignmentSubmission + result = await db.execute( + select(AssignmentSubmission) + .where(AssignmentSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=404, detail="Submission not found") + + graded = await grade_submission(db, submission, data) + + # Reload with student relationship + from sqlalchemy.orm import selectinload + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.id == graded.id) + ) + graded = result.scalar_one() + return _build_submission_out(graded) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..72547c0 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import hash_password, verify_password, create_access_token +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest +from app.schemas.user import TokenResponse, UserOut +from app.services.roster_service import validate_registration + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register") +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + # 1. Check if email is already registered + existing = await db.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该邮箱已注册") + + # 2. Validate invite_code + student_id against roster + roster_entry = await validate_registration(db, req.invite_code, req.student_id) + if roster_entry is None: + raise HTTPException( + status_code=400, detail="邀请码或学号无效,或该学号已注册" + ) + + # 3. Create user with approved status directly + user = User( + email=req.email, + password_hash=hash_password(req.password), + name=req.name, + student_id=req.student_id, + role="student", + status="approved", + class_id=roster_entry.class_id, + ) + db.add(user) + await db.flush() + + # 4. Mark roster entry as registered + roster_entry.status = "registered" + roster_entry.user_id = user.id + await db.commit() + + # 5. Issue token — register and login in one step + token = create_access_token({"sub": str(user.id), "role": user.role}) + return { + "message": "注册成功", + "token": token, + "user": UserOut.model_validate(user), + } + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException(status_code=401, detail="邮箱或密码错误") + + if user.status == "disabled": + raise HTTPException(status_code=401, detail="账号已被禁用") + + if not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=401, detail="邮箱或密码错误") + + token = create_access_token({"sub": str(user.id), "role": user.role}) + return TokenResponse( + token=token, + user=UserOut.model_validate(user), + ) + + +@router.get("/me", response_model=UserOut) +async def get_me(user: User = Depends(get_current_user)): + return UserOut.model_validate(user) + + +@router.put("/change-password") +async def change_password( + req: ChangePasswordRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not verify_password(req.old_password, user.password_hash): + raise HTTPException(status_code=400, detail="Old password is incorrect") + user.password_hash = hash_password(req.new_password) + await db.commit() + return {"message": "Password changed successfully"} diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py new file mode 100644 index 0000000..cbdca3e --- /dev/null +++ b/backend/app/api/classes.py @@ -0,0 +1,270 @@ +import csv +import io + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut +from app.schemas.user import UserListItem +from app.schemas.roster import RosterOut, RosterImportRequest +from app.schemas.common import PageResponse +from app.services.class_service import ( + create_class, + update_class, + delete_class, + get_class_by_id, + list_classes, + get_member_count, + get_class_members, +) +from app.services.roster_service import ( + ensure_invite_code, + regenerate_invite_code, + import_roster, + get_roster, + delete_roster_entry, + clear_unregistered_roster, +) + +router = APIRouter(prefix="/api/classes", tags=["classes"]) + + +@router.get("/", response_model=PageResponse[ClassOut]) +async def get_classes( + page: int = 1, + page_size: int = 50, + db: AsyncSession = Depends(get_db), +): + classes, total = await list_classes(db, page, page_size) + total_pages = (total + page_size - 1) // page_size + result = [] + for c in classes: + count = await get_member_count(db, c.id) + out = ClassOut.model_validate(c) + out.member_count = count + result.append(out) + return PageResponse( + items=result, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=ClassOut) +async def create_new_class( + data: ClassCreate, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await create_class(db, data) + out = ClassOut.model_validate(class_) + out.member_count = 0 + return out + + +@router.put("/{class_id}", response_model=ClassOut) +async def update_existing_class( + class_id: int, + data: ClassUpdate, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + updated = await update_class(db, class_, data) + out = ClassOut.model_validate(updated) + out.member_count = await get_member_count(db, class_id) + return out + + +@router.delete("/{class_id}") +async def delete_existing_class( + class_id: int, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + await delete_class(db, class_) + return {"message": "Class deleted"} + + +@router.get("/{class_id}/members", response_model=PageResponse[UserListItem]) +async def get_members( + class_id: int, + status: str | None = None, + page: int = 1, + page_size: int = 50, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied for this class") + + members, total = await get_class_members(db, class_id, status, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[UserListItem.model_validate(m) for m in members], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +# --- Roster management --- + + +@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut]) +async def get_class_roster( + class_id: int, + page: int = 1, + page_size: int = 50, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + entries, total = await get_roster(db, class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[RosterOut.model_validate(e) for e in entries], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.post("/{class_id}/roster/import") +async def import_class_roster( + class_id: int, + data: RosterImportRequest, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + count = await import_roster(db, class_id, data.entries) + return {"message": f"成功导入 {count} 条记录"} + + +@router.post("/{class_id}/roster/upload") +async def upload_roster_file( + class_id: int, + file: UploadFile = File(...), + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + + contents = await file.read() + filename = file.filename or "" + + entries: list[dict] = [] + + if filename.endswith(".csv"): + text = contents.decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + for row in reader: + sid = row.get("student_id") or row.get("学号") or "" + name = row.get("name") or row.get("姓名") or "" + if sid and name: + entries.append({"student_id": sid.strip(), "name": name.strip()}) + elif filename.endswith((".xlsx", ".xls")): + try: + import openpyxl + + wb = openpyxl.load_workbook(io.BytesIO(contents), read_only=True) + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + raise HTTPException(status_code=400, detail="Excel 文件为空") + header = [str(h).strip() if h else "" for h in rows[0]] + # Find student_id and name columns + sid_col = None + name_col = None + for i, h in enumerate(header): + if h in ("student_id", "学号"): + sid_col = i + elif h in ("name", "姓名"): + name_col = i + if sid_col is None or name_col is None: + raise HTTPException( + status_code=400, + detail="Excel 需包含 '学号'(student_id) 和 '姓名'(name) 列", + ) + for row in rows[1:]: + sid = str(row[sid_col]).strip() if row[sid_col] else "" + name = str(row[name_col]).strip() if row[name_col] else "" + if sid and name and sid != "None": + entries.append({"student_id": sid, "name": name}) + wb.close() + except ImportError: + raise HTTPException( + status_code=400, detail="服务器未安装 openpyxl,请使用 CSV 格式" + ) + else: + raise HTTPException(status_code=400, detail="仅支持 CSV 或 Excel (.xlsx) 文件") + + if not entries: + raise HTTPException(status_code=400, detail="未找到有效数据") + + count = await import_roster(db, class_id, entries) + return {"message": f"成功导入 {count} 条记录"} + + +@router.delete("/{class_id}/roster/{roster_id}") +async def delete_roster_item( + class_id: int, + roster_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + success = await delete_roster_entry(db, roster_id) + if not success: + raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)") + return {"message": "已删除"} + + +@router.post("/{class_id}/roster/clear") +async def clear_roster( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + count = await clear_unregistered_roster(db, class_id) + return {"message": f"已清除 {count} 条未注册记录"} + + +# --- Invite code management --- + + +@router.get("/{class_id}/invite-code") +async def get_invite_code( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + code = await ensure_invite_code(db, class_id) + if not code: + raise HTTPException(status_code=404, detail="Class not found") + return {"invite_code": code} + + +@router.post("/{class_id}/invite-code/regenerate") +async def regenerate_invite( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + code = await regenerate_invite_code(db, class_id) + if not code: + raise HTTPException(status_code=404, detail="Class not found") + return {"invite_code": code} diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py new file mode 100644 index 0000000..4c604a3 --- /dev/null +++ b/backend/app/api/directory.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.user import UserPublic +from app.schemas.common import PageResponse +from app.services.directory_service import search_directory, user_to_public +from app.services.user_service import get_user_by_id + +router = APIRouter(prefix="/api/directory", tags=["directory"]) + + +@router.get("/", response_model=PageResponse[UserPublic]) +async def search_members( + search: str | None = None, + industry: str | None = None, + company: str | None = None, + class_id: int | None = None, + page: int = 1, + page_size: int = 20, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + # Determine effective class_id: super_admin can specify one, others use their own + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + users, total = await search_directory( + db, effective_class_id, search, industry, company, page, page_size + ) + total_pages = (total + page_size - 1) // page_size + include_contact = True # Same class, approved users can see contact + return PageResponse( + items=[user_to_public(u, include_contact=include_contact) for u in users], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.get("/{user_id}", response_model=UserPublic) +async def get_member_detail( + user_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None or target.status != "approved": + raise HTTPException(status_code=404, detail="User not found") + + # Privacy: only show contact info to same-class members + include_contact = user.class_id == target.class_id + return user_to_public(target, include_contact=include_contact) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..2d6e64a --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.notification import NotificationOut, UnreadCount +from app.schemas.common import PageResponse +from app.services.notification_service import ( + list_notifications, + get_unread_count, + mark_as_read, + mark_all_as_read, +) + +router = APIRouter(prefix="/api/notifications", tags=["notifications"]) + + +@router.get("/", response_model=PageResponse[NotificationOut]) +async def get_notifications( + page: int = 1, + page_size: int = 20, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + notifications, total = await list_notifications(db, user.id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [ + NotificationOut( + id=n.id, + type=n.type, + title=n.title, + content=n.content, + related_id=n.related_id, + is_read=n.is_read, + created_at=n.created_at, + ) + for n in notifications + ] + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.get("/unread-count", response_model=UnreadCount) +async def get_unread_count_api( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await get_unread_count(db, user.id) + return UnreadCount(count=count) + + +@router.put("/{notification_id}/read") +async def mark_notification_read( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + success = await mark_as_read(db, notification_id, user.id) + if not success: + raise HTTPException(status_code=404, detail="Notification not found") + return {"message": "Marked as read"} + + +@router.put("/read-all") +async def mark_all_notifications_read( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await mark_all_as_read(db, user.id) + return {"message": f"Marked {count} notifications as read"} diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py new file mode 100644 index 0000000..6d8f83d --- /dev/null +++ b/backend/app/api/resources.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.resource import ResourceCreate, ResourceOut +from app.schemas.common import PageResponse +from app.services.resource_service import ( + create_resource, + list_resources, + get_resource_by_id, + increment_download_count, + delete_resource, +) +from app.services.cos_service import upload_file + +router = APIRouter(prefix="/api/resources", tags=["resources"]) + +ALLOWED_FILE_TYPES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/zip", + "text/plain", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +} + + +@router.get("/", response_model=PageResponse[ResourceOut]) +async def get_resources( + page: int = 1, + page_size: int = 20, + category: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + resources, total = await list_resources(db, effective_class_id, category, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for r in resources: + items.append( + ResourceOut( + id=r.id, + class_id=r.class_id, + uploader_id=r.uploader_id, + uploader_name=r.uploader.name if r.uploader else "Unknown", + title=r.title, + description=r.description, + file_url=r.file_url, + file_type=r.file_type, + file_size=r.file_size, + category=r.category, + download_count=r.download_count, + created_at=r.created_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=ResourceOut) +async def upload_new_resource( + file: UploadFile = File(...), + title: str = Form(...), + category: str = Form(...), + description: str | None = Form(None), + class_id: int | None = Form(None), + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: # 50MB limit + raise HTTPException(status_code=400, detail="File too large (max 50MB)") + + if file.content_type not in ALLOWED_FILE_TYPES: + raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed") + + file_url = upload_file( + f"resources/{effective_class_id}", + file.filename or "file", + contents, + file.content_type, + ) + + data = ResourceCreate(title=title, description=description, category=category) + resource = await create_resource( + db, effective_class_id, user.id, data, file_url, file.content_type, len(contents) + ) + + return ResourceOut( + id=resource.id, + class_id=resource.class_id, + uploader_id=resource.uploader_id, + uploader_name=user.name, + title=resource.title, + description=resource.description, + file_url=resource.file_url, + file_type=resource.file_type, + file_size=resource.file_size, + category=resource.category, + download_count=resource.download_count, + created_at=resource.created_at, + ) + + +@router.post("/{resource_id}/download") +async def download_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + + await increment_download_count(db, resource) + return {"file_url": resource.file_url} + + +@router.delete("/{resource_id}") +async def delete_existing_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + if user.role != "super_admin" and resource.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_resource(db, resource) + return {"message": "Resource deleted"} diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py new file mode 100644 index 0000000..5174539 --- /dev/null +++ b/backend/app/api/schedule.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut +from app.schemas.common import PageResponse +from app.services.schedule_service import ( + create_schedule, + update_schedule, + delete_schedule, + get_schedule_by_id, + list_schedules, + get_upcoming_schedules, +) + +router = APIRouter(prefix="/api/schedule", tags=["schedule"]) + + +@router.get("/upcoming", response_model=list[ScheduleOut]) +async def get_upcoming( + limit: int = 10, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return [] + items = await get_upcoming_schedules(db, effective_class_id, limit) + return [ScheduleOut.model_validate(i) for i in items] + + +@router.get("/", response_model=PageResponse[ScheduleOut]) +async def get_schedules( + type: str | None = None, + page: int = 1, + page_size: int = 50, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + items, total = await list_schedules(db, effective_class_id, type, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[ScheduleOut.model_validate(i) for i in items], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.post("/", response_model=ScheduleOut) +async def create_new_schedule( + data: ScheduleCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + item = await create_schedule(db, effective_class_id, data) + return ScheduleOut.model_validate(item) + + +@router.put("/{schedule_id}", response_model=ScheduleOut) +async def update_existing_schedule( + schedule_id: int, + data: ScheduleUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + item = await get_schedule_by_id(db, schedule_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule not found") + if user.role != "super_admin" and item.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_schedule(db, item, data) + return ScheduleOut.model_validate(updated) + + +@router.delete("/{schedule_id}") +async def delete_existing_schedule( + schedule_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + item = await get_schedule_by_id(db, schedule_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule not found") + if user.role != "super_admin" and item.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_schedule(db, item) + return {"message": "Schedule deleted"} diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py new file mode 100644 index 0000000..1bbda36 --- /dev/null +++ b/backend/app/api/timeline.py @@ -0,0 +1,262 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.timeline import ( + TimelineCreate, TimelineUpdate, TimelineOut, + TimelineCommentCreate, TimelineCommentOut, +) +from app.schemas.common import PageResponse +from app.services.timeline_service import ( + create_timeline, + update_timeline, + delete_timeline, + get_timeline_by_id, + list_timelines, + add_images_to_timeline, + toggle_like, + create_comment, + delete_comment, + get_comment_by_id, + list_comments, +) +from app.services.cos_service import upload_image + +router = APIRouter(prefix="/api/timeline", tags=["timeline"]) + + +def _build_timeline_out(p, user_id: int, include_comments: bool = False) -> TimelineOut: + like_count = len(p.likes) if p.likes else 0 + has_liked = any(l.user_id == user_id for l in (p.likes or [])) + comment_count = len(p.comments) if p.comments else 0 + comments_out = None + if include_comments and p.comments: + comments_out = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in p.comments + ] + return TimelineOut( + id=p.id, + class_id=p.class_id, + author_id=p.author_id, + author_name=p.author.name if p.author else "Unknown", + title=p.title, + content=p.content, + image_urls=p.get_image_urls_list(), + like_count=like_count, + has_liked=has_liked, + comment_count=comment_count, + comments=comments_out, + created_at=p.created_at, + updated_at=p.updated_at, + ) + + +@router.get("/", response_model=PageResponse[TimelineOut]) +async def get_timelines( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + posts, total = await list_timelines(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [_build_timeline_out(p, user.id) for p in posts] + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=TimelineOut) +async def create_new_timeline( + data: TimelineCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + post = await create_timeline(db, effective_class_id, user.id, data) + return TimelineOut( + id=post.id, + class_id=post.class_id, + author_id=post.author_id, + author_name=user.name, + title=post.title, + content=post.content, + image_urls=[], + like_count=0, + has_liked=False, + comment_count=0, + created_at=post.created_at, + updated_at=post.updated_at, + ) + + +@router.post("/{post_id}/images") +async def upload_timeline_images( + post_id: int, + files: list[UploadFile] = File(...), + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + + # Student can only upload to own post; admin can upload to any in their class + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + urls = [] + for f in files: + contents = await f.read() + if len(contents) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") + if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") + url = upload_image(f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) + urls.append(url) + + await add_images_to_timeline(db, post, urls) + return {"image_urls": urls} + + +@router.put("/{post_id}", response_model=TimelineOut) +async def update_existing_timeline( + post_id: int, + data: TimelineUpdate, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_timeline(db, post, data) + return _build_timeline_out(updated, user.id) + + +@router.delete("/{post_id}") +async def delete_existing_timeline( + post_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_timeline(db, post) + return {"message": "Timeline post deleted"} + + +# --- Like & Comment endpoints --- + +@router.post("/{post_id}/like") +async def like_timeline_post( + post_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return await toggle_like(db, post_id, user.id) + + +@router.get("/{post_id}/comments", response_model=PageResponse[TimelineCommentOut]) +async def get_post_comments( + post_id: int, + page: int = 1, + page_size: int = 50, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comments, total = await list_comments(db, post_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in comments + ] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/{post_id}/comments", response_model=TimelineCommentOut) +async def add_post_comment( + post_id: int, + data: TimelineCommentCreate, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + comment = await create_comment(db, post_id, user.id, data) + return TimelineCommentOut( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + author_name=comment.author.name if comment.author else "Unknown", + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) + + +@router.delete("/comments/{comment_id}") +async def delete_timeline_comment( + comment_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comment = await get_comment_by_id(db, comment_id) + if comment is None: + raise HTTPException(status_code=404, detail="Comment not found") + if user.role == "student" and comment.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_comment(db, comment) + return {"message": "Comment deleted"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..bb9e687 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File + +from app.core.deps import require_role +from app.db.models import User +from app.services.cos_service import upload_image + +router = APIRouter(prefix="/api/upload", tags=["upload"]) + + +@router.post("/image") +async def upload_image_api( + file: UploadFile = File(...), + user: User = Depends(require_role("super_admin", "class_admin")), +): + """Upload an image to Tencent COS.""" + contents = await file.read() + if len(contents) > 10 * 1024 * 1024: # 10MB + raise HTTPException(status_code=400, detail="File too large (max 10MB)") + + if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail="Invalid file type, only JPEG/PNG/GIF/WebP allowed") + + url = upload_image("images", file.filename or "upload.jpg", contents, file.content_type) + return {"url": url} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..57f594d --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,123 @@ +import json + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate +from app.schemas.common import PageResponse +from app.services.user_service import ( + update_profile, + update_user_status, + list_users, + get_user_by_id, +) +from app.services.cos_service import upload_image +from app.services.email_service import send_approval_notification + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("/me", response_model=UserOut) +async def get_my_profile(user: User = Depends(get_current_user)): + return UserOut.model_validate(user) + + +@router.put("/me", response_model=UserOut) +async def update_my_profile( + data: UserUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + updated = await update_profile(db, user, data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return UserOut.model_validate(updated) + + +@router.post("/me/avatar") +async def upload_avatar( + file: UploadFile = File(...), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + contents = await file.read() + if len(contents) > 5 * 1024 * 1024: # 5MB limit + raise HTTPException(status_code=400, detail="File too large (max 5MB)") + + if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail="Invalid file type") + + url = upload_image(f"avatars/{user.id}", file.filename or "avatar.jpg", contents, file.content_type) + user.avatar_url = url + await db.commit() + return {"avatar_url": url} + + +@router.get("/", response_model=PageResponse[UserListItem]) +async def list_all_users( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + status: str | None = None, + role: str | None = None, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + users, total = await list_users(db, page, page_size, class_id, status, role) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[UserListItem.model_validate(u) for u in users], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.put("/{user_id}/status") +async def change_user_status( + user_id: int, + data: UserStatusUpdate, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + # Class admin can only manage users in their own class + if admin.role == "class_admin" and target.class_id != admin.class_id: + raise HTTPException( + status_code=403, detail="Cannot manage users outside your class" + ) + + updated = await update_user_status(db, user_id, data.status, data.role) + + # Send email notification + if data.status in ("approved", "rejected"): + await send_approval_notification(target.email, data.status == "approved") + + return {"message": f"User status updated to {data.status}"} + + +@router.put("/{user_id}/role") +async def change_user_role( + user_id: int, + role: str, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + if role not in ("super_admin", "class_admin", "student"): + raise HTTPException(status_code=400, detail="Invalid role") + + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + target.role = role + await db.commit() + return {"message": f"User role updated to {role}"} diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py new file mode 100644 index 0000000..59137ab --- /dev/null +++ b/backend/app/api/votes.py @@ -0,0 +1,177 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut +from app.schemas.common import PageResponse +from app.services.vote_service import ( + create_vote, + get_vote_by_id, + list_votes, + submit_vote, + close_vote, + delete_vote, +) + +router = APIRouter(prefix="/api/votes", tags=["votes"]) + + +def _build_vote_out(vote: any, user_id: int) -> VoteOut: + # Compute per-option stats + options_out = [] + for opt in (vote.options or []): + responses = opt.responses or [] + vote_count = len(responses) + voter_names = None + if not vote.is_anonymous: + voter_names = [r.voter.name for r in responses if r.voter] + options_out.append(VoteOptionOut( + id=opt.id, + content=opt.content, + sort_order=opt.sort_order, + vote_count=vote_count, + voter_names=voter_names, + )) + + # Total unique voters + all_voter_ids = set() + my_option_ids = [] + for opt in (vote.options or []): + for r in (opt.responses or []): + all_voter_ids.add(r.voter_id) + if r.voter_id == user_id: + my_option_ids.append(opt.id) + + return VoteOut( + id=vote.id, + class_id=vote.class_id, + creator_id=vote.creator_id, + creator_name=vote.creator.name if vote.creator else "Unknown", + title=vote.title, + description=vote.description, + vote_type=vote.vote_type, + is_anonymous=vote.is_anonymous, + max_choices=vote.max_choices, + deadline=vote.deadline, + status=vote.status, + total_voters=len(all_voter_ids), + has_voted=user_id in all_voter_ids, + my_option_ids=my_option_ids if my_option_ids else None, + options=options_out, + created_at=vote.created_at, + updated_at=vote.updated_at, + ) + + +@router.get("/", response_model=PageResponse[VoteOut]) +async def get_votes( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + votes, total = await list_votes(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [_build_vote_out(v, user.id) for v in votes] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=VoteOut) +async def create_new_vote( + data: VoteCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + if len(data.options) < 2: + raise HTTPException(status_code=400, detail="至少需要 2 个选项") + if data.vote_type == "multiple" and data.max_choices < 2: + raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2") + + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + vote = await create_vote(db, effective_class_id, user.id, data) + # Reload with relationships + vote = await get_vote_by_id(db, vote.id) + return _build_vote_out(vote, user.id) + + +@router.get("/{vote_id}", response_model=VoteOut) +async def get_vote_detail( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return _build_vote_out(vote, user.id) + + +@router.post("/{vote_id}/submit") +async def submit_vote_response( + vote_id: int, + data: VoteSubmit, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + try: + await submit_vote(db, vote_id, user.id, data.option_ids) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return {"message": "投票成功"} + + +@router.put("/{vote_id}/close") +async def close_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + # Only creator or admin can close + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await close_vote(db, vote) + return {"message": "投票已关闭"} + + +@router.delete("/{vote_id}") +async def delete_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_vote(db, vote) + return {"message": "投票已删除"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..dca2318 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,43 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Service + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + + # Database + database_url: str = "sqlite+aiosqlite:///./classhub.db" + + # JWT + jwt_secret: str = "change-me-in-production" + jwt_expiry_hours: int = 72 + jwt_algorithm: str = "HS256" + + # Tencent COS + cos_secret_id: str = "" + cos_secret_key: str = "" + cos_region: str = "ap-hongkong" + cos_bucket: str = "" + cos_base_url: str = "" + + # SMTP Email + smtp_host: str = "" + smtp_port: int = 465 + smtp_user: str = "" + smtp_password: str = "" + smtp_from_email: str = "" + smtp_from_name: str = "HKU ICB" + + # Frontend URL + frontend_url: str = "http://localhost:3000" + + # Super Admin seed + super_admin_email: str = "admin@classhub.com" + super_admin_password: str = "admin123" + + model_config = {"env_file": ".env", "env_prefix": "CH_"} + + +settings = Settings() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..156ed75 --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta, timezone + +import bcrypt +from jose import JWTError, jwt + +from app.config import settings + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode( + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm] + ) + return payload + except JWTError: + return None diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..e1a8bba --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import decode_access_token +from app.db.database import get_db +from app.db.models import User + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + payload = decode_access_token(credentials.credentials) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalid or expired", + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token format", + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + if user.status == "disabled": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled" + ) + + return user + + +def require_role(*roles: str): + """Factory: returns a dependency that checks user role.""" + + async def _check(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) + return user + + return _check diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..3982c31 --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.database_url, echo=settings.debug) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + yield session + + +async def create_tables(): + from app.db.base import Base + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..932b734 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,416 @@ +import json +from datetime import datetime + +from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Class_(Base): + __tablename__ = "classes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + cohort_year: Mapped[int] = mapped_column(Integer, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + invite_code: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + members: Mapped[list["User"]] = relationship("User", back_populates="class_") + timelines: Mapped[list["Timeline"]] = relationship( + "Timeline", back_populates="class_", cascade="all, delete-orphan" + ) + schedules: Mapped[list["Schedule"]] = relationship( + "Schedule", back_populates="class_", cascade="all, delete-orphan" + ) + announcements: Mapped[list["Announcement"]] = relationship( + "Announcement", back_populates="class_", cascade="all, delete-orphan" + ) + resources: Mapped[list["Resource"]] = relationship( + "Resource", back_populates="class_", cascade="all, delete-orphan" + ) + roster: Mapped[list["StudentRoster"]] = relationship( + "StudentRoster", back_populates="class_", cascade="all, delete-orphan" + ) + assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="class_", cascade="all, delete-orphan" + ) + votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="class_", cascade="all, delete-orphan" + ) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True) + + # role: super_admin | class_admin | student + role: Mapped[str] = mapped_column(String(20), default="student", nullable=False) + # status: pending | approved | rejected | disabled + status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) + + class_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=True + ) + class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members") + + # Profile + industry: Mapped[str | None] = mapped_column(String(100), nullable=True) + company: Mapped[str | None] = mapped_column(String(100), nullable=True) + position: Mapped[str | None] = mapped_column(String(100), nullable=True) + skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + timeline_posts: Mapped[list["Timeline"]] = relationship( + "Timeline", back_populates="author" + ) + created_assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="creator" + ) + assignment_submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="student" + ) + created_votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="creator" + ) + + def get_skills_list(self) -> list[str]: + if not self.skills_tags: + return [] + try: + return json.loads(self.skills_tags) + except (json.JSONDecodeError, TypeError): + return [] + + def set_skills_list(self, tags: list[str]): + self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None + + +class Timeline(Base): + __tablename__ = "timelines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + image_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="timelines") + author: Mapped["User"] = relationship("User", back_populates="timeline_posts") + likes: Mapped[list["TimelineLike"]] = relationship( + "TimelineLike", back_populates="post", cascade="all, delete-orphan" + ) + comments: Mapped[list["TimelineComment"]] = relationship( + "TimelineComment", back_populates="post", cascade="all, delete-orphan" + ) + + def get_image_urls_list(self) -> list[str]: + if not self.image_urls: + return [] + try: + return json.loads(self.image_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_image_urls_list(self, urls: list[str]): + self.image_urls = json.dumps(urls) if urls else None + + +class Schedule(Base): + __tablename__ = "schedules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + # type: course | deadline | activity + type: Mapped[str] = mapped_column(String(20), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) + end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + location: Mapped[str | None] = mapped_column(String(200), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="schedules") + + +class Announcement(Base): + __tablename__ = "announcements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="announcements") + author: Mapped["User"] = relationship("User") + + +class Resource(Base): + __tablename__ = "resources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + uploader_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str] = mapped_column(Text, nullable=False) + file_type: Mapped[str] = mapped_column(String(50), nullable=False) + file_size: Mapped[int] = mapped_column(Integer, nullable=False) + category: Mapped[str] = mapped_column(String(50), nullable=False) + download_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="resources") + uploader: Mapped["User"] = relationship("User") + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + related_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + user: Mapped["User"] = relationship("User") + + +class StudentRoster(Base): + __tablename__ = "student_rosters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + student_id: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str] = mapped_column( + String(20), default="unregistered", nullable=False + ) + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="roster") + user: Mapped["User | None"] = relationship("User") + + +class TimelineLike(Base): + __tablename__ = "timeline_likes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="likes") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("post_id", "user_id", name="uq_timeline_like"), + ) + + +class TimelineComment(Base): + __tablename__ = "timeline_comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="comments") + author: Mapped["User"] = relationship("User") + + +class Vote(Base): + __tablename__ = "votes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + vote_type: Mapped[str] = mapped_column(String(20), default="single", nullable=False) # single | multiple + is_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + max_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="votes") + creator: Mapped["User"] = relationship("User", back_populates="created_votes") + options: Mapped[list["VoteOption"]] = relationship( + "VoteOption", back_populates="vote", cascade="all, delete-orphan" + ) + + +class VoteOption(Base): + __tablename__ = "vote_options" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + content: Mapped[str] = mapped_column(String(500), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + vote: Mapped["Vote"] = relationship("Vote", back_populates="options") + responses: Mapped[list["VoteResponse"]] = relationship( + "VoteResponse", back_populates="option", cascade="all, delete-orphan" + ) + + +class VoteResponse(Base): + __tablename__ = "vote_responses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + option_id: Mapped[int] = mapped_column( + Integer, ForeignKey("vote_options.id"), nullable=False, index=True + ) + voter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + option: Mapped["VoteOption"] = relationship("VoteOption", back_populates="responses") + voter: Mapped["User"] = relationship("User") + + +class Assignment(Base): + __tablename__ = "assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + attachment_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="assignments") + creator: Mapped["User"] = relationship("User", back_populates="created_assignments") + submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="assignment", cascade="all, delete-orphan" + ) + + def get_attachment_urls_list(self) -> list[str]: + if not self.attachment_urls: + return [] + try: + return json.loads(self.attachment_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_attachment_urls_list(self, urls: list[str]): + self.attachment_urls = json.dumps(urls) if urls else None + + +class AssignmentSubmission(Base): + __tablename__ = "assignment_submissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + assignment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("assignments.id"), nullable=False, index=True + ) + student_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str | None] = mapped_column(Text, nullable=True) + file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + file_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + grade: Mapped[str | None] = mapped_column(String(50), nullable=True) + feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + graded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions") + student: Mapped["User"] = relationship("User", back_populates="assignment_submissions") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..dc431d2 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,99 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.db.database import create_tables +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments + +logging.basicConfig( + level=logging.DEBUG if settings.debug else logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +async def ensure_super_admin(): + """Seed super admin on first run.""" + from sqlalchemy import select + from app.db.database import async_session + from app.db.models import User + from app.core.auth import hash_password + + async with async_session() as db: + result = await db.execute(select(User).where(User.role == "super_admin")) + if result.scalar_one_or_none() is None: + admin = User( + email=settings.super_admin_email, + password_hash=hash_password(settings.super_admin_password), + name="Super Admin", + role="super_admin", + status="approved", + ) + db.add(admin) + await db.commit() + logger.info("Super admin seeded: %s", settings.super_admin_email) + + +async def ensure_sample_class(): + """Seed a sample class if none exists.""" + from sqlalchemy import select, func + from app.db.database import async_session + from app.db.models import Class_ + + async with async_session() as db: + result = await db.execute(select(func.count(Class_.id))) + count = result.scalar() + if count == 0: + sample = Class_( + name="HKU ICB Sample Class", + cohort_year=2025, + description="Sample class for testing", + ) + db.add(sample) + await db.commit() + logger.info("Sample class seeded") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await create_tables() + await ensure_super_admin() + await ensure_sample_class() + yield + + +app = FastAPI( + title="HKU ICB", + description="HKU ICB Graduate Class Resource Platform", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.frontend_url, "http://localhost:3000", "http://192.168.31.172:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(classes.router) +app.include_router(directory.router) +app.include_router(timeline.router) +app.include_router(schedule.router) +app.include_router(upload.router) +app.include_router(announcements.router) +app.include_router(resources.router) +app.include_router(notifications.router) +app.include_router(votes.router) +app.include_router(assignments.router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok", "service": "classhub"} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/announcement.py b/backend/app/schemas/announcement.py new file mode 100644 index 0000000..6a846ea --- /dev/null +++ b/backend/app/schemas/announcement.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AnnouncementCreate(BaseModel): + title: str + content: str | None = None + is_pinned: bool = False + + +class AnnouncementUpdate(BaseModel): + title: str | None = None + content: str | None = None + is_pinned: bool | None = None + + +class AnnouncementOut(BaseModel): + id: int + class_id: int + author_id: int + author_name: str + title: str + content: str | None + is_pinned: bool + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py new file mode 100644 index 0000000..be8645d --- /dev/null +++ b/backend/app/schemas/assignment.py @@ -0,0 +1,63 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AssignmentCreate(BaseModel): + title: str + description: str | None = None + deadline: datetime | None = None + + +class AssignmentUpdate(BaseModel): + title: str | None = None + description: str | None = None + deadline: datetime | None = None + status: str | None = None # open | closed + + +class SubmissionCreate(BaseModel): + notes: str | None = None + + +class SubmissionGrade(BaseModel): + grade: str + feedback: str | None = None + + +class SubmissionOut(BaseModel): + id: int + assignment_id: int + student_id: int + student_name: str + notes: str | None + file_url: str | None + file_name: str | None + file_type: str | None + file_size: int | None + grade: str | None + feedback: str | None + graded_at: datetime | None + created_at: datetime + updated_at: datetime + + +class AssignmentOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + deadline: datetime | None + attachment_urls: list[str] | None + status: str + submission_count: int + total_members: int + my_submitted: bool + created_at: datetime + updated_at: datetime + + +class AssignmentDetailOut(AssignmentOut): + submissions: list[SubmissionOut] | None = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..851f3f7 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RegisterRequest(BaseModel): + invite_code: str + student_id: str + name: str + email: EmailStr + password: str + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str diff --git a/backend/app/schemas/class_.py b/backend/app/schemas/class_.py new file mode 100644 index 0000000..5615b24 --- /dev/null +++ b/backend/app/schemas/class_.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ClassCreate(BaseModel): + name: str + cohort_year: int + description: str | None = None + + +class ClassUpdate(BaseModel): + name: str | None = None + cohort_year: int | None = None + description: str | None = None + + +class ClassOut(BaseModel): + id: int + name: str + cohort_year: int + description: str | None + invite_code: str | None = None + member_count: int = 0 + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..c536567 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,24 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class APIResponse(BaseModel, Generic[T]): + success: bool = True + data: T | None = None + message: str = "" + + +class PageParams(BaseModel): + page: int = 1 + page_size: int = 20 + + +class PageResponse(BaseModel, Generic[T]): + items: list[T] + total: int + page: int + page_size: int + total_pages: int diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..46eec08 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class NotificationOut(BaseModel): + id: int + type: str + title: str + content: str | None + related_id: int | None + is_read: bool + created_at: datetime + + +class UnreadCount(BaseModel): + count: int diff --git a/backend/app/schemas/resource.py b/backend/app/schemas/resource.py new file mode 100644 index 0000000..fd5405e --- /dev/null +++ b/backend/app/schemas/resource.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ResourceCreate(BaseModel): + title: str + description: str | None = None + category: str # "course_material" | "assignment" | "reading" | "other" + + +class ResourceOut(BaseModel): + id: int + class_id: int + uploader_id: int + uploader_name: str + title: str + description: str | None + file_url: str + file_type: str + file_size: int + category: str + download_count: int + created_at: datetime diff --git a/backend/app/schemas/roster.py b/backend/app/schemas/roster.py new file mode 100644 index 0000000..75640e7 --- /dev/null +++ b/backend/app/schemas/roster.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class RosterOut(BaseModel): + id: int + student_id: str + name: str + status: str # "unregistered" | "registered" + user_id: int | None + + model_config = {"from_attributes": True} + + +class RosterImportRequest(BaseModel): + entries: list[dict] # [{"student_id": "...", "name": "..."}, ...] diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py new file mode 100644 index 0000000..fa6a25a --- /dev/null +++ b/backend/app/schemas/schedule.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ScheduleCreate(BaseModel): + type: str # course | deadline | activity + title: str + start_time: datetime + end_time: datetime | None = None + location: str | None = None + description: str | None = None + + +class ScheduleUpdate(BaseModel): + type: str | None = None + title: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + location: str | None = None + description: str | None = None + + +class ScheduleOut(BaseModel): + id: int + class_id: int + type: str + title: str + start_time: datetime + end_time: datetime | None + location: str | None + description: str | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py new file mode 100644 index 0000000..d8278e1 --- /dev/null +++ b/backend/app/schemas/timeline.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class TimelineCreate(BaseModel): + title: str + content: str | None = None + + +class TimelineUpdate(BaseModel): + title: str | None = None + content: str | None = None + + +class TimelineCommentCreate(BaseModel): + content: str + + +class TimelineCommentOut(BaseModel): + id: int + post_id: int + author_id: int + author_name: str + content: str + created_at: datetime + updated_at: datetime + + +class TimelineOut(BaseModel): + id: int + class_id: int + author_id: int + author_name: str + title: str + content: str | None + image_urls: list[str] | None + like_count: int + has_liked: bool + comment_count: int + comments: list[TimelineCommentOut] | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..eaf4184 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,86 @@ +import json +from datetime import datetime + +from pydantic import BaseModel, EmailStr, field_validator + + +class UserOut(BaseModel): + id: int + email: str + name: str + student_id: str | None + role: str + status: str + class_id: int | None + industry: str | None + company: str | None + position: str | None + skills_tags: list[str] | None + wechat_id: str | None + phone: str | None + avatar_url: str | None + bio: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + @field_validator("skills_tags", mode="before") + @classmethod + def parse_skills_tags(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except (json.JSONDecodeError, TypeError): + return [] + return v + + +class UserPublic(BaseModel): + """Shown to same-class approved members (includes contact info).""" + id: int + name: str + student_id: str | None + industry: str | None + company: str | None + position: str | None + wechat_id: str | None + phone: str | None + avatar_url: str | None + bio: str | None + + +class UserListItem(BaseModel): + """For admin user management list.""" + id: int + email: str + name: str + student_id: str | None + role: str + status: str + class_id: int | None + industry: str | None + company: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + name: str | None = None + industry: str | None = None + company: str | None = None + position: str | None = None + wechat_id: str | None = None + phone: str | None = None + bio: str | None = None + + +class UserStatusUpdate(BaseModel): + status: str # approved | rejected | disabled + role: str | None = None + + +class TokenResponse(BaseModel): + token: str + user: UserOut diff --git a/backend/app/schemas/vote.py b/backend/app/schemas/vote.py new file mode 100644 index 0000000..059a692 --- /dev/null +++ b/backend/app/schemas/vote.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class VoteCreate(BaseModel): + title: str + description: str | None = None + vote_type: str = "single" # single | multiple + is_anonymous: bool = False + max_choices: int = 1 + deadline: datetime | None = None + options: list[str] # option text list, min 2 + + +class VoteUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None # to close a vote + + +class VoteSubmit(BaseModel): + option_ids: list[int] # selected option IDs + + +class VoteOptionOut(BaseModel): + id: int + content: str + sort_order: int + vote_count: int + voter_names: list[str] | None = None # None if anonymous + + +class VoteOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + vote_type: str + is_anonymous: bool + max_choices: int + deadline: datetime | None + status: str + total_voters: int + has_voted: bool + my_option_ids: list[int] | None = None + options: list[VoteOptionOut] + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..2e831d2 --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -0,0 +1,79 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Announcement, User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate +from app.services.notification_service import create_notifications_for_class + + +async def create_announcement( + db: AsyncSession, class_id: int, author_id: int, data: AnnouncementCreate +) -> Announcement: + announcement = Announcement( + class_id=class_id, + author_id=author_id, + title=data.title, + content=data.content, + is_pinned=data.is_pinned, + ) + db.add(announcement) + await db.commit() + await db.refresh(announcement) + + # Send notifications + email to class members + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") + await create_notifications_for_class( + db, class_id, "announcement", f"新公告: {data.title}", + content=content_preview, + related_id=announcement.id, + email_subject=f"HKU ICB - 新公告: {data.title}", + email_body=f"

{content_preview}

" if content_preview else None, + email_action_path="/announcements", + ) + + return announcement + + +async def update_announcement( + db: AsyncSession, announcement: Announcement, data: AnnouncementUpdate +) -> Announcement: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(announcement, field, value) + await db.commit() + await db.refresh(announcement) + return announcement + + +async def delete_announcement(db: AsyncSession, announcement: Announcement): + await db.delete(announcement) + await db.commit() + + +async def get_announcement_by_id(db: AsyncSession, announcement_id: int) -> Announcement | None: + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.id == announcement_id) + ) + return result.scalar_one_or_none() + + +async def list_announcements( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Announcement], int]: + total_result = await db.execute( + select(func.count(Announcement.id)).where(Announcement.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.class_id == class_id) + .order_by(Announcement.is_pinned.desc(), Announcement.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + announcements = list(result.scalars().all()) + return announcements, total diff --git a/backend/app/services/assignment_service.py b/backend/app/services/assignment_service.py new file mode 100644 index 0000000..a0807ae --- /dev/null +++ b/backend/app/services/assignment_service.py @@ -0,0 +1,179 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Assignment, AssignmentSubmission, User +from app.schemas.assignment import AssignmentCreate, AssignmentUpdate, SubmissionCreate, SubmissionGrade +from app.services.notification_service import create_notifications_for_class + + +async def create_assignment( + db: AsyncSession, class_id: int, creator_id: int, data: AssignmentCreate +) -> Assignment: + assignment = Assignment( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + deadline=data.deadline, + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + + await create_notifications_for_class( + db, class_id, "assignment", f"新作业: {data.title}", + content=data.description, + related_id=assignment.id, + email_subject=f"HKU ICB - 新作业: {data.title}", + email_body=f"

{data.description or data.title}

", + email_action_path="/assignments", + ) + + return assignment + + +async def update_assignment( + db: AsyncSession, assignment: Assignment, data: AssignmentUpdate +) -> Assignment: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(assignment, field, value) + await db.commit() + await db.refresh(assignment) + return assignment + + +async def delete_assignment(db: AsyncSession, assignment: Assignment): + await db.delete(assignment) + await db.commit() + + +async def get_assignment_by_id(db: AsyncSession, assignment_id: int) -> Assignment | None: + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions).selectinload(AssignmentSubmission.student), + ) + .where(Assignment.id == assignment_id) + ) + return result.scalar_one_or_none() + + +async def list_assignments( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Assignment], int]: + total_result = await db.execute( + select(func.count(Assignment.id)).where(Assignment.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions), + ) + .where(Assignment.class_id == class_id) + .order_by(Assignment.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def add_attachments(db: AsyncSession, assignment: Assignment, urls: list[str]): + existing = assignment.get_attachment_urls_list() + existing.extend(urls) + assignment.set_attachment_urls_list(existing) + await db.commit() + await db.refresh(assignment) + + +async def create_submission( + db: AsyncSession, + assignment_id: int, + student_id: int, + notes: str | None, + file_url: str | None = None, + file_name: str | None = None, + file_type: str | None = None, + file_size: int | None = None, +) -> AssignmentSubmission: + # Check assignment exists and is open + a_result = await db.execute(select(Assignment).where(Assignment.id == assignment_id)) + assignment = a_result.scalar_one_or_none() + if assignment is None: + raise ValueError("作业不存在") + if assignment.status != "open": + raise ValueError("作业已关闭提交") + if assignment.deadline and datetime.now() > assignment.deadline: + raise ValueError("作业已过截止日期") + + # Check no existing submission + existing = await db.execute( + select(AssignmentSubmission).where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经提交过作业了") + + submission = AssignmentSubmission( + assignment_id=assignment_id, + student_id=student_id, + notes=notes, + file_url=file_url, + file_name=file_name, + file_type=file_type, + file_size=file_size, + ) + db.add(submission) + await db.commit() + await db.refresh(submission) + + # Reload with student relationship + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.id == submission.id) + ) + return result.scalar_one() + + +async def get_submission_by_student( + db: AsyncSession, assignment_id: int, student_id: int +) -> AssignmentSubmission | None: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + return result.scalar_one_or_none() + + +async def grade_submission( + db: AsyncSession, submission: AssignmentSubmission, data: SubmissionGrade +) -> AssignmentSubmission: + submission.grade = data.grade + submission.feedback = data.feedback + submission.graded_at = datetime.now() + await db.commit() + await db.refresh(submission) + return submission + + +async def list_submissions(db: AsyncSession, assignment_id: int) -> list[AssignmentSubmission]: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.assignment_id == assignment_id) + .order_by(AssignmentSubmission.created_at.desc()) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/class_service.py b/backend/app/services/class_service.py new file mode 100644 index 0000000..003efd1 --- /dev/null +++ b/backend/app/services/class_service.py @@ -0,0 +1,79 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Class_, User +from app.schemas.class_ import ClassCreate, ClassUpdate + + +async def create_class(db: AsyncSession, data: ClassCreate) -> Class_: + class_ = Class_(**data.model_dump()) + db.add(class_) + await db.commit() + await db.refresh(class_) + return class_ + + +async def update_class(db: AsyncSession, class_: Class_, data: ClassUpdate) -> Class_: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(class_, field, value) + await db.commit() + await db.refresh(class_) + return class_ + + +async def delete_class(db: AsyncSession, class_: Class_): + await db.delete(class_) + await db.commit() + + +async def get_class_by_id(db: AsyncSession, class_id: int) -> Class_ | None: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + return result.scalar_one_or_none() + + +async def list_classes( + db: AsyncSession, page: int = 1, page_size: int = 50 +) -> tuple[list[Class_], int]: + total_result = await db.execute(select(func.count(Class_.id))) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Class_) + .order_by(Class_.cohort_year.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + classes = list(result.scalars().all()) + return classes, total + + +async def get_member_count(db: AsyncSession, class_id: int) -> int: + result = await db.execute( + select(func.count(User.id)).where( + User.class_id == class_id, User.status == "approved" + ) + ) + return result.scalar() or 0 + + +async def get_class_members( + db: AsyncSession, + class_id: int, + status: str | None = None, + page: int = 1, + page_size: int = 50, +) -> tuple[list[User], int]: + query = select(User).where(User.class_id == class_id) + count_query = select(func.count(User.id)).where(User.class_id == class_id) + + if status: + query = query.where(User.status == status) + count_query = count_query.where(User.status == status) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(User.name).offset((page - 1) * page_size).limit(page_size) + ) + return list(result.scalars().all()), total diff --git a/backend/app/services/cos_service.py b/backend/app/services/cos_service.py new file mode 100644 index 0000000..2fbd850 --- /dev/null +++ b/backend/app/services/cos_service.py @@ -0,0 +1,75 @@ +import uuid +from datetime import datetime +from io import BytesIO + +from qcloud_cos import CosConfig, CosS3Client + +from app.config import settings + +config = CosConfig( + Region=settings.cos_region, + SecretId=settings.cos_secret_id, + SecretKey=settings.cos_secret_key, +) +client = CosS3Client(config) + +ALLOWED_IMAGE_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def upload_bytes(key: str, data: bytes, content_type: str) -> str: + """Upload raw bytes to COS, return the public URL.""" + client.put_object( + Bucket=settings.cos_bucket, + Key=key, + Body=BytesIO(data), + ContentType=content_type, + ) + return f"{settings.cos_base_url}/{key}" + + +def upload_image(prefix: str, filename: str, data: bytes, content_type: str) -> str: + """ + Upload an image to COS under the given prefix. + Returns the public URL. + """ + if content_type not in ALLOWED_IMAGE_TYPES: + raise ValueError(f"Unsupported image type: {content_type}") + + ext = ALLOWED_IMAGE_TYPES[content_type] + date_path = datetime.now().strftime("%Y/%m/%d") + unique_name = uuid.uuid4().hex[:12] + key = f"{prefix}/{date_path}/{unique_name}{ext}" + + return upload_bytes(key, data, content_type) + + +FILE_TYPE_EXTENSIONS = { + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/zip": ".zip", + "text/plain": ".txt", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def upload_file(prefix: str, filename: str, data: bytes, content_type: str) -> str: + """Upload any allowed file type to COS. Returns the public URL.""" + ext = FILE_TYPE_EXTENSIONS.get(content_type, "") + date_path = datetime.now().strftime("%Y/%m/%d") + unique_name = uuid.uuid4().hex[:12] + key = f"{prefix}/{date_path}/{unique_name}{ext}" + + return upload_bytes(key, data, content_type) diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py new file mode 100644 index 0000000..72b56a5 --- /dev/null +++ b/backend/app/services/directory_service.py @@ -0,0 +1,77 @@ +import json +from sqlalchemy import select, or_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.schemas.user import UserPublic + + +async def search_directory( + db: AsyncSession, + class_id: int, + search: str | None = None, + industry: str | None = None, + company: str | None = None, + page: int = 1, + page_size: int = 20, +) -> tuple[list[User], int]: + """Search approved members in a class.""" + query = select(User).where( + User.class_id == class_id, User.status == "approved" + ) + count_query = select(func.count(User.id)).where( + User.class_id == class_id, User.status == "approved" + ) + + if search: + search_term = f"%{search}%" + query = query.where( + or_( + User.name.ilike(search_term), + User.company.ilike(search_term), + User.position.ilike(search_term), + ) + ) + count_query = count_query.where( + or_( + User.name.ilike(search_term), + User.company.ilike(search_term), + User.position.ilike(search_term), + ) + ) + + if industry: + query = query.where(User.industry == industry) + count_query = count_query.where(User.industry == industry) + + if company: + company_term = f"%{company}%" + query = query.where(User.company.ilike(company_term)) + count_query = count_query.where(User.company.ilike(company_term)) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(User.name) + .offset((page - 1) * page_size) + .limit(page_size) + ) + users = list(result.scalars().all()) + return users, total + + +def user_to_public(user: User, include_contact: bool = True) -> UserPublic: + """Convert User model to public profile, optionally hiding contact info.""" + return UserPublic( + id=user.id, + name=user.name, + student_id=user.student_id, + industry=user.industry, + company=user.company, + position=user.position, + wechat_id=user.wechat_id if include_contact else None, + phone=user.phone if include_contact else None, + avatar_url=user.avatar_url, + bio=user.bio, + ) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..28f0b20 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,95 @@ +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib + +from app.config import settings + +logger = logging.getLogger(__name__) + + +async def send_email(to: str, subject: str, html_body: str) -> bool: + """Send HTML email via SMTP. Returns True on success.""" + if not settings.smtp_host: + logger.info(f"SMTP not configured, skipping email to {to}: {subject}") + return False + + msg = MIMEMultipart("alternative") + msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>" + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(html_body, "html")) + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + use_tls=True, + ) + return True + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + return False + + +async def send_registration_notification( + admin_email: str, student_name: str, class_name: str +): + html = f""" +

New Registration Pending Approval

+

{student_name} has registered for {class_name}.

+

Please log in to HKU ICB to review and approve.

+ """ + await send_email(admin_email, "HKU ICB: New Registration", html) + + +async def send_approval_notification(student_email: str, approved: bool): + status_text = "approved" if approved else "rejected" + html = f""" +

Registration {status_text.capitalize()}

+

Your registration has been {status_text}.

+ {"

You can now log in to HKU ICB.

" if approved else ""} + """ + await send_email( + student_email, f"HKU ICB: Registration {status_text.capitalize()}", html + ) + + +async def send_class_notification_email( + emails: list[str], + subject: str, + title: str, + body: str, + action_url: str | None = None, +): + """Send a styled notification email to class members.""" + action_html = "" + if action_url: + action_html = f""" +
+ + 查看详情 + +
+ """ + html = f""" +
+
+

HKU ICB

+
+
+

{title}

+
{body}
+ {action_html} +
+
+ 此邮件由系统自动发送,请勿直接回复。 +
+
+ """ + for email in emails: + await send_email(email, subject, html) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..f379b8a --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,132 @@ +import asyncio +import logging + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Notification, User +from app.services.email_service import send_class_notification_email + +logger = logging.getLogger(__name__) + + +async def create_notification( + db: AsyncSession, + user_id: int, + type: str, + title: str, + content: str | None = None, + related_id: int | None = None, +) -> Notification: + notification = Notification( + user_id=user_id, + type=type, + title=title, + content=content, + related_id=related_id, + ) + db.add(notification) + await db.commit() + await db.refresh(notification) + return notification + + +async def create_notifications_for_class( + db: AsyncSession, + class_id: int, + type: str, + title: str, + content: str | None = None, + related_id: int | None = None, + exclude_user_id: int | None = None, + email_subject: str | None = None, + email_body: str | None = None, + email_action_path: str | None = None, +): + """Create in-app notifications + send email for all approved users in a class.""" + result = await db.execute( + select(User.id, User.email).where( + User.class_id == class_id, + User.status == "approved", + ) + ) + rows = result.all() + + emails: list[str] = [] + for uid, email in rows: + notification = Notification( + user_id=uid, + type=type, + title=title, + content=content, + related_id=related_id, + ) + db.add(notification) + emails.append(email) + + await db.commit() + + # Send email notification in background (fire-and-forget) + if email_subject and emails: + from app.config import settings + action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None + asyncio.create_task(_safe_send_emails(emails, email_subject, title, email_body or content or "", action_url)) + + +async def _safe_send_emails( + emails: list[str], subject: str, title: str, body: str, action_url: str | None +): + """Fire-and-forget email sending with error logging.""" + try: + await send_class_notification_email(emails, subject, title, body, action_url) + except Exception as e: + logger.error(f"Failed to send class notification emails: {e}") + + +async def list_notifications( + db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Notification], int]: + total_result = await db.execute( + select(func.count(Notification.id)).where(Notification.user_id == user_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + notifications = list(result.scalars().all()) + return notifications, total + + +async def get_unread_count(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.count(Notification.id)).where( + Notification.user_id == user_id, + Notification.is_read == False, + ) + ) + return result.scalar() or 0 + + +async def mark_as_read(db: AsyncSession, notification_id: int, user_id: int) -> bool: + result = await db.execute( + update(Notification) + .where(Notification.id == notification_id, Notification.user_id == user_id) + .values(is_read=True) + ) + await db.commit() + return result.rowcount > 0 + + +async def mark_all_as_read(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + update(Notification) + .where(Notification.user_id == user_id, Notification.is_read == False) + .values(is_read=True) + ) + await db.commit() + return result.rowcount diff --git a/backend/app/services/resource_service.py b/backend/app/services/resource_service.py new file mode 100644 index 0000000..747a32f --- /dev/null +++ b/backend/app/services/resource_service.py @@ -0,0 +1,75 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Resource, User +from app.schemas.resource import ResourceCreate + + +async def create_resource( + db: AsyncSession, + class_id: int, + uploader_id: int, + data: ResourceCreate, + file_url: str, + file_type: str, + file_size: int, +) -> Resource: + resource = Resource( + class_id=class_id, + uploader_id=uploader_id, + title=data.title, + description=data.description, + file_url=file_url, + file_type=file_type, + file_size=file_size, + category=data.category, + ) + db.add(resource) + await db.commit() + await db.refresh(resource) + return resource + + +async def list_resources( + db: AsyncSession, class_id: int, category: str | None = None, page: int = 1, page_size: int = 20 +) -> tuple[list[Resource], int]: + query = select(Resource).where(Resource.class_id == class_id) + count_query = select(func.count(Resource.id)).where(Resource.class_id == class_id) + + if category: + query = query.where(Resource.category == category) + count_query = count_query.where(Resource.category == category) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.options(selectinload(Resource.uploader)) + .order_by(Resource.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + resources = list(result.scalars().all()) + return resources, total + + +async def get_resource_by_id(db: AsyncSession, resource_id: int) -> Resource | None: + result = await db.execute( + select(Resource) + .options(selectinload(Resource.uploader)) + .where(Resource.id == resource_id) + ) + return result.scalar_one_or_none() + + +async def increment_download_count(db: AsyncSession, resource: Resource) -> Resource: + resource.download_count += 1 + await db.commit() + await db.refresh(resource) + return resource + + +async def delete_resource(db: AsyncSession, resource: Resource): + await db.delete(resource) + await db.commit() diff --git a/backend/app/services/roster_service.py b/backend/app/services/roster_service.py new file mode 100644 index 0000000..b0b2fc0 --- /dev/null +++ b/backend/app/services/roster_service.py @@ -0,0 +1,125 @@ +import secrets + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import StudentRoster, Class_ + + +def generate_invite_code(length: int = 8) -> str: + chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + return "".join(secrets.choice(chars) for _ in range(length)) + + +async def ensure_invite_code(db: AsyncSession, class_id: int) -> str: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + class_ = result.scalar_one_or_none() + if class_ is None: + return "" + if not class_.invite_code: + class_.invite_code = generate_invite_code() + await db.commit() + await db.refresh(class_) + return class_.invite_code + + +async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + class_ = result.scalar_one_or_none() + if class_ is None: + return "" + class_.invite_code = generate_invite_code() + await db.commit() + await db.refresh(class_) + return class_.invite_code + + +async def import_roster( + db: AsyncSession, class_id: int, entries: list[dict] +) -> int: + existing_ids: set[str] = set() + result = await db.execute( + select(StudentRoster.student_id).where(StudentRoster.class_id == class_id) + ) + for row in result.all(): + existing_ids.add(row[0]) + + count = 0 + for entry in entries: + sid = entry.get("student_id", "").strip() + name = entry.get("name", "").strip() + if not sid or not name or sid in existing_ids: + continue + roster = StudentRoster(class_id=class_id, student_id=sid, name=name) + db.add(roster) + existing_ids.add(sid) + count += 1 + await db.commit() + return count + + +async def get_roster( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50 +) -> tuple[list[StudentRoster], int]: + query = select(StudentRoster).where(StudentRoster.class_id == class_id) + count_query = select(func.count(StudentRoster.id)).where( + StudentRoster.class_id == class_id + ) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(StudentRoster.student_id) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def validate_registration( + db: AsyncSession, invite_code: str, student_id: str +) -> StudentRoster | None: + class_result = await db.execute( + select(Class_).where(Class_.invite_code == invite_code) + ) + class_ = class_result.scalar_one_or_none() + if class_ is None: + return None + + roster_result = await db.execute( + select(StudentRoster).where( + StudentRoster.class_id == class_.id, + StudentRoster.student_id == student_id, + StudentRoster.status == "unregistered", + ) + ) + return roster_result.scalar_one_or_none() + + +async def delete_roster_entry(db: AsyncSession, roster_id: int) -> bool: + result = await db.execute( + select(StudentRoster).where(StudentRoster.id == roster_id) + ) + entry = result.scalar_one_or_none() + if entry is None: + return False + if entry.status == "registered": + return False + await db.delete(entry) + await db.commit() + return True + + +async def clear_unregistered_roster(db: AsyncSession, class_id: int) -> int: + result = await db.execute( + select(StudentRoster).where( + StudentRoster.class_id == class_id, + StudentRoster.status == "unregistered", + ) + ) + entries = list(result.scalars().all()) + for entry in entries: + await db.delete(entry) + await db.commit() + return len(entries) diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py new file mode 100644 index 0000000..e7f4a60 --- /dev/null +++ b/backend/app/services/schedule_service.py @@ -0,0 +1,93 @@ +from datetime import datetime, timezone + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Schedule +from app.schemas.schedule import ScheduleCreate, ScheduleUpdate +from app.services.notification_service import create_notifications_for_class + + +async def create_schedule( + db: AsyncSession, class_id: int, data: ScheduleCreate +) -> Schedule: + item = Schedule( + class_id=class_id, + **data.model_dump(), + ) + db.add(item) + await db.commit() + await db.refresh(item) + + # Send notifications + email to class members + time_str = data.start_time.strftime("%Y-%m-%d %H:%M") + location_info = f" · {data.location}" if data.location else "" + await create_notifications_for_class( + db, class_id, "schedule", f"新排期: {data.title}", + content=f"{time_str}{location_info}", + related_id=item.id, + email_subject=f"HKU ICB - 新排期: {data.title}", + email_body=f"

{data.title}

时间: {time_str}{location_info}

", + email_action_path="/schedule", + ) + + return item + + +async def update_schedule( + db: AsyncSession, item: Schedule, data: ScheduleUpdate +) -> Schedule: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + await db.commit() + await db.refresh(item) + return item + + +async def delete_schedule(db: AsyncSession, item: Schedule): + await db.delete(item) + await db.commit() + + +async def get_schedule_by_id(db: AsyncSession, schedule_id: int) -> Schedule | None: + result = await db.execute(select(Schedule).where(Schedule.id == schedule_id)) + return result.scalar_one_or_none() + + +async def list_schedules( + db: AsyncSession, + class_id: int, + schedule_type: str | None = None, + page: int = 1, + page_size: int = 50, +) -> tuple[list[Schedule], int]: + query = select(Schedule).where(Schedule.class_id == class_id) + count_query = select(func.count(Schedule.id)).where(Schedule.class_id == class_id) + + if schedule_type: + query = query.where(Schedule.type == schedule_type) + count_query = count_query.where(Schedule.type == schedule_type) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(Schedule.start_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = list(result.scalars().all()) + return items, total + + +async def get_upcoming_schedules( + db: AsyncSession, class_id: int, limit: int = 10 +) -> list[Schedule]: + now = datetime.now(timezone.utc) + result = await db.execute( + select(Schedule) + .where(Schedule.class_id == class_id, Schedule.start_time >= now) + .order_by(Schedule.start_time.asc()) + .limit(limit) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py new file mode 100644 index 0000000..dc29b3a --- /dev/null +++ b/backend/app/services/timeline_service.py @@ -0,0 +1,177 @@ +import json +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Timeline, TimelineLike, TimelineComment, User +from app.schemas.timeline import TimelineCreate, TimelineUpdate, TimelineCommentCreate +from app.services.notification_service import create_notifications_for_class + + +async def create_timeline( + db: AsyncSession, class_id: int, author_id: int, data: TimelineCreate +) -> Timeline: + post = Timeline( + class_id=class_id, + author_id=author_id, + title=data.title, + content=data.content, + ) + db.add(post) + await db.commit() + await db.refresh(post) + + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") + await create_notifications_for_class( + db, class_id, "timeline", f"新动态: {data.title}", + content=content_preview, + related_id=post.id, + email_subject=f"HKU ICB - 新动态: {data.title}", + email_body=f"

{content_preview}

" if content_preview else None, + email_action_path="/timeline", + ) + + return post + + +async def update_timeline( + db: AsyncSession, post: Timeline, data: TimelineUpdate +) -> Timeline: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(post, field, value) + await db.commit() + await db.refresh(post) + return post + + +async def delete_timeline(db: AsyncSession, post: Timeline): + await db.delete(post) + await db.commit() + + +async def get_timeline_by_id(db: AsyncSession, post_id: int) -> Timeline | None: + result = await db.execute( + select(Timeline) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments).selectinload(TimelineComment.author), + ) + .where(Timeline.id == post_id) + ) + return result.scalar_one_or_none() + + +async def list_timelines( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Timeline], int]: + total_result = await db.execute( + select(func.count(Timeline.id)).where(Timeline.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Timeline) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments), + ) + .where(Timeline.class_id == class_id) + .order_by(Timeline.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + posts = list(result.scalars().all()) + return posts, total + + +async def add_images_to_timeline(db: AsyncSession, post: Timeline, urls: list[str]): + existing = post.get_image_urls_list() + existing.extend(urls) + post.set_image_urls_list(existing) + await db.commit() + await db.refresh(post) + + +async def toggle_like(db: AsyncSession, post_id: int, user_id: int) -> dict: + result = await db.execute( + select(TimelineLike).where( + TimelineLike.post_id == post_id, + TimelineLike.user_id == user_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + await db.delete(existing) + await db.commit() + # Count remaining likes + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": False, "like_count": count_result.scalar() or 0} + else: + like = TimelineLike(post_id=post_id, user_id=user_id) + db.add(like) + await db.commit() + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": True, "like_count": count_result.scalar() or 0} + + +async def create_comment( + db: AsyncSession, post_id: int, author_id: int, data: TimelineCommentCreate +) -> TimelineComment: + comment = TimelineComment( + post_id=post_id, + author_id=author_id, + content=data.content, + ) + db.add(comment) + await db.commit() + await db.refresh(comment) + + # Load author relationship for response + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment.id) + ) + return result.scalar_one() + + +async def delete_comment(db: AsyncSession, comment: TimelineComment): + await db.delete(comment) + await db.commit() + + +async def get_comment_by_id(db: AsyncSession, comment_id: int) -> TimelineComment | None: + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment_id) + ) + return result.scalar_one_or_none() + + +async def list_comments( + db: AsyncSession, post_id: int, page: int = 1, page_size: int = 50 +) -> tuple[list[TimelineComment], int]: + total_result = await db.execute( + select(func.count(TimelineComment.id)).where(TimelineComment.post_id == post_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.post_id == post_id) + .order_by(TimelineComment.created_at.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..a951866 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,80 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.schemas.user import UserOut, UserUpdate + + +async def get_user_by_email(db: AsyncSession, email: str) -> User | None: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def update_profile(db: AsyncSession, user: User, data: UserUpdate) -> User: + update_data = data.model_dump(exclude_unset=True) + + # Handle email change with uniqueness check + if "email" in update_data and update_data["email"] != user.email: + existing = await db.execute( + select(User).where(User.email == update_data["email"]) + ) + if existing.scalar_one_or_none(): + raise ValueError("该邮箱已被使用") + user.email = update_data.pop("email") + + for field, value in update_data.items(): + setattr(user, field, value) + await db.commit() + await db.refresh(user) + return user + + +async def update_user_status( + db: AsyncSession, user_id: int, status: str, role: str | None = None +) -> User | None: + user = await get_user_by_id(db, user_id) + if user is None: + return None + user.status = status + if role is not None: + user.role = role + await db.commit() + await db.refresh(user) + return user + + +async def list_users( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + status: str | None = None, + role: str | None = None, +) -> tuple[list[User], int]: + query = select(User) + count_query = select(func.count(User.id)) + + if class_id is not None: + query = query.where(User.class_id == class_id) + count_query = count_query.where(User.class_id == class_id) + if status is not None: + query = query.where(User.status == status) + count_query = count_query.where(User.status == status) + if role is not None: + query = query.where(User.role == role) + count_query = count_query.where(User.role == role) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + query = query.order_by(User.created_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + users = list(result.scalars().all()) + + return users, total diff --git a/backend/app/services/vote_service.py b/backend/app/services/vote_service.py new file mode 100644 index 0000000..8173ebe --- /dev/null +++ b/backend/app/services/vote_service.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Vote, VoteOption, VoteResponse, User +from app.schemas.vote import VoteCreate +from app.services.notification_service import create_notifications_for_class + + +async def create_vote( + db: AsyncSession, class_id: int, creator_id: int, data: VoteCreate +) -> Vote: + vote = Vote( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + vote_type=data.vote_type, + is_anonymous=data.is_anonymous, + max_choices=data.max_choices, + deadline=data.deadline, + ) + db.add(vote) + await db.flush() + + for i, opt_text in enumerate(data.options): + option = VoteOption(vote_id=vote.id, content=opt_text, sort_order=i) + db.add(option) + + await db.commit() + await db.refresh(vote) + + # Load options for the returned object + result = await db.execute( + select(Vote) + .options(selectinload(Vote.options)) + .where(Vote.id == vote.id) + ) + vote = result.scalar_one() + + await create_notifications_for_class( + db, class_id, "vote", f"新投票: {data.title}", + content=data.description, + related_id=vote.id, + email_subject=f"HKU ICB - 新投票: {data.title}", + email_body=f"

{data.description or data.title}

", + email_action_path="/votes", + ) + + return vote + + +async def get_vote_by_id(db: AsyncSession, vote_id: int) -> Vote | None: + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses).selectinload(VoteResponse.voter), + ) + .where(Vote.id == vote_id) + ) + return result.scalar_one_or_none() + + +async def list_votes( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Vote], int]: + total_result = await db.execute( + select(func.count(Vote.id)).where(Vote.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses), + ) + .where(Vote.class_id == class_id) + .order_by(Vote.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def submit_vote( + db: AsyncSession, vote_id: int, voter_id: int, option_ids: list[int] +) -> None: + vote_result = await db.execute(select(Vote).where(Vote.id == vote_id)) + vote = vote_result.scalar_one_or_none() + if vote is None: + raise ValueError("投票不存在") + if vote.status != "open": + raise ValueError("投票已关闭") + if vote.deadline and datetime.now() > vote.deadline: + raise ValueError("投票已过截止日期") + + # Check if already voted + existing = await db.execute( + select(VoteResponse).where( + VoteResponse.vote_id == vote_id, + VoteResponse.voter_id == voter_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经投过票了") + + # Validate option count + if vote.vote_type == "single" and len(option_ids) != 1: + raise ValueError("单选投票只能选择一个选项") + if vote.vote_type == "multiple" and len(option_ids) > vote.max_choices: + raise ValueError(f"最多选择 {vote.max_choices} 个选项") + + # Validate option_ids belong to this vote + for oid in option_ids: + opt_result = await db.execute( + select(VoteOption).where(VoteOption.id == oid, VoteOption.vote_id == vote_id) + ) + if opt_result.scalar_one_or_none() is None: + raise ValueError(f"选项 {oid} 不属于此投票") + + for oid in option_ids: + response = VoteResponse(vote_id=vote_id, option_id=oid, voter_id=voter_id) + db.add(response) + + await db.commit() + + +async def close_vote(db: AsyncSession, vote: Vote) -> Vote: + vote.status = "closed" + await db.commit() + await db.refresh(vote) + return vote + + +async def delete_vote(db: AsyncSession, vote: Vote): + await db.delete(vote) + await db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..b8033a9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +aiosqlite==0.20.0 +pydantic[email]==2.10.3 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +alembic==1.14.0 +python-multipart==0.0.20 +aiosmtplib==3.0.2 +cos-python-sdk-v5==1.9.30 +httpx==0.28.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..901d45f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + backend: + build: ./backend + restart: unless-stopped + env_file: ./backend/.env + environment: + - CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db + - CH_FRONTEND_URL=http://localhost + volumes: + - classhub-data:/app/data + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + + frontend: + build: + context: ./frontend + args: + - NEXT_PUBLIC_API_URL= + restart: unless-stopped + expose: + - "3000" + depends_on: + - backend + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + +volumes: + classhub-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..5522d9f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +out/ +.env.local +.env*.local +*.tsbuildinfo +.git/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..af6d9b4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG NEXT_PUBLIC_API_URL="" +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..8d886db --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..bf85763 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + allowedDevOrigins: ["192.168.31.172"], +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e1336b1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.8.0", + "next": "16.2.3", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "shadcn": "^4.2.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/(app)/admin/classes/page.tsx b/frontend/src/app/(app)/admin/classes/page.tsx new file mode 100644 index 0000000..df728cc --- /dev/null +++ b/frontend/src/app/(app)/admin/classes/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RoleGuard } from "@/components/role-guard"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { ErrorState } from "@/components/error-state"; +import { toast } from "sonner"; +import type { ClassInfo } from "@/lib/types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export default function ClassesPage() { + const { user } = useAuth(); + const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formName, setFormName] = useState(""); + const [formYear, setFormYear] = useState(new Date().getFullYear().toString()); + const [formDesc, setFormDesc] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const loadClasses = async () => { + setError(null); + try { + const res = await fetchAPI("/api/classes/"); + setClasses(res.items || []); + } catch (err: any) { + setError(err.message || "加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadClasses(); + }, []); + + const resetForm = () => { + setFormName(""); + setFormYear(new Date().getFullYear().toString()); + setFormDesc(""); + setEditingId(null); + }; + + const openCreate = () => { + resetForm(); + setDialogOpen(true); + }; + + const openEdit = (cls: ClassInfo) => { + setEditingId(cls.id); + setFormName(cls.name); + setFormYear(String(cls.cohort_year)); + setFormDesc(cls.description || ""); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!formName.trim()) return; + setSubmitting(true); + try { + if (editingId) { + await putAPI(`/api/classes/${editingId}`, { + name: formName, + cohort_year: parseInt(formYear), + description: formDesc || null, + }); + toast.success("班级已更新"); + } else { + await postAPI("/api/classes/", { + name: formName, + cohort_year: parseInt(formYear), + description: formDesc || null, + }); + toast.success("班级已创建"); + } + setDialogOpen(false); + resetForm(); + loadClasses(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } finally { + setSubmitting(false); + } + }; + + const [deleteTarget, setDeleteTarget] = useState(null); + + const handleDelete = async (id: number) => { + try { + await deleteAPI(`/api/classes/${id}`); + toast.success("已删除"); + setDeleteTarget(null); + loadClasses(); + } catch (err: any) { + toast.error(err.message || "删除失败"); + } + }; + + return ( +
+
+
+

班级管理

+

创建和管理班级

+
+ + { setDialogOpen(open); if (!open) resetForm(); }}> + + + + + + {editingId ? "编辑班级" : "创建班级"} + +
+
+ + setFormName(e.target.value)} + placeholder="如:HKU ICB FinTech 2025" + /> +
+
+ + setFormYear(e.target.value)} + /> +
+
+ + setFormDesc(e.target.value)} + placeholder="班级描述" + /> +
+ +
+
+
+
+
+ + {loading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : error ? ( + + ) : ( +
+ {classes.map((cls) => ( + + +
+

{cls.name}

+

+ {cls.cohort_year}届 · {cls.member_count} 名成员 +

+
+ +
+ + +
+
+
+
+ ))} +
+ )} + {/* Delete confirmation */} + { if (!open) setDeleteTarget(null); }} + title="删除班级" + description="确定删除该班级?相关成员数据也将清除,此操作不可恢复。" + confirmText="删除" + variant="destructive" + onConfirm={() => deleteTarget && handleDelete(deleteTarget)} + /> +
+ ); +} diff --git a/frontend/src/app/(app)/admin/members/page.tsx b/frontend/src/app/(app)/admin/members/page.tsx new file mode 100644 index 0000000..43baa29 --- /dev/null +++ b/frontend/src/app/(app)/admin/members/page.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { useActiveClass } from "@/hooks/use-active-class"; +import { fetchAPI, putAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { ErrorState } from "@/components/error-state"; +import { Pagination } from "@/components/pagination"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { toast } from "sonner"; +import type { UserListItem, RosterEntry } from "@/lib/types"; +import { USER_STATUS, ROLES } from "@/lib/constants"; + +export default function MembersPage() { + const { user } = useAuth(); + const { activeClassId } = useActiveClass(); + + // Tab state + const [activeTab, setActiveTab] = useState<"members" | "roster">("roster"); + + // Members state + const [members, setMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(true); + const [membersError, setMembersError] = useState(null); + const [membersPage, setMembersPage] = useState(1); + const [membersTotalPages, setMembersTotalPages] = useState(1); + const [filter, setFilter] = useState("all"); + + // Roster state + const [roster, setRoster] = useState([]); + const [rosterLoading, setRosterLoading] = useState(true); + const [rosterError, setRosterError] = useState(null); + const [rosterPage, setRosterPage] = useState(1); + const [rosterTotalPages, setRosterTotalPages] = useState(1); + const [inviteCode, setInviteCode] = useState(""); + const [importOpen, setImportOpen] = useState(false); + const [importText, setImportText] = useState(""); + const [importing, setImporting] = useState(false); + const [clearTarget, setClearTarget] = useState(false); + const fileInputRef = useRef(null); + + const isSuperAdmin = user?.role === "super_admin"; + + // Load members + const loadMembers = async () => { + if (!activeClassId) { + setMembersLoading(false); + return; + } + setMembersLoading(true); + setMembersError(null); + try { + const res = await fetchAPI(`/api/classes/${activeClassId}/members`, { + page: String(membersPage), + page_size: "20", + }); + setMembers(res.items || []); + setMembersTotalPages(res.total_pages || 1); + } catch (err: any) { + setMembersError(err.message || "加载失败"); + } finally { + setMembersLoading(false); + } + }; + + // Load roster + const loadRoster = async () => { + if (!activeClassId) { + setRosterLoading(false); + return; + } + setRosterLoading(true); + setRosterError(null); + try { + const [rosterRes, codeRes] = await Promise.all([ + fetchAPI(`/api/classes/${activeClassId}/roster`, { + page: String(rosterPage), + page_size: "50", + }), + fetchAPI(`/api/classes/${activeClassId}/invite-code`), + ]); + setRoster(rosterRes.items || []); + setRosterTotalPages(rosterRes.total_pages || 1); + setInviteCode(codeRes.invite_code || ""); + } catch (err: any) { + setRosterError(err.message || "加载失败"); + } finally { + setRosterLoading(false); + } + }; + + useEffect(() => { + if (!activeClassId) return; + if (activeTab === "members") loadMembers(); + else loadRoster(); + }, [activeClassId, activeTab, membersPage, rosterPage]); + + // Member actions + const handleStatusChange = async ( + userId: number, + newStatus: string, + role?: string + ) => { + try { + await putAPI(`/api/users/${userId}/status`, { + status: newStatus, + role: role || undefined, + }); + toast.success( + `已更新状态为 ${USER_STATUS[newStatus as keyof typeof USER_STATUS] || newStatus}` + ); + loadMembers(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } + }; + + const handleRoleChange = async (userId: number, newRole: string) => { + try { + await putAPI(`/api/users/${userId}/status`, { + status: "approved", + role: newRole, + }); + toast.success( + `角色已更新为 ${ROLES[newRole as keyof typeof ROLES] || newRole}` + ); + loadMembers(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } + }; + + const getStatusBadge = (status: string) => { + const variants: Record = { + approved: "default", + disabled: "secondary", + }; + return ( + + {USER_STATUS[status as keyof typeof USER_STATUS] || status} + + ); + }; + + // Roster actions + const handleCopyCode = () => { + navigator.clipboard.writeText(inviteCode); + toast.success("邀请码已复制"); + }; + + const handleRegenerateCode = async () => { + try { + const res = await postAPI(`/api/classes/${activeClassId}/invite-code/regenerate`); + setInviteCode(res.invite_code); + toast.success("邀请码已重新生成"); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } + }; + + const handleTextImport = async () => { + if (!importText.trim()) return; + setImporting(true); + try { + // Parse text: each line is "学号 姓名" or "学号,姓名" or "学号\t姓名" + const entries: { student_id: string; name: string }[] = []; + const lines = importText.trim().split("\n"); + for (const line of lines) { + const parts = line.trim().split(/[,\t,\s]+/); + if (parts.length >= 2) { + entries.push({ student_id: parts[0], name: parts.slice(1).join("") }); + } + } + if (entries.length === 0) { + toast.error("未解析到有效数据,每行格式:学号 姓名"); + return; + } + const res = await postAPI(`/api/classes/${activeClassId}/roster/import`, { + entries, + }); + toast.success(res.message); + setImportOpen(false); + setImportText(""); + loadRoster(); + } catch (err: any) { + toast.error(err.message || "导入失败"); + } finally { + setImporting(false); + } + }; + + const handleFileUpload = async () => { + const fileInput = fileInputRef.current; + if (!fileInput?.files?.length) return; + setImporting(true); + try { + const formData = new FormData(); + formData.append("file", fileInput.files[0]); + const res = await uploadAPI(`/api/classes/${activeClassId}/roster/upload`, formData); + toast.success((res as any).message); + setImportOpen(false); + if (fileInput) fileInput.value = ""; + loadRoster(); + } catch (err: any) { + toast.error(err.message || "上传失败"); + } finally { + setImporting(false); + } + }; + + const handleDeleteRoster = async (rosterId: number) => { + try { + await deleteAPI(`/api/classes/${activeClassId}/roster/${rosterId}`); + toast.success("已删除"); + loadRoster(); + } catch (err: any) { + toast.error(err.message || "删除失败"); + } + }; + + const handleClearRoster = async () => { + try { + const res = await postAPI(`/api/classes/${activeClassId}/roster/clear`); + toast.success(res.message); + setClearTarget(false); + loadRoster(); + } catch (err: any) { + toast.error(err.message || "操作失败"); + } + }; + + const filteredMembers = + filter === "all" ? members : members.filter((m) => m.status === filter); + + return ( +
+
+

成员与花名册

+

管理班级花名册、邀请码和已注册成员

+
+ + {!activeClassId ? ( +
+ {isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"} +
+ ) : ( + <> + {/* Tab switcher */} +
+ + +
+ + {activeTab === "roster" && ( +
+ {/* Invite code section */} + + +
+
+

班级邀请码

+

+ {inviteCode || "—"} +

+
+
+ + +
+
+

+ 将此邀请码分享给学生,学生注册时输入邀请码+学号即可加入班级 +

+
+
+ + {/* Import actions */} +
+ { setImportOpen(open); if (!open) setImportText(""); }}> + + + + + + 导入花名册 + +
+
+ +