1
This commit is contained in:
parent
e46dcdcd1d
commit
657a06778f
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
121
backend/app/api/announcements.py
Normal file
121
backend/app/api/announcements.py
Normal 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"}
|
||||
74
backend/app/api/notifications.py
Normal file
74
backend/app/api/notifications.py
Normal 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"}
|
||||
153
backend/app/api/resources.py
Normal file
153
backend/app/api/resources.py
Normal 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"}
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
27
backend/app/schemas/announcement.py
Normal file
27
backend/app/schemas/announcement.py
Normal 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
|
||||
17
backend/app/schemas/notification.py
Normal file
17
backend/app/schemas/notification.py
Normal 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
|
||||
24
backend/app/schemas/resource.py
Normal file
24
backend/app/schemas/resource.py
Normal 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
|
||||
74
backend/app/services/announcement_service.py
Normal file
74
backend/app/services/announcement_service.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
107
backend/app/services/notification_service.py
Normal file
107
backend/app/services/notification_service.py
Normal 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
|
||||
75
backend/app/services/resource_service.py
Normal file
75
backend/app/services/resource_service.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user