This commit is contained in:
aaron 2026-04-11 17:08:59 +08:00
parent e46dcdcd1d
commit 657a06778f
15 changed files with 787 additions and 9 deletions

View File

@ -18,16 +18,16 @@ CH_COS_BUCKET=hku-icb-1311994147
CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com CH_COS_BASE_URL=https://hku-icb-1311994147.cos.ap-guangzhou.myqcloud.com
# SMTP Email # SMTP Email
CH_SMTP_HOST= CH_SMTP_HOST=gz-smtp.qcloudmail.com
CH_SMTP_PORT=465 CH_SMTP_PORT=465
CH_SMTP_USER= CH_SMTP_USER=noreply
CH_SMTP_PASSWORD= CH_SMTP_PASSWORD=hX2gqEPjaRhULXF69
CH_SMTP_FROM_EMAIL= CH_SMTP_FROM_EMAIL=noreply@hkuicb.info
CH_SMTP_FROM_NAME=HKU ICB CH_SMTP_FROM_NAME=HKU ICB Class Hub
# Frontend URL # Frontend URL
CH_FRONTEND_URL=http://localhost:3000 CH_FRONTEND_URL=http://localhost:3000
# Super Admin Seed # Super Admin Seed
CH_SUPER_ADMIN_EMAIL=admin@classhub.com CH_SUPER_ADMIN_EMAIL=admin@hkuicb.info
CH_SUPER_ADMIN_PASSWORD=admin123 CH_SUPER_ADMIN_PASSWORD=admin123

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings from app.config import settings
from app.db.base import Base 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 = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import json import json
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
@ -26,6 +26,12 @@ class Class_(Base):
schedules: Mapped[list["Schedule"]] = relationship( schedules: Mapped[list["Schedule"]] = relationship(
"Schedule", back_populates="class_", cascade="all, delete-orphan" "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): class User(Base):
@ -131,3 +137,65 @@ class Schedule(Base):
) )
class_: Mapped["Class_"] = relationship("Class_", back_populates="schedules") 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")

View File

@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.db.database import create_tables 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( logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO, 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(timeline.router)
app.include_router(schedule.router) app.include_router(schedule.router)
app.include_router(upload.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") @app.get("/api/health")

View File

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

View File

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

View File

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

View File

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

View File

@ -46,3 +46,30 @@ def upload_image(prefix: str, filename: str, data: bytes, content_type: str) ->
key = f"{prefix}/{date_path}/{unique_name}{ext}" key = f"{prefix}/{date_path}/{unique_name}{ext}"
return upload_bytes(key, data, content_type) 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)

View File

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

View File

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

View File

@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Schedule from app.db.models import Schedule
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate from app.schemas.schedule import ScheduleCreate, ScheduleUpdate
from app.services.notification_service import create_notifications_for_class
async def create_schedule( async def create_schedule(
@ -17,6 +18,13 @@ async def create_schedule(
db.add(item) db.add(item)
await db.commit() await db.commit()
await db.refresh(item) 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 return item