优化考勤记录
This commit is contained in:
@@ -20,7 +20,7 @@ class AttendanceModel:
|
||||
@staticmethod
|
||||
async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT attendance_id, date, status, reason, deduction_applied, created_at
|
||||
SELECT attendance_id, date, slot, status, reason, deduction_applied, created_at
|
||||
FROM attendance_records
|
||||
WHERE student_id = %s
|
||||
"""
|
||||
@@ -37,7 +37,8 @@ class AttendanceModel:
|
||||
@staticmethod
|
||||
async def get_class_records(
|
||||
date: str = None,
|
||||
student_id: int = None
|
||||
student_id: int = None,
|
||||
slot: str = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
sql = """
|
||||
SELECT ar.*, s.name as student_name, s.student_no
|
||||
@@ -55,6 +56,10 @@ class AttendanceModel:
|
||||
sql += " AND ar.student_id = %s"
|
||||
params.append(student_id)
|
||||
|
||||
if slot:
|
||||
sql += " AND ar.slot = %s"
|
||||
params.append(slot)
|
||||
|
||||
sql += " ORDER BY ar.date DESC, s.student_no"
|
||||
|
||||
return await execute_query(sql, tuple(params))
|
||||
@@ -65,12 +70,13 @@ class AttendanceModel:
|
||||
date: str,
|
||||
status: str,
|
||||
reason: str = None,
|
||||
recorder_id: int = None
|
||||
recorder_id: int = None,
|
||||
slot: str = 'morning'
|
||||
) -> int:
|
||||
# 检查是否已存在当天记录
|
||||
# 检查是否已存在当天同时段记录
|
||||
existing = await execute_one(
|
||||
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s",
|
||||
(student_id, date)
|
||||
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s AND slot = %s",
|
||||
(student_id, date, slot)
|
||||
)
|
||||
|
||||
if existing:
|
||||
@@ -78,17 +84,17 @@ class AttendanceModel:
|
||||
sql = """
|
||||
UPDATE attendance_records
|
||||
SET status = %s, reason = %s, recorder_id = %s
|
||||
WHERE student_id = %s AND date = %s
|
||||
WHERE student_id = %s AND date = %s AND slot = %s
|
||||
"""
|
||||
await execute_update(sql, (status, reason, recorder_id, student_id, date))
|
||||
await execute_update(sql, (status, reason, recorder_id, student_id, date, slot))
|
||||
return existing["attendance_id"]
|
||||
else:
|
||||
# 插入新记录
|
||||
sql = """
|
||||
INSERT INTO attendance_records (student_id, date, status, reason, recorder_id)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
INSERT INTO attendance_records (student_id, date, slot, status, reason, recorder_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
return await execute_insert(sql, (student_id, date, status, reason, recorder_id))
|
||||
return await execute_insert(sql, (student_id, date, slot, status, reason, recorder_id))
|
||||
|
||||
@staticmethod
|
||||
async def mark_deduction_applied(attendance_id: int) -> bool:
|
||||
|
||||
@@ -391,7 +391,8 @@ async def add_attendance(request: Request, req: AddAttendanceRequest):
|
||||
reason=req.reason,
|
||||
apply_deduction=req.apply_deduction,
|
||||
recorder_id=user["user_id"],
|
||||
custom_deduction=req.custom_deduction
|
||||
custom_deduction=req.custom_deduction,
|
||||
slot=req.slot
|
||||
)
|
||||
if result["success"]:
|
||||
await LogService.write_operation_log(
|
||||
@@ -410,14 +411,16 @@ async def add_attendance(request: Request, req: AddAttendanceRequest):
|
||||
async def get_attendance_records(
|
||||
request: Request,
|
||||
date: Optional[str] = None,
|
||||
student_id: Optional[int] = None
|
||||
student_id: Optional[int] = None,
|
||||
slot: Optional[str] = None
|
||||
):
|
||||
"""获取考勤记录"""
|
||||
user = await get_current_user(request)
|
||||
result = await AttendanceService.get_records(
|
||||
user_id=user["user_id"],
|
||||
date=date,
|
||||
student_id=student_id
|
||||
student_id=student_id,
|
||||
slot=slot
|
||||
)
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ from datetime import date, datetime
|
||||
|
||||
class AddPointsRequest(BaseModel):
|
||||
"""加减分请求"""
|
||||
student_ids: List[int] = Field(..., description="学生ID列表")
|
||||
points_change: int = Field(..., description="分数变动")
|
||||
student_ids: List[int] = Field(..., min_length=1, max_length=200, description="学生ID列表")
|
||||
points_change: int = Field(..., gt=-100, lt=100, description="分数变动")
|
||||
reason: str = Field(..., min_length=1, max_length=255, description="原因")
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ class ImportResult(BaseModel):
|
||||
|
||||
class AddAdminRequest(BaseModel):
|
||||
"""添加管理员请求"""
|
||||
username: str = Field(..., min_length=1, max_length=50, description="登录账号")
|
||||
username: str = Field(..., min_length=2, max_length=50, pattern=r'^[a-zA-Z0-9_\u4e00-\u9fa]+$', description="登录账号")
|
||||
real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名")
|
||||
password: Optional[str] = Field(None, description="密码(不填则自动生成)")
|
||||
role_type: str = Field(..., description="角色类型")
|
||||
subject_id: Optional[int] = Field(None, description="科目ID(科代表需要)")
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=50, description="密码(不填则自动生成)")
|
||||
role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
|
||||
subject_id: Optional[int] = Field(None, gt=0, description="科目ID(科代表需要)")
|
||||
|
||||
|
||||
class AddAdminResponse(BaseModel):
|
||||
@@ -65,34 +65,35 @@ class AddAdminResponse(BaseModel):
|
||||
|
||||
class UpdateHomeworkStatusRequest(BaseModel):
|
||||
"""更新作业状态请求"""
|
||||
submission_id: int
|
||||
status: str
|
||||
comments: Optional[str] = None
|
||||
submission_id: int = Field(..., gt=0, description="提交记录ID")
|
||||
status: str = Field(..., pattern=r'^(submitted|not_submitted|late|excused)$', description="状态")
|
||||
comments: Optional[str] = Field(None, max_length=500, description="评语")
|
||||
apply_deduction: bool = False
|
||||
|
||||
|
||||
class AddStudentRequest(BaseModel):
|
||||
"""新增学生请求"""
|
||||
student_no: str = Field(..., min_length=1, max_length=20, description="学号")
|
||||
student_no: str = Field(..., min_length=1, max_length=20, pattern=r'^[a-zA-Z0-9]+$', description="学号")
|
||||
name: str = Field(..., min_length=1, max_length=50, description="姓名")
|
||||
parent_phone: Optional[str] = Field(None, max_length=11, description="家长手机号")
|
||||
parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号")
|
||||
|
||||
|
||||
class AddAttendanceRequest(BaseModel):
|
||||
"""添加考勤请求"""
|
||||
student_id: int
|
||||
student_id: int = Field(..., gt=0, description="学生ID")
|
||||
date: date
|
||||
status: str
|
||||
reason: Optional[str] = None
|
||||
slot: str = Field(default="morning", pattern=r'^(morning|afternoon|evening)$', description="时段")
|
||||
status: str = Field(..., pattern=r'^(present|absent|late|leave)$', description="考勤状态")
|
||||
reason: Optional[str] = Field(None, max_length=255, description="原因")
|
||||
apply_deduction: bool = True
|
||||
custom_deduction: Optional[int] = Field(default=None, gt=0, description="自定义扣分值")
|
||||
custom_deduction: Optional[int] = Field(default=None, gt=0, le=20, description="自定义扣分值")
|
||||
|
||||
|
||||
class UpdateAdminRequest(BaseModel):
|
||||
"""更新管理员请求"""
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
user_id: int = Field(..., gt=0, description="用户ID")
|
||||
real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名")
|
||||
role_type: str = Field(..., description="角色类型")
|
||||
role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
|
||||
|
||||
|
||||
class DeleteAdminRequest(BaseModel):
|
||||
@@ -108,4 +109,4 @@ class ResetPasswordRequest(BaseModel):
|
||||
class UpdateStudentRequest(BaseModel):
|
||||
"""更新学生请求"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=50, description="姓名")
|
||||
parent_phone: Optional[str] = Field(None, max_length=20, description="家长手机号")
|
||||
parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号")
|
||||
@@ -41,16 +41,29 @@ class AttendanceService:
|
||||
reason: Optional[str],
|
||||
apply_deduction: bool,
|
||||
recorder_id: int,
|
||||
custom_deduction: Optional[int] = None
|
||||
custom_deduction: Optional[int] = None,
|
||||
slot: str = 'morning'
|
||||
) -> Dict[str, Any]:
|
||||
"""添加考勤记录"""
|
||||
# 校验时段
|
||||
if slot not in ('morning', 'afternoon', 'evening'):
|
||||
return {"success": False, "message": "无效的考勤时段"}
|
||||
# 校验状态
|
||||
if status not in ('present', 'absent', 'late', 'leave'):
|
||||
return {"success": False, "message": "无效的考勤状态"}
|
||||
# 校验自定义扣分范围
|
||||
if custom_deduction is not None and (custom_deduction < 1 or custom_deduction > 20):
|
||||
return {"success": False, "message": "自定义扣分必须在1-20之间"}
|
||||
|
||||
# 检查权限
|
||||
role = await PermissionChecker.get_user_role(recorder_id)
|
||||
if role not in ["班主任", "考勤委员"]:
|
||||
return {"success": False, "message": "无权进行此操作"}
|
||||
|
||||
# 检查是否同班级
|
||||
# 单班级系统,管理员均可操作
|
||||
# 考勤委员扣分上限
|
||||
if role == "考勤委员" and apply_deduction and status in ["absent", "late"]:
|
||||
if custom_deduction is not None and custom_deduction > settings.ATTENDANCE_REP_MAX_POINTS:
|
||||
return {"success": False, "message": f"考勤委员单次扣分上限为{settings.ATTENDANCE_REP_MAX_POINTS}分"}
|
||||
|
||||
# 添加考勤记录
|
||||
attendance_id = await AttendanceModel.create_record(
|
||||
@@ -58,7 +71,8 @@ class AttendanceService:
|
||||
date=date,
|
||||
status=status,
|
||||
reason=reason,
|
||||
recorder_id=recorder_id
|
||||
recorder_id=recorder_id,
|
||||
slot=slot
|
||||
)
|
||||
|
||||
if not attendance_id:
|
||||
@@ -107,7 +121,8 @@ class AttendanceService:
|
||||
async def get_records(
|
||||
user_id: int,
|
||||
date: Optional[str] = None,
|
||||
student_id: Optional[int] = None
|
||||
student_id: Optional[int] = None,
|
||||
slot: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取考勤记录"""
|
||||
role = await PermissionChecker.get_user_role(user_id)
|
||||
@@ -115,7 +130,8 @@ class AttendanceService:
|
||||
if role in ["班主任", "考勤委员"]:
|
||||
records = await AttendanceModel.get_class_records(
|
||||
date=date,
|
||||
student_id=student_id
|
||||
student_id=student_id,
|
||||
slot=slot
|
||||
)
|
||||
elif student_id:
|
||||
# 管理员可查看指定学生
|
||||
|
||||
@@ -33,12 +33,18 @@ class ConductService:
|
||||
recorder_id: int,
|
||||
recorder_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
批量加减分
|
||||
"""
|
||||
"""批量加减分"""
|
||||
# 输入校验
|
||||
if not student_ids or len(student_ids) > 200:
|
||||
return {"success": False, "message": "学生数量需在1-200之间"}
|
||||
if not reason or not reason.strip() or len(reason) > 255:
|
||||
return {"success": False, "message": "原因不能为空且不超过255字符"}
|
||||
|
||||
# 验证分值
|
||||
if points_change == 0:
|
||||
return {"success": False, "message": "分值不能为0"}
|
||||
if abs(points_change) > 100:
|
||||
return {"success": False, "message": "单次加减分不能超过100分"}
|
||||
|
||||
# 获取操作人角色
|
||||
role = await PermissionChecker.get_user_role(recorder_id)
|
||||
@@ -119,6 +125,9 @@ class ConductService:
|
||||
@staticmethod
|
||||
async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]:
|
||||
"""撤销扣分记录"""
|
||||
if not record_id or record_id <= 0:
|
||||
return {"success": False, "message": "无效的记录ID"}
|
||||
|
||||
# 检查权限
|
||||
can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id)
|
||||
if not can_revoke:
|
||||
@@ -155,6 +164,9 @@ class ConductService:
|
||||
@staticmethod
|
||||
async def restore_record(record_id: int, restorer_id: int) -> Dict[str, Any]:
|
||||
"""反撤销(恢复)已撤销的记录"""
|
||||
if not record_id or record_id <= 0:
|
||||
return {"success": False, "message": "无效的记录ID"}
|
||||
|
||||
# 检查权限:只有班主任可以反撤销
|
||||
role = await PermissionChecker.get_user_role(restorer_id)
|
||||
if role != "班主任":
|
||||
@@ -207,6 +219,8 @@ class ConductService:
|
||||
end_date = None
|
||||
if related_type == "":
|
||||
related_type = None
|
||||
if related_type and related_type not in ('manual', 'homework', 'attendance'):
|
||||
return {"records": [], "page": page, "page_size": page_size, "total": 0, "total_pages": 0}
|
||||
|
||||
role = await PermissionChecker.get_user_role(user_id)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
@@ -41,9 +41,9 @@ include __DIR__ . '/../includes/header.php';
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>时段</label>
|
||||
<select id="attendanceSlot">
|
||||
<option value="morning">早 8:15</option>
|
||||
<option value="afternoon">下午 14:00</option>
|
||||
<option value="evening">晚 19:30</option>
|
||||
<option value="morning">早上 7:15</option>
|
||||
<option value="afternoon">中午 14:00</option>
|
||||
<option value="evening">晚修 19:30</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="status-group">
|
||||
@@ -124,7 +124,7 @@ function renderStudentGrid() {
|
||||
const currentSlot = document.getElementById('attendanceSlot').value;
|
||||
let html = '';
|
||||
studentsData.forEach(student => {
|
||||
const hasRecord = existingRecords.some(r => r.student_id === student.student_id && (!r.slot || r.slot === currentSlot));
|
||||
const hasRecord = existingRecords.some(r => r.student_id === student.student_id && r.slot === currentSlot);
|
||||
html += `<div class="student-cell${hasRecord ? ' has-record' : ''}"
|
||||
data-id="${student.student_id}"
|
||||
data-name="${escapeHtml(student.name)}"
|
||||
@@ -178,17 +178,19 @@ async function submitAttendance() {
|
||||
}
|
||||
|
||||
const date = document.getElementById('attendanceDate').value;
|
||||
const slot = document.getElementById('attendanceSlot').value;
|
||||
const reason = document.getElementById('attendanceReason').value;
|
||||
const customDeduction = document.getElementById('customDeduction').value;
|
||||
const customDeductionValue = customDeduction ? parseInt(customDeduction) : null;
|
||||
|
||||
// 批量提交(不再检查已有记录,允许同一学生同一天多次考勤)
|
||||
// 批量提交
|
||||
const promises = [];
|
||||
selectedCells.forEach(cell => {
|
||||
const studentId = parseInt(cell.dataset.id);
|
||||
const payload = {
|
||||
student_id: studentId,
|
||||
date: date,
|
||||
slot: slot,
|
||||
status: currentStatus,
|
||||
reason: reason,
|
||||
apply_deduction: true
|
||||
|
||||
30
sql/init.sql
30
sql/init.sql
@@ -267,6 +267,36 @@ PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- attendance_records 表:添加 slot 字段(如不存在)
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'classmanagerdb'
|
||||
AND TABLE_NAME = 'attendance_records'
|
||||
AND COLUMN_NAME = 'slot'
|
||||
);
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `attendance_records` ADD COLUMN `slot` VARCHAR(20) DEFAULT ''morning'' COMMENT ''时段: morning/afternoon/evening'' AFTER `date`',
|
||||
'SELECT ''attendance_records.slot already exists'' AS message'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 删除旧唯一键并添加新唯一键(含slot)
|
||||
SET @uk_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = 'classmanagerdb'
|
||||
AND TABLE_NAME = 'attendance_records'
|
||||
AND CONSTRAINT_NAME = 'uk_student_date'
|
||||
);
|
||||
SET @sql = IF(@uk_exists > 0,
|
||||
'ALTER TABLE `attendance_records` DROP INDEX `uk_student_date`, ADD UNIQUE KEY `uk_student_date_slot` (`student_id`, `date`, `slot`)',
|
||||
'SELECT ''uk_student_date does not exist, skipping'' AS message'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 迁移:semester_archives 表新增 attendance_present 字段
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
|
||||
Reference in New Issue
Block a user