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 index c3da64b..367fb5c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,33 +1,33 @@ # Service CH_HOST=0.0.0.0 CH_PORT=8000 -CH_DEBUG=true +CH_DEBUG=false -# Database -CH_DATABASE_URL=sqlite+aiosqlite:///./classhub.db +# Database (Docker 部署时使用 volume 路径) +CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db -# JWT +# JWT (务必修改 secret) CH_JWT_SECRET=change-me-in-production CH_JWT_EXPIRY_HOURS=72 -# Tencent COS -CH_COS_SECRET_ID=AKIDUxR3TfeWDbqFQQDn9INJ1U8annY7TbWN -CH_COS_SECRET_KEY=jJitIUTFyf5WvDPXiigS2PaaMtCJSQCn +# 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=hku-icb-1311994147 -CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com +CH_COS_BUCKET=your-bucket-name +CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com -# SMTP Email -CH_SMTP_HOST=gz-smtp.qcloudmail.com +# SMTP Email (邮件通知) +CH_SMTP_HOST=smtp.example.com CH_SMTP_PORT=465 -CH_SMTP_USER=noreply@hkuicb.info -CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69 -CH_SMTP_FROM_EMAIL=noreply@hkuicb.info +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 -CH_FRONTEND_URL=http://localhost:3000 +# Frontend URL (CORS 用) +CH_FRONTEND_URL=http://your-server-ip -# Super Admin Seed -CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info -CH_SUPER_ADMIN_PASSWORD=admin123 +# 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/app/api/assignments.py b/backend/app/api/assignments.py index ddb95c0..a7ec865 100644 --- a/backend/app/api/assignments.py +++ b/backend/app/api/assignments.py @@ -1,9 +1,11 @@ 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 +from app.db.models import User, Class_ from app.schemas.assignment import ( AssignmentCreate, AssignmentUpdate, AssignmentOut, AssignmentDetailOut, SubmissionGrade, SubmissionOut, @@ -26,7 +28,15 @@ from app.services.cos_service import upload_file router = APIRouter(prefix="/api/assignments", tags=["assignments"]) -def _build_assignment_out(a: any, user_id: int) -> AssignmentOut: +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( @@ -40,6 +50,7 @@ def _build_assignment_out(a: any, user_id: int) -> AssignmentOut: 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, @@ -79,7 +90,8 @@ async def get_assignments( assignments, total = await list_assignments(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size - items = [_build_assignment_out(a, user.id) for a in assignments] + 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) @@ -95,7 +107,8 @@ async def create_new_assignment( raise HTTPException(status_code=400, detail="You are not assigned to a class") assignment = await create_assignment(db, effective_class_id, user.id, data) - return _build_assignment_out(assignment, user.id) + roster_count = await _get_roster_count(db, effective_class_id) + return _build_assignment_out(assignment, user.id, roster_count) @router.post("/{assignment_id}/attachments") @@ -138,7 +151,7 @@ async def get_assignment_detail( 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) + 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": @@ -168,7 +181,7 @@ async def update_existing_assignment( raise HTTPException(status_code=403, detail="Access denied") updated = await update_assignment(db, assignment, data) - return _build_assignment_out(updated, user.id) + return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id)) @router.delete("/{assignment_id}") diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 095b5c0..be8645d 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -53,6 +53,7 @@ class AssignmentOut(BaseModel): attachment_urls: list[str] | None status: str submission_count: int + total_members: int my_submitted: bool created_at: datetime updated_at: datetime 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/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..488b161 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + + client_max_body_size 20m; + + # API requests → backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Frontend + location / { + proxy_pass http://frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Next.js HMR (hot module replacement) WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +}