first commit

This commit is contained in:
aaron 2026-04-11 12:52:23 +08:00
commit bbd50a38b1
41 changed files with 2006 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"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:*)"
]
}
}

41
.gitignore vendored Normal file
View File

@ -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

33
backend/.env.example Normal file
View File

@ -0,0 +1,33 @@
# Service
CH_HOST=0.0.0.0
CH_PORT=8000
CH_DEBUG=true
# Database
CH_DATABASE_URL=sqlite+aiosqlite:///./classhub.db
# JWT
CH_JWT_SECRET=change-me-in-production
CH_JWT_EXPIRY_HOURS=72
# Tencent COS
CH_COS_SECRET_ID=
CH_COS_SECRET_KEY=
CH_COS_REGION=ap-hongkong
CH_COS_BUCKET=
CH_COS_BASE_URL=
# SMTP Email
CH_SMTP_HOST=
CH_SMTP_PORT=465
CH_SMTP_USER=
CH_SMTP_PASSWORD=
CH_SMTP_FROM_EMAIL=
CH_SMTP_FROM_NAME=ClassHub
# Frontend URL
CH_FRONTEND_URL=http://localhost:3000
# Super Admin Seed
CH_SUPER_ADMIN_EMAIL=admin@classhub.com
CH_SUPER_ADMIN_PASSWORD=admin123

36
backend/alembic.ini Normal file
View File

@ -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

57
backend/alembic/env.py Normal file
View File

@ -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 # 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()

View File

@ -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"}

0
backend/app/__init__.py Normal file
View File

View File

74
backend/app/api/auth.py Normal file
View File

@ -0,0 +1,74 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status
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, Class_
from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
from app.schemas.user import TokenResponse, UserOut
from app.services.user_service import register_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register")
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
class_result = await db.execute(select(Class_).where(Class_.id == req.class_id))
if class_result.scalar_one_or_none() is None:
raise HTTPException(status_code=400, detail="Class not found")
user = await register_user(
db=db,
email=req.email,
password_hash=hash_password(req.password),
name=req.name,
class_id=req.class_id,
student_id=req.student_id,
)
return {"message": "Registration submitted. Awaiting admin approval."}
@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 or user.status != "approved":
raise HTTPException(
status_code=401, detail="Invalid credentials or account not approved"
)
if not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
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"}

125
backend/app/api/classes.py Normal file
View File

@ -0,0 +1,125 @@
from fastapi import APIRouter, Depends, HTTPException, 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.class_ import ClassCreate, ClassUpdate, ClassOut
from app.schemas.user import UserListItem
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,
)
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,
)
@router.get("/{class_id}/pending", response_model=PageResponse[UserListItem])
async def get_pending_members(
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 for this class")
members, total = await get_class_members(db, class_id, status="pending", page=page, page_size=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,
)

View File

@ -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)

104
backend/app/api/schedule.py Normal file
View File

@ -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"}

158
backend/app/api/timeline.py Normal file
View File

@ -0,0 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, 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.timeline import TimelineCreate, TimelineUpdate, TimelineOut
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,
)
from app.services.cos_service import upload_image
router = APIRouter(prefix="/api/timeline", tags=["timeline"])
@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 = []
for p in posts:
items.append(
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(),
created_at=p.created_at,
updated_at=p.updated_at,
)
)
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")),
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=[],
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")),
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")
# Verify post belongs to user's class (super_admin can access any)
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: # 10MB limit
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")),
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")
updated = await update_timeline(db, post, data)
return TimelineOut(
id=updated.id,
class_id=updated.class_id,
author_id=updated.author_id,
author_name=user.name,
title=updated.title,
content=updated.content,
image_urls=updated.get_image_urls_list(),
created_at=updated.created_at,
updated_at=updated.updated_at,
)
@router.delete("/{post_id}")
async def delete_existing_timeline(
post_id: int,
user: User = Depends(require_role("super_admin", "class_admin")),
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")
await delete_timeline(db, post)
return {"message": "Timeline post deleted"}

24
backend/app/api/upload.py Normal file
View File

@ -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}

120
backend/app/api/users.py Normal file
View File

@ -0,0 +1,120 @@
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),
):
updated = await update_profile(db, user, data)
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}"}

43
backend/app/config.py Normal file
View File

@ -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 = "ClassHub"
# 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()

View File

31
backend/app/core/auth.py Normal file
View File

@ -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

57
backend/app/core/deps.py Normal file
View File

@ -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 != "approved":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account not approved"
)
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

View File

5
backend/app/db/base.py Normal file
View File

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@ -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)

132
backend/app/db/models.py Normal file
View File

@ -0,0 +1,132 @@
import json
from datetime import datetime
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey, func
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)
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"
)
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)
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"
)
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")
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")

94
backend/app/main.py Normal file
View File

@ -0,0 +1,94 @@
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
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="ClassHub",
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"],
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.get("/api/health")
async def health():
return {"status": "ok", "service": "classhub"}

View File

View File

@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
class_id: int
student_id: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str

View File

@ -0,0 +1,26 @@
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
member_count: int = 0
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -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

View File

@ -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}

View File

@ -0,0 +1,25 @@
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 TimelineOut(BaseModel):
id: int
class_id: int
author_id: int
author_name: str
title: str
content: str | None
image_urls: list[str] | None
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,84 @@
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
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
skills_tags: list[str] | None
wechat_id: 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):
name: str | None = None
industry: str | None = None
company: str | None = None
position: str | None = None
skills_tags: list[str] | None = None
wechat_id: 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

View File

View File

@ -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

View File

@ -0,0 +1,48 @@
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)

View File

@ -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,
skills_tags=user.get_skills_list(),
wechat_id=user.wechat_id if include_contact else None,
avatar_url=user.avatar_url,
bio=user.bio,
)

View File

@ -0,0 +1,59 @@
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"""
<h2>New Registration Pending Approval</h2>
<p><strong>{student_name}</strong> has registered for <strong>{class_name}</strong>.</p>
<p>Please log in to ClassHub to review and approve.</p>
"""
await send_email(admin_email, "ClassHub: New Registration", html)
async def send_approval_notification(student_email: str, approved: bool):
status_text = "approved" if approved else "rejected"
html = f"""
<h2>Registration {status_text.capitalize()}</h2>
<p>Your registration has been <strong>{status_text}</strong>.</p>
{"<p>You can now log in to ClassHub.</p>" if approved else ""}
"""
await send_email(
student_email, f"ClassHub: Registration {status_text.capitalize()}", html
)

View File

@ -0,0 +1,79 @@
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
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)
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())

View File

@ -0,0 +1,72 @@
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, User
from app.schemas.timeline import TimelineCreate, TimelineUpdate
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)
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).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))
.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)

View File

@ -0,0 +1,115 @@
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User, Class_
from app.schemas.user import UserOut, UserUpdate
from app.services.email_service import send_registration_notification
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 register_user(
db: AsyncSession,
email: str,
password_hash: str,
name: str,
class_id: int,
student_id: str | None = None,
) -> User:
user = User(
email=email,
password_hash=password_hash,
name=name,
student_id=student_id,
role="student",
status="pending",
class_id=class_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
# Notify class admins
admins_result = await db.execute(
select(User).where(
User.class_id == class_id,
User.role.in_(["class_admin", "super_admin"]),
User.status == "approved",
)
)
class_result = await db.execute(select(Class_).where(Class_.id == class_id))
class_ = class_result.scalar_one_or_none()
class_name = class_.name if class_ else "Unknown"
for admin in admins_result.scalars():
await send_registration_notification(admin.email, name, class_name)
return user
async def update_profile(db: AsyncSession, user: User, data: UserUpdate) -> User:
update_data = data.model_dump(exclude_unset=True)
if "skills_tags" in update_data and update_data["skills_tags"] is not None:
import json
user.skills_tags = json.dumps(
update_data.pop("skills_tags"), ensure_ascii=False
)
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

13
backend/requirements.txt Normal file
View File

@ -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

1
frontend Submodule

@ -0,0 +1 @@
Subproject commit bd03dcfdc46fb2dbfb67fa72401230a4b516fbd1