docker
This commit is contained in:
parent
a39fd6186c
commit
8a35c0b97f
10
backend/.dockerignore
Normal file
10
backend/.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
alembic/versions/
|
||||||
@ -1,33 +1,33 @@
|
|||||||
# Service
|
# Service
|
||||||
CH_HOST=0.0.0.0
|
CH_HOST=0.0.0.0
|
||||||
CH_PORT=8000
|
CH_PORT=8000
|
||||||
CH_DEBUG=true
|
CH_DEBUG=false
|
||||||
|
|
||||||
# Database
|
# Database (Docker 部署时使用 volume 路径)
|
||||||
CH_DATABASE_URL=sqlite+aiosqlite:///./classhub.db
|
CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
|
||||||
|
|
||||||
# JWT
|
# JWT (务必修改 secret)
|
||||||
CH_JWT_SECRET=change-me-in-production
|
CH_JWT_SECRET=change-me-in-production
|
||||||
CH_JWT_EXPIRY_HOURS=72
|
CH_JWT_EXPIRY_HOURS=72
|
||||||
|
|
||||||
# Tencent COS
|
# Tencent COS (对象存储)
|
||||||
CH_COS_SECRET_ID=AKIDUxR3TfeWDbqFQQDn9INJ1U8annY7TbWN
|
CH_COS_SECRET_ID=your-cos-secret-id
|
||||||
CH_COS_SECRET_KEY=jJitIUTFyf5WvDPXiigS2PaaMtCJSQCn
|
CH_COS_SECRET_KEY=your-cos-secret-key
|
||||||
CH_COS_REGION=ap-guangzhou
|
CH_COS_REGION=ap-guangzhou
|
||||||
CH_COS_BUCKET=hku-icb-1311994147
|
CH_COS_BUCKET=your-bucket-name
|
||||||
CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com
|
CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com
|
||||||
|
|
||||||
# SMTP Email
|
# SMTP Email (邮件通知)
|
||||||
CH_SMTP_HOST=gz-smtp.qcloudmail.com
|
CH_SMTP_HOST=smtp.example.com
|
||||||
CH_SMTP_PORT=465
|
CH_SMTP_PORT=465
|
||||||
CH_SMTP_USER=noreply@hkuicb.info
|
CH_SMTP_USER=noreply@example.com
|
||||||
CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69
|
CH_SMTP_PASSWORD=your-smtp-password
|
||||||
CH_SMTP_FROM_EMAIL=noreply@hkuicb.info
|
CH_SMTP_FROM_EMAIL=noreply@example.com
|
||||||
CH_SMTP_FROM_NAME=HKU ICB Class Hub
|
CH_SMTP_FROM_NAME=HKU ICB Class Hub
|
||||||
|
|
||||||
# Frontend URL
|
# Frontend URL (CORS 用)
|
||||||
CH_FRONTEND_URL=http://localhost:3000
|
CH_FRONTEND_URL=http://your-server-ip
|
||||||
|
|
||||||
# Super Admin Seed
|
# Super Admin Seed (首次启动自动创建)
|
||||||
CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info
|
CH_SUPER_ADMIN_EMAIL=admin@example.com
|
||||||
CH_SUPER_ADMIN_PASSWORD=admin123
|
CH_SUPER_ADMIN_PASSWORD=change-me-please
|
||||||
|
|||||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@ -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"]
|
||||||
@ -1,9 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.db.database import get_db
|
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 (
|
from app.schemas.assignment import (
|
||||||
AssignmentCreate, AssignmentUpdate, AssignmentOut,
|
AssignmentCreate, AssignmentUpdate, AssignmentOut,
|
||||||
AssignmentDetailOut, SubmissionGrade, SubmissionOut,
|
AssignmentDetailOut, SubmissionGrade, SubmissionOut,
|
||||||
@ -26,7 +28,15 @@ from app.services.cos_service import upload_file
|
|||||||
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
|
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
|
submission_count = len(a.submissions) if a.submissions else 0
|
||||||
my_submitted = any(s.student_id == user_id for s in (a.submissions or []))
|
my_submitted = any(s.student_id == user_id for s in (a.submissions or []))
|
||||||
return AssignmentOut(
|
return AssignmentOut(
|
||||||
@ -40,6 +50,7 @@ def _build_assignment_out(a: any, user_id: int) -> AssignmentOut:
|
|||||||
attachment_urls=a.get_attachment_urls_list(),
|
attachment_urls=a.get_attachment_urls_list(),
|
||||||
status=a.status,
|
status=a.status,
|
||||||
submission_count=submission_count,
|
submission_count=submission_count,
|
||||||
|
total_members=total_members,
|
||||||
my_submitted=my_submitted,
|
my_submitted=my_submitted,
|
||||||
created_at=a.created_at,
|
created_at=a.created_at,
|
||||||
updated_at=a.updated_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)
|
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // 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)
|
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")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
|
||||||
assignment = await create_assignment(db, effective_class_id, user.id, data)
|
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")
|
@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:
|
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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
|
# Student only sees their own submission
|
||||||
if user.role == "student":
|
if user.role == "student":
|
||||||
@ -168,7 +181,7 @@ async def update_existing_assignment(
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
updated = await update_assignment(db, assignment, data)
|
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}")
|
@router.delete("/{assignment_id}")
|
||||||
|
|||||||
@ -53,6 +53,7 @@ class AssignmentOut(BaseModel):
|
|||||||
attachment_urls: list[str] | None
|
attachment_urls: list[str] | None
|
||||||
status: str
|
status: str
|
||||||
submission_count: int
|
submission_count: int
|
||||||
|
total_members: int
|
||||||
my_submitted: bool
|
my_submitted: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@ -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:
|
||||||
29
nginx/nginx.conf
Normal file
29
nginx/nginx.conf
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user