From bbd50a38b18bd7ef109f75fbdfbe777417ff7140 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 11 Apr 2026 12:52:23 +0800 Subject: [PATCH] first commit --- .claude/settings.local.json | 16 +++ .gitignore | 41 ++++++ backend/.env.example | 33 +++++ 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/auth.py | 74 ++++++++++ backend/app/api/classes.py | 125 +++++++++++++++++ backend/app/api/directory.py | 57 ++++++++ backend/app/api/schedule.py | 104 ++++++++++++++ backend/app/api/timeline.py | 158 ++++++++++++++++++++++ backend/app/api/upload.py | 24 ++++ backend/app/api/users.py | 120 ++++++++++++++++ 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 | 132 ++++++++++++++++++ backend/app/main.py | 94 +++++++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/auth.py | 19 +++ backend/app/schemas/class_.py | 26 ++++ backend/app/schemas/common.py | 24 ++++ backend/app/schemas/schedule.py | 35 +++++ backend/app/schemas/timeline.py | 25 ++++ backend/app/schemas/user.py | 84 ++++++++++++ backend/app/services/__init__.py | 0 backend/app/services/class_service.py | 79 +++++++++++ backend/app/services/cos_service.py | 48 +++++++ backend/app/services/directory_service.py | 77 +++++++++++ backend/app/services/email_service.py | 59 ++++++++ backend/app/services/schedule_service.py | 79 +++++++++++ backend/app/services/timeline_service.py | 72 ++++++++++ backend/app/services/user_service.py | 115 ++++++++++++++++ backend/requirements.txt | 13 ++ frontend | 1 + 41 files changed, 2006 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 backend/.env.example 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/auth.py create mode 100644 backend/app/api/classes.py create mode 100644 backend/app/api/directory.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/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/auth.py create mode 100644 backend/app/schemas/class_.py create mode 100644 backend/app/schemas/common.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/services/__init__.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/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/requirements.txt create mode 160000 frontend diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bc4d8bc --- /dev/null +++ b/.claude/settings.local.json @@ -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:*)" + ] + } +} 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/.env.example b/backend/.env.example new file mode 100644 index 0000000..54abed1 --- /dev/null +++ b/backend/.env.example @@ -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 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..96e3e0c --- /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 # 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/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..0f5caa6 --- /dev/null +++ b/backend/app/api/auth.py @@ -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"} diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py new file mode 100644 index 0000000..112c365 --- /dev/null +++ b/backend/app/api/classes.py @@ -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, + ) 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/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..47dfdcb --- /dev/null +++ b/backend/app/api/timeline.py @@ -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"} 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..e008035 --- /dev/null +++ b/backend/app/api/users.py @@ -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}"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c4e8995 --- /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 = "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() 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..ee713f2 --- /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 != "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 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..9bba718 --- /dev/null +++ b/backend/app/db/models.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2a3907f --- /dev/null +++ b/backend/app/main.py @@ -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"} 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/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..c245337 --- /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): + email: EmailStr + password: str + name: str + class_id: int + student_id: 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..3f78265 --- /dev/null +++ b/backend/app/schemas/class_.py @@ -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} 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/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..0538b8f --- /dev/null +++ b/backend/app/schemas/timeline.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..04487b1 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 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/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..298ca3c --- /dev/null +++ b/backend/app/services/cos_service.py @@ -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) diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py new file mode 100644 index 0000000..9cafd64 --- /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, + skills_tags=user.get_skills_list(), + wechat_id=user.wechat_id 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..cec043a --- /dev/null +++ b/backend/app/services/email_service.py @@ -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""" +
{student_name} has registered for {class_name}.
+Please log in to ClassHub to review and approve.
+ """ + 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""" +Your registration has been {status_text}.
+ {"You can now log in to ClassHub.
" if approved else ""} + """ + await send_email( + student_email, f"ClassHub: Registration {status_text.capitalize()}", html + ) diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py new file mode 100644 index 0000000..44fa731 --- /dev/null +++ b/backend/app/services/schedule_service.py @@ -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()) diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py new file mode 100644 index 0000000..0aef37d --- /dev/null +++ b/backend/app/services/timeline_service.py @@ -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) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..c8787de --- /dev/null +++ b/backend/app/services/user_service.py @@ -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 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/frontend b/frontend new file mode 160000 index 0000000..bd03dcf --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit bd03dcfdc46fb2dbfb67fa72401230a4b516fbd1