diff --git a/backend/.env.example b/backend/.env.example index 1581b25..c3a1fde 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,16 +18,16 @@ CH_COS_BUCKET=hku-icb-1311994147 CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com # SMTP Email -CH_SMTP_HOST= +CH_SMTP_HOST=gz-smtp.qcloudmail.com CH_SMTP_PORT=465 -CH_SMTP_USER= -CH_SMTP_PASSWORD= -CH_SMTP_FROM_EMAIL= -CH_SMTP_FROM_NAME=HKU ICB +CH_SMTP_USER=noreply +CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69 +CH_SMTP_FROM_EMAIL=noreply@hkuicb.info +CH_SMTP_FROM_NAME=HKU ICB Class Hub # Frontend URL CH_FRONTEND_URL=http://localhost:3000 # Super Admin Seed -CH_SUPER_ADMIN_EMAIL=admin@classhub.com +CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info CH_SUPER_ADMIN_PASSWORD=admin123 diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 96e3e0c..21a77bf 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,7 +7,7 @@ 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 +from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered config = context.config config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py new file mode 100644 index 0000000..e8a2671 --- /dev/null +++ b/backend/app/api/announcements.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut +from app.schemas.common import PageResponse +from app.services.announcement_service import ( + create_announcement, + update_announcement, + delete_announcement, + get_announcement_by_id, + list_announcements, +) + +router = APIRouter(prefix="/api/announcements", tags=["announcements"]) + + +@router.get("/", response_model=PageResponse[AnnouncementOut]) +async def get_announcements( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + announcements, total = await list_announcements(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for a in announcements: + items.append( + AnnouncementOut( + id=a.id, + class_id=a.class_id, + author_id=a.author_id, + author_name=a.author.name if a.author else "Unknown", + title=a.title, + content=a.content, + is_pinned=a.is_pinned, + created_at=a.created_at, + updated_at=a.updated_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=AnnouncementOut) +async def create_new_announcement( + data: AnnouncementCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + announcement = await create_announcement(db, effective_class_id, user.id, data) + return AnnouncementOut( + id=announcement.id, + class_id=announcement.class_id, + author_id=announcement.author_id, + author_name=user.name, + title=announcement.title, + content=announcement.content, + is_pinned=announcement.is_pinned, + created_at=announcement.created_at, + updated_at=announcement.updated_at, + ) + + +@router.put("/{announcement_id}", response_model=AnnouncementOut) +async def update_existing_announcement( + announcement_id: int, + data: AnnouncementUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_announcement(db, announcement, data) + return AnnouncementOut( + id=updated.id, + class_id=updated.class_id, + author_id=updated.author_id, + author_name=updated.author.name if updated.author else user.name, + title=updated.title, + content=updated.content, + is_pinned=updated.is_pinned, + created_at=updated.created_at, + updated_at=updated.updated_at, + ) + + +@router.delete("/{announcement_id}") +async def delete_existing_announcement( + announcement_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_announcement(db, announcement) + return {"message": "Announcement deleted"} diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..2d6e64a --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.notification import NotificationOut, UnreadCount +from app.schemas.common import PageResponse +from app.services.notification_service import ( + list_notifications, + get_unread_count, + mark_as_read, + mark_all_as_read, +) + +router = APIRouter(prefix="/api/notifications", tags=["notifications"]) + + +@router.get("/", response_model=PageResponse[NotificationOut]) +async def get_notifications( + page: int = 1, + page_size: int = 20, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + notifications, total = await list_notifications(db, user.id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [ + NotificationOut( + id=n.id, + type=n.type, + title=n.title, + content=n.content, + related_id=n.related_id, + is_read=n.is_read, + created_at=n.created_at, + ) + for n in notifications + ] + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.get("/unread-count", response_model=UnreadCount) +async def get_unread_count_api( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await get_unread_count(db, user.id) + return UnreadCount(count=count) + + +@router.put("/{notification_id}/read") +async def mark_notification_read( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + success = await mark_as_read(db, notification_id, user.id) + if not success: + raise HTTPException(status_code=404, detail="Notification not found") + return {"message": "Marked as read"} + + +@router.put("/read-all") +async def mark_all_notifications_read( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await mark_all_as_read(db, user.id) + return {"message": f"Marked {count} notifications as read"} diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py new file mode 100644 index 0000000..e3f231f --- /dev/null +++ b/backend/app/api/resources.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.resource import ResourceCreate, ResourceOut +from app.schemas.common import PageResponse +from app.services.resource_service import ( + create_resource, + list_resources, + get_resource_by_id, + increment_download_count, + delete_resource, +) +from app.services.cos_service import upload_file + +router = APIRouter(prefix="/api/resources", tags=["resources"]) + +ALLOWED_FILE_TYPES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/zip", + "text/plain", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +} + + +@router.get("/", response_model=PageResponse[ResourceOut]) +async def get_resources( + page: int = 1, + page_size: int = 20, + category: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + resources, total = await list_resources(db, effective_class_id, category, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for r in resources: + items.append( + ResourceOut( + id=r.id, + class_id=r.class_id, + uploader_id=r.uploader_id, + uploader_name=r.uploader.name if r.uploader else "Unknown", + title=r.title, + description=r.description, + file_url=r.file_url, + file_type=r.file_type, + file_size=r.file_size, + category=r.category, + download_count=r.download_count, + created_at=r.created_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=ResourceOut) +async def upload_new_resource( + title: str, + category: str, + file: UploadFile = File(...), + description: str | None = None, + 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") + + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: # 50MB limit + raise HTTPException(status_code=400, detail="File too large (max 50MB)") + + if file.content_type not in ALLOWED_FILE_TYPES: + raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed") + + file_url = upload_file( + f"resources/{effective_class_id}", + file.filename or "file", + contents, + file.content_type, + ) + + data = ResourceCreate(title=title, description=description, category=category) + resource = await create_resource( + db, effective_class_id, user.id, data, file_url, file.content_type, len(contents) + ) + + return ResourceOut( + id=resource.id, + class_id=resource.class_id, + uploader_id=resource.uploader_id, + uploader_name=user.name, + title=resource.title, + description=resource.description, + file_url=resource.file_url, + file_type=resource.file_type, + file_size=resource.file_size, + category=resource.category, + download_count=resource.download_count, + created_at=resource.created_at, + ) + + +@router.post("/{resource_id}/download") +async def download_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + + await increment_download_count(db, resource) + return {"file_url": resource.file_url} + + +@router.delete("/{resource_id}") +async def delete_existing_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + if user.role != "super_admin" and resource.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_resource(db, resource) + return {"message": "Resource deleted"} diff --git a/backend/app/db/models.py b/backend/app/db/models.py index cd2ff6c..59f8187 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from sqlalchemy import String, Text, Integer, DateTime, ForeignKey, func +from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -26,6 +26,12 @@ class Class_(Base): schedules: Mapped[list["Schedule"]] = relationship( "Schedule", back_populates="class_", cascade="all, delete-orphan" ) + announcements: Mapped[list["Announcement"]] = relationship( + "Announcement", back_populates="class_", cascade="all, delete-orphan" + ) + resources: Mapped[list["Resource"]] = relationship( + "Resource", back_populates="class_", cascade="all, delete-orphan" + ) class User(Base): @@ -131,3 +137,65 @@ class Schedule(Base): ) class_: Mapped["Class_"] = relationship("Class_", back_populates="schedules") + + +class Announcement(Base): + __tablename__ = "announcements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="announcements") + author: Mapped["User"] = relationship("User") + + +class Resource(Base): + __tablename__ = "resources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + uploader_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str] = mapped_column(Text, nullable=False) + file_type: Mapped[str] = mapped_column(String(50), nullable=False) + file_size: Mapped[int] = mapped_column(Integer, nullable=False) + category: Mapped[str] = mapped_column(String(50), nullable=False) + download_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="resources") + uploader: Mapped["User"] = relationship("User") + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + related_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + user: Mapped["User"] = relationship("User") diff --git a/backend/app/main.py b/backend/app/main.py index 2ee5459..4a10041 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ 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 +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -87,6 +87,9 @@ app.include_router(directory.router) app.include_router(timeline.router) app.include_router(schedule.router) app.include_router(upload.router) +app.include_router(announcements.router) +app.include_router(resources.router) +app.include_router(notifications.router) @app.get("/api/health") diff --git a/backend/app/schemas/announcement.py b/backend/app/schemas/announcement.py new file mode 100644 index 0000000..6a846ea --- /dev/null +++ b/backend/app/schemas/announcement.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AnnouncementCreate(BaseModel): + title: str + content: str | None = None + is_pinned: bool = False + + +class AnnouncementUpdate(BaseModel): + title: str | None = None + content: str | None = None + is_pinned: bool | None = None + + +class AnnouncementOut(BaseModel): + id: int + class_id: int + author_id: int + author_name: str + title: str + content: str | None + is_pinned: bool + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..46eec08 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class NotificationOut(BaseModel): + id: int + type: str + title: str + content: str | None + related_id: int | None + is_read: bool + created_at: datetime + + +class UnreadCount(BaseModel): + count: int diff --git a/backend/app/schemas/resource.py b/backend/app/schemas/resource.py new file mode 100644 index 0000000..fd5405e --- /dev/null +++ b/backend/app/schemas/resource.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ResourceCreate(BaseModel): + title: str + description: str | None = None + category: str # "course_material" | "assignment" | "reading" | "other" + + +class ResourceOut(BaseModel): + id: int + class_id: int + uploader_id: int + uploader_name: str + title: str + description: str | None + file_url: str + file_type: str + file_size: int + category: str + download_count: int + created_at: datetime diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..424a849 --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -0,0 +1,74 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Announcement, User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate +from app.services.notification_service import create_notifications_for_class + + +async def create_announcement( + db: AsyncSession, class_id: int, author_id: int, data: AnnouncementCreate +) -> Announcement: + announcement = Announcement( + class_id=class_id, + author_id=author_id, + title=data.title, + content=data.content, + is_pinned=data.is_pinned, + ) + db.add(announcement) + await db.commit() + await db.refresh(announcement) + + # Send notifications to class members + await create_notifications_for_class( + db, class_id, "announcement", f"新公告: {data.title}", + related_id=announcement.id, exclude_user_id=author_id, + ) + + return announcement + + +async def update_announcement( + db: AsyncSession, announcement: Announcement, data: AnnouncementUpdate +) -> Announcement: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(announcement, field, value) + await db.commit() + await db.refresh(announcement) + return announcement + + +async def delete_announcement(db: AsyncSession, announcement: Announcement): + await db.delete(announcement) + await db.commit() + + +async def get_announcement_by_id(db: AsyncSession, announcement_id: int) -> Announcement | None: + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.id == announcement_id) + ) + return result.scalar_one_or_none() + + +async def list_announcements( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Announcement], int]: + total_result = await db.execute( + select(func.count(Announcement.id)).where(Announcement.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.class_id == class_id) + .order_by(Announcement.is_pinned.desc(), Announcement.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + announcements = list(result.scalars().all()) + return announcements, total diff --git a/backend/app/services/cos_service.py b/backend/app/services/cos_service.py index 298ca3c..2fbd850 100644 --- a/backend/app/services/cos_service.py +++ b/backend/app/services/cos_service.py @@ -46,3 +46,30 @@ def upload_image(prefix: str, filename: str, data: bytes, content_type: str) -> key = f"{prefix}/{date_path}/{unique_name}{ext}" return upload_bytes(key, data, content_type) + + +FILE_TYPE_EXTENSIONS = { + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/zip": ".zip", + "text/plain": ".txt", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def upload_file(prefix: str, filename: str, data: bytes, content_type: str) -> str: + """Upload any allowed file type to COS. Returns the public URL.""" + ext = FILE_TYPE_EXTENSIONS.get(content_type, "") + date_path = datetime.now().strftime("%Y/%m/%d") + unique_name = uuid.uuid4().hex[:12] + key = f"{prefix}/{date_path}/{unique_name}{ext}" + + return upload_bytes(key, data, content_type) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..697941c --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,107 @@ +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Notification, User + + +async def create_notification( + db: AsyncSession, + user_id: int, + type: str, + title: str, + content: str | None = None, + related_id: int | None = None, +) -> Notification: + notification = Notification( + user_id=user_id, + type=type, + title=title, + content=content, + related_id=related_id, + ) + db.add(notification) + await db.commit() + await db.refresh(notification) + return notification + + +async def create_notifications_for_class( + db: AsyncSession, + class_id: int, + type: str, + title: str, + content: str | None = None, + related_id: int | None = None, + exclude_user_id: int | None = None, +): + """Create notifications for all approved users in a class.""" + result = await db.execute( + select(User.id).where( + User.class_id == class_id, + User.status == "approved", + ) + ) + user_ids = [row[0] for row in result.all()] + + for uid in user_ids: + if exclude_user_id and uid == exclude_user_id: + continue + notification = Notification( + user_id=uid, + type=type, + title=title, + content=content, + related_id=related_id, + ) + db.add(notification) + + await db.commit() + + +async def list_notifications( + db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Notification], int]: + total_result = await db.execute( + select(func.count(Notification.id)).where(Notification.user_id == user_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + notifications = list(result.scalars().all()) + return notifications, total + + +async def get_unread_count(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.count(Notification.id)).where( + Notification.user_id == user_id, + Notification.is_read == False, + ) + ) + return result.scalar() or 0 + + +async def mark_as_read(db: AsyncSession, notification_id: int, user_id: int) -> bool: + result = await db.execute( + update(Notification) + .where(Notification.id == notification_id, Notification.user_id == user_id) + .values(is_read=True) + ) + await db.commit() + return result.rowcount > 0 + + +async def mark_all_as_read(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + update(Notification) + .where(Notification.user_id == user_id, Notification.is_read == False) + .values(is_read=True) + ) + await db.commit() + return result.rowcount diff --git a/backend/app/services/resource_service.py b/backend/app/services/resource_service.py new file mode 100644 index 0000000..747a32f --- /dev/null +++ b/backend/app/services/resource_service.py @@ -0,0 +1,75 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Resource, User +from app.schemas.resource import ResourceCreate + + +async def create_resource( + db: AsyncSession, + class_id: int, + uploader_id: int, + data: ResourceCreate, + file_url: str, + file_type: str, + file_size: int, +) -> Resource: + resource = Resource( + class_id=class_id, + uploader_id=uploader_id, + title=data.title, + description=data.description, + file_url=file_url, + file_type=file_type, + file_size=file_size, + category=data.category, + ) + db.add(resource) + await db.commit() + await db.refresh(resource) + return resource + + +async def list_resources( + db: AsyncSession, class_id: int, category: str | None = None, page: int = 1, page_size: int = 20 +) -> tuple[list[Resource], int]: + query = select(Resource).where(Resource.class_id == class_id) + count_query = select(func.count(Resource.id)).where(Resource.class_id == class_id) + + if category: + query = query.where(Resource.category == category) + count_query = count_query.where(Resource.category == category) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.options(selectinload(Resource.uploader)) + .order_by(Resource.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + resources = list(result.scalars().all()) + return resources, total + + +async def get_resource_by_id(db: AsyncSession, resource_id: int) -> Resource | None: + result = await db.execute( + select(Resource) + .options(selectinload(Resource.uploader)) + .where(Resource.id == resource_id) + ) + return result.scalar_one_or_none() + + +async def increment_download_count(db: AsyncSession, resource: Resource) -> Resource: + resource.download_count += 1 + await db.commit() + await db.refresh(resource) + return resource + + +async def delete_resource(db: AsyncSession, resource: Resource): + await db.delete(resource) + await db.commit() diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py index 44fa731..49a5f59 100644 --- a/backend/app/services/schedule_service.py +++ b/backend/app/services/schedule_service.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Schedule from app.schemas.schedule import ScheduleCreate, ScheduleUpdate +from app.services.notification_service import create_notifications_for_class async def create_schedule( @@ -17,6 +18,13 @@ async def create_schedule( db.add(item) await db.commit() await db.refresh(item) + + # Send notifications to class members + await create_notifications_for_class( + db, class_id, "schedule", f"新排期: {data.title}", + related_id=item.id, + ) + return item