diff --git a/Appointment/admin.py b/Appointment/admin.py index 708fdbfe5..128d78738 100644 --- a/Appointment/admin.py +++ b/Appointment/admin.py @@ -31,21 +31,22 @@ class College_AnnouncementAdmin(admin.ModelAdmin): class ParticipantAdmin(admin.ModelAdmin): actions_on_top = True actions_on_bottom = True - search_fields = ('Sid__username', 'Sid__name', 'Sid__pinyin', 'Sid__acronym') + search_fields = ('Sid__username', 'Sid__name', + 'Sid__pinyin', 'Sid__acronym') list_display = ('Sid_id', 'name', 'credit', 'longterm', 'hidden') list_display_links = ('Sid_id', 'name') class AgreeFilter(admin.SimpleListFilter): title = '签署状态' parameter_name = 'Agree' - + def lookups(self, request, model_admin): '''针对字段值设置过滤器的显示效果''' return ( ('true', "已签署"), ('false', "未签署"), ) - + def queryset(self, request, queryset): '''定义过滤器的过滤动作''' if self.value() == 'true': @@ -71,11 +72,11 @@ class AppointInline(admin.TabularInline): ] show_change_link = True # 可申诉的范围只有一周,筛选两周内范围的即可 + def get_queryset(self, request): return super().get_queryset(request).filter( Astart__gte=datetime.now().date() - timedelta(days=14)) - inlines = [AppointInline] actions = [] @@ -116,7 +117,8 @@ class RoomAdmin(admin.ModelAdmin): 'RneedAgree', ) list_display_links = ('Rid', ) - list_editable = ('Rtitle', 'Rmin', 'Rmax', 'Rstart', 'Rfinish', 'RneedAgree') + list_editable = ('Rtitle', 'Rmin', 'Rmax', + 'Rstart', 'Rfinish', 'RneedAgree') search_fields = ('Rid', 'Rtitle') list_filter = ('Rstatus', 'RIsAllNight', 'RneedAgree') @@ -140,7 +142,7 @@ class AppointAdmin(admin.ModelAdmin): LETTERS = set(string.digits + string.ascii_letters + string.punctuation) search_fields = ('Room__Rtitle', 'Room__Rid', 'major_student__name', "students__name", - 'major_student__pinyin', # 仅发起者缩写,方便搜索者区分发起者和参与者 + 'major_student__pinyin', # 仅发起者缩写,方便搜索者区分发起者和参与者 ) list_display = ( 'Aid', @@ -169,16 +171,16 @@ class AppointAdmin(admin.ModelAdmin): readonly_fields = ('Atime', ) class ActivateFilter(admin.SimpleListFilter): - title = '有效状态' # 过滤标题显示为"以 有效状态" - parameter_name = 'Activate' # 过滤器使用的过滤字段 - + title = '有效状态' # 过滤标题显示为"以 有效状态" + parameter_name = 'Activate' # 过滤器使用的过滤字段 + def lookups(self, request, model_admin): '''针对字段值设置过滤器的显示效果''' return ( ('true', "有效"), ('false', "无效"), ) - + def queryset(self, request, queryset): '''定义过滤器的过滤动作''' if self.value() == 'true': @@ -200,7 +202,8 @@ def get_search_results(self, request, queryset, search_term: str): if ' ' not in search_term: # 判断时需要增加exists,否则会报错,似乎是QuerySet的缓存问题? if str.isascii(search_term) and str.isalpha(search_term): - pinyin_result = queryset.filter(major_student__pinyin__icontains=search_term) + pinyin_result = queryset.filter( + major_student__pinyin__icontains=search_term) if pinyin_result.exists(): return pinyin_result, False elif str.isascii(search_term) and str.isalnum(search_term): @@ -208,7 +211,8 @@ def get_search_results(self, request, queryset, search_term: str): if room_result.exists(): return room_result, False else: - room_result = queryset.filter(Room__Rtitle__icontains=search_term) + room_result = queryset.filter( + Room__Rtitle__icontains=search_term) if room_result.exists(): return room_result, False return super().get_search_results(request, queryset, search_term) @@ -232,7 +236,8 @@ def usage_display(self, obj): usage = obj.Ausage else: usage = obj.Ausage[:half_len] + '...' + obj.Ausage[3-half_len:] - usage = '
'.join([usage[i:i+batch] for i in range(0, len(usage), batch)]) + usage = '
'.join([usage[i:i+batch] + for i in range(0, len(usage), batch)]) return mark_safe(usage) @as_display('通过率') @@ -275,7 +280,6 @@ def _waiting2confirm(self, appoint: Appoint): students_id=[appoint.get_major_id()], admin=True) logger.info(f"{appoint.Aid}号预约被管理员通过,发起人:{_appointor(appoint)}") - def _violated2judged(self, appoint: Appoint): appoint.Astatus = Appoint.Status.JUDGED appoint.save() @@ -284,7 +288,6 @@ def _violated2judged(self, appoint: Appoint): students_id=[appoint.get_major_id()], admin=True) logger.info(f"{appoint.Aid}号预约被管理员通过,发起人:{_appointor(appoint)}") - @as_action('所选条目 通过', actions, 'change', update=True) def confirm(self, request, queryset: QuerySet[Appoint]): # 确认通过 invalid = [] @@ -303,18 +306,18 @@ def confirm(self, request, queryset: QuerySet[Appoint]): # 确认通过 message = f'部分成功!但{invalid}状态不为等待、违约,不允许更改!' return self.message_user(request, message, messages.WARNING) - @as_action('所选条目 违约', actions, 'change', update=True) def violate(self, request, queryset: QuerySet[Appoint]): # 确认违约 for appoint in queryset: if (appoint.Astatus == Appoint.Status.VIOLATED - and appoint.Areason == Appoint.Reason.R_ELSE): + and appoint.Areason == Appoint.Reason.R_ELSE): return self.message_user( request, '操作失败!只允许对未审核的条目操作!', messages.WARNING) ori_status = appoint.get_status() if appoint.Astatus != Appoint.Status.VIOLATED: appoint.Astatus = Appoint.Status.VIOLATED - User.objects.modify_credit(appoint.get_major_id(), -1, '地下室:后台') + User.objects.modify_credit( + appoint.get_major_id(), -1, '地下室:后台') appoint.Areason = Appoint.Reason.R_ELSE appoint.save() @@ -326,7 +329,6 @@ def violate(self, request, queryset: QuerySet[Appoint]): # 确认违约 return self.message_user(request, "设为违约成功!") - @as_action('更新定时任务', actions, ['add', 'change']) def refresh_scheduler(self, request, queryset): ''' @@ -339,8 +341,8 @@ def refresh_scheduler(self, request, queryset): start = appoint.Astart finish = appoint.Afinish if start > finish: - return self.message_user(request, - f'操作失败,预约{aid}开始和结束时间冲突!请勿篡改数据!', messages.WARNING) + return self.message_user(request, + f'操作失败,预约{aid}开始和结束时间冲突!请勿篡改数据!', messages.WARNING) cancel_scheduler(aid) # 注销原有定时任务 无异常 set_scheduler(appoint) # 开始时进入进行中 结束后判定 set_appoint_reminder(appoint) @@ -349,7 +351,6 @@ def refresh_scheduler(self, request, queryset): return self.message_user(request, str(e), messages.WARNING) return self.message_user(request, '定时任务更新成功!') - def longterm_wk(self, request, queryset, times, interval_week=1): new_appoints = {} for appoint in queryset: @@ -365,7 +366,8 @@ def longterm_wk(self, request, queryset, times, interval_week=1): longterm_info = jobs.get_longterm_display(times, interval_week) notify_appoint(appoint, MessageType.LONGTERM_CREATED, f'新增了{longterm_info}同时段预约', admin=True) - new_appoints[appoint.pk] = list(appoints.values_list('pk', flat=True)) + new_appoints[appoint.pk] = list( + appoints.values_list('pk', flat=True)) except Exception as e: return self.message_user(request, f'长线化失败!', messages.WARNING) new_infos = [] @@ -377,8 +379,8 @@ def longterm_wk(self, request, queryset, times, interval_week=1): new_infos.append(f'{appoint}->{new_appoint_ids}') return self.message_user(request, f'长线化成功!生成预约{";".join(new_appoints)}') - # @as_action('增加一周本预约', actions, 'add', single=True) + def longterm1(self, request, queryset): return self.longterm_wk(request, queryset, 1) @@ -418,7 +420,7 @@ class CardCheckInfoAdmin(admin.ModelAdmin): 'Cardtime', 'CardStatus', ('Cardroom', admin.EmptyFieldListFilter), ] - + @as_display('刷卡者', except_value='-') def student_display(self, obj): return obj.Cardstudent.name @@ -433,3 +435,29 @@ class LongTermAppointAdmin(admin.ModelAdmin): def view_on_site(self, obj: LongTermAppoint): return f'/underground/review?Lid={obj.pk}' + + +@admin.register(AI_Inspection_Info) +class AI_Inspection_InfoAdmin(admin.ModelAdmin): + list_display = ['id', 'Iperson', 'Iroom', 'Ireason_truncated', + 'Iresult', 'Itimestamp', 'Idetail_truncated'] + list_select_related = ['Iperson', 'Iroom'] + search_fields = ['Iperson__name', 'Iperson__Sid__username', + 'Iroom__Rtitle', 'Ireason', 'Idetail'] + list_filter = ['Iresult', 'Itimestamp'] + readonly_fields = ['Iperson', 'Iroom', + 'Ireason', 'Iresult', 'Itimestamp', 'Idetail'] + + @as_display('事由') + def Ireason_truncated(self, obj): + if len(obj.Ireason) > 30: + return obj.Ireason[:27] + '...' + return obj.Ireason + + @as_display('详情') + def Idetail_truncated(self, obj): + if not obj.Idetail: + return '-' + if len(obj.Idetail) > 30: + return obj.Idetail[:27] + '...' + return obj.Idetail diff --git a/Appointment/config.py b/Appointment/config.py index d5c2026eb..4c150286b 100644 --- a/Appointment/config.py +++ b/Appointment/config.py @@ -49,4 +49,18 @@ class AppointmentConfig(Config): # 是否允许单人同一时段预约两个房间 allow_overlap = False + # AI 审核功能开启与否,使用的 API 地址和密钥 + AI_Inspection_Enabled = LazySetting('AI_Inspection/enabled', type=bool) + AI_Inspection_Method = LazySetting( + 'AI_Inspection/method', type=str, default="Ollama") # "Ollama" or "GLM" + + # Ollama 参数 + Ollama_ADDR = LazySetting('AI_Inspection/Ollama_ADDR', type=str) + + # GLM 参数 + GLM_API_KEY = LazySetting('AI_Inspection/GLM_API_Key', type=str) + GLM_Timeout = LazySetting( + 'AI_Inspection/GLM_Timeout', type=int, default=30) + + appointment_config = AppointmentConfig(ROOT_CONFIG, 'underground') diff --git a/Appointment/migrations/0004_ai_inspection_info.py b/Appointment/migrations/0004_ai_inspection_info.py new file mode 100644 index 000000000..4d7cf8a38 --- /dev/null +++ b/Appointment/migrations/0004_ai_inspection_info.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.14 on 2025-09-16 19:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("Appointment", "0003_alter_appoint_options_alter_appoint_areason"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AI_Inspection_Info", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("Ireason", models.CharField(max_length=256, verbose_name="事由")), + ( + "Iresult", + models.SmallIntegerField( + choices=[(1, "合规"), (0, "不合规"), (-1, "错误")], + default=-1, + verbose_name="审核结果", + ), + ), + ( + "Idetail", + models.CharField( + blank=True, max_length=512, verbose_name="审核详情" + ), + ), + ( + "Itimestamp", + models.DateTimeField(auto_now_add=True, verbose_name="审核时间"), + ), + ( + "Iperson", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="预约人", + ), + ), + ( + "Iroom", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="Appointment.room", + verbose_name="房间号", + ), + ), + ], + options={ + "verbose_name": "AI审核信息", + "verbose_name_plural": "AI审核信息", + }, + ), + ] diff --git a/Appointment/models.py b/Appointment/models.py index 5f04c30fe..ab7b1f462 100644 --- a/Appointment/models.py +++ b/Appointment/models.py @@ -22,10 +22,10 @@ 'Appoint', 'LongTermAppoint', 'CardCheckInfo', + 'AI_Inspection_Info', ] - class College_Announcement(models.Model): class Meta: verbose_name = "全院公告" @@ -172,7 +172,8 @@ class Status(models.IntegerChoices): UNLIMITED = 1, '无需预约' # 允许使用 FORBIDDEN = 2, '禁止使用' # 禁止使用 - Rstatus = models.SmallIntegerField('房间状态', choices=Status.choices, default=0) + Rstatus = models.SmallIntegerField( + '房间状态', choices=Status.choices, default=0) # 标记当前房间是否可以通宵使用,可由管理员修改(主要针对自习室) RIsAllNight = models.BooleanField('可通宵使用', default=False) @@ -249,7 +250,7 @@ class CheckStatus(models.IntegerChoices): PASSED = 1 # 预约在特定分钟内的检查是通过的 UNSAVED = 2 # 预约在此分钟内尚未记录检测状态 Acheck_status = models.SmallIntegerField('检测状态', - choices=CheckStatus.choices, default=2) + choices=CheckStatus.choices, default=2) # 这里Room使用外键的话只能设置DO_NOTHING,否则删除房间就会丢失预约信息 # 所以房间信息不能删除,只能逻辑删除 @@ -309,7 +310,7 @@ class Type(models.IntegerChoices): INTERVIEW = choice(4, '面试预约') Atype = models.SmallIntegerField('预约类型', - choices=Type.choices, default=Type.NORMAL) + choices=Type.choices, default=Type.NORMAL) get_Atype_display: CustomizedDisplay objects: AppointManager = AppointManager() @@ -519,6 +520,31 @@ def get_applicant_id(self) -> str: return self.applicant.get_id() +class AI_Inspection_Info(models.Model): + """ + 记录AI审核的结果和理由 + """ + class Meta: + verbose_name = 'AI审核信息' + verbose_name_plural = verbose_name + + Iperson = models.ForeignKey( + User, on_delete=models.CASCADE, verbose_name='预约人') + Iroom = models.ForeignKey( + Room, on_delete=models.CASCADE, verbose_name='房间号') + Ireason = models.CharField('事由', max_length=256) + + class Result(models.IntegerChoices): + APPROVED = (1, '合规') + NOT_APPROVED = (0, '不合规') + ERROR = (-1, '错误') + + Iresult = models.SmallIntegerField( + '审核结果', choices=Result.choices, default=Result.ERROR) + Idetail = models.CharField('审核详情', max_length=512, blank=True) + Itimestamp = models.DateTimeField('审核时间', auto_now_add=True) + + @receiver(pre_delete, sender=Appoint) def before_delete_Appoint(sender, instance, **kwargs): from Appointment.appoint.jobs import cancel_scheduler diff --git a/Appointment/utils/AI_Inspection.py b/Appointment/utils/AI_Inspection.py new file mode 100644 index 000000000..03dbd0433 --- /dev/null +++ b/Appointment/utils/AI_Inspection.py @@ -0,0 +1,206 @@ +from Appointment.config import appointment_config as CONFIG +from Appointment.models import AI_Inspection_Info, Room +from Appointment.utils.log import logger +import os +import json +import requests +from django.utils import timezone + +# 模型人设:只返回“合规/不合规”,JSON格式 +SYSTEM_PROMPT = ( + "你是一个审查学生预约功能房间信息的老师,请审核以下预约信息,审核要求是预约事由和房间用途匹配,且不能包含政治敏感信息和色情内容。" + "你的输出必须严格为JSON,字段为:" + '{"decision": 0 或 1, "reason":"一句话理由"}' + "其中 0 代表不允许预约,1 代表允许预约" +) + + +def _call_glm_decision(text: str, timeout: int = 30) -> tuple[str, str]: + """ + 调用智谱 HTTP 接口,返回 0 或 1,超时/网络错误报出异常。 + """ + ENDPOINT = "https://open.bigmodel.cn/api/paas/v4/chat/completions" + MODEL = "glm-4.5-flash" + api_key = CONFIG.GLM_API_KEY + if not api_key: + raise RuntimeError("缺少参数 API_KEY") + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "model": MODEL, + "temperature": 0.2, # 可调 + "top_p": 0.7, # 可调 + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": f"待审核文本:\n{text}"}, + ], + } + + resp = requests.post(ENDPOINT, headers=headers, + json=payload, timeout=timeout) + if resp.status_code != 200: + # 把上游错误透出一小段,便于排查 + if resp.status_code == 400: + return False, "请勿在预约理由中包含敏感词汇!" + raise RuntimeError(f"Upstream {resp.status_code}: {resp.text[:300]}") + + data = resp.json() + try: + content_str = data["choices"][0]["message"]["content"] + obj = json.loads(content_str) + except Exception as e: + raise RuntimeError(f"Bad JSON content from provider: {e}; raw={data}") + + decision = obj.get("decision") + reason = obj.get("reason", "") + if decision not in (0, 1): + raise ValueError(f"unexpected decision: {decision};obj={obj}") + if not isinstance(reason, str): + reason = str(reason) + if decision == 1: + return True, reason + else: + return False, reason + + +def Ollama_Inspection(room_name, reason, user) -> tuple[bool, str]: + # Ollama 审核功能接口,将预约房间(名称)和事由发送至 API,然后接收判断结果(合格/不合格) + # 完整的处理办法(需要在API调用失败时抛出错误) + try: + response = requests.post( + CONFIG.Ollama_ADDR + "/api/generate", + json={ + "model": "gpt-oss:20b", + "prompt": f"你是一个审查学生预约功能房间信息的老师,请审核以下预约信息,审核要求是预约事由和房间用途匹配,且不能包含政治敏感信息和色情内容。房间名称:{room_name}。事由:{reason}。如果审核通过,只输出 1,不通过,则输出 0,然后接一个空格,在空格后输出不通过的原因,原因尽量简要;如无法判断,默认不通过", + "stream": False, + "think": False + } + ) # GPT-OSS:20b 实测效果最好 + response.raise_for_status() # 如果响应状态码不是 200,将抛出异常 + result = response.json() + # 根据上述格式解析 response 部分,给出返回值 + output = result.get("response", "").strip() + if output.startswith("1"): + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.APPROVED + ) + return True, "Approved" + elif output.startswith("0"): + why = output[1:].strip() # 获取不通过的原因 + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.NOT_APPROVED, + Idetail=why + ) + return False, "房间用途不合规:" + why if why else "Not approved" + else: + logger.error( + f"AI Inspection failed: Unexpected response format {output}") + return False, "Unexpected response format" + + except requests.RequestException as e: + # 处理请求异常 + # Idetail 最多记录 500 字符 + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.NOT_APPROVED, + Idetail=f"Request failed: {str(e)[:480]}" + ) + + # 错误信息需记入日志 + logger.error(f"AI Inspection failed: {str(e)}") + + return False, f"发生错误,请稍后再试或联系管理员!" + + +def GLM_Inspection(room_name, reason, user) -> tuple[bool, str]: + # GLM 审核 + + # 组装要审核的文本 + content = f"房间:{room_name}\n事由:{reason}" + # 超时时间可从配置读取,默认30秒 + timeout = CONFIG.GLM_Timeout + try: + decision, why = _call_glm_decision(content, timeout=timeout) + passed = (decision == 1) + # 返回是否通过和理由 True通过,False不通过 + if passed: + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.APPROVED + ) + return True, "合规" + else: + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.NOT_APPROVED, + Idetail=why + ) + return False, f"房间用途不合规:{why or '无具体理由'}" + + except Exception as e: + # API 调用失败:不通过并附带错误信息 + # Idetail 最多记录 500 字符 + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason, + Iresult=AI_Inspection_Info.Result.NOT_APPROVED, + Idetail=f"Request failed: {str(e)[:480]}" + ) + + # 错误信息需记入日志 + logger.error(f"AI Inspection failed: {str(e)}") + + return False, f"发生错误,请稍后再试或联系管理员!" + + +def AI_Inspection(room_name, reason, user) -> tuple[bool, str]: + # AI 审核功能接口,将预约房间(名称)和事由发送至 API,然后接收判断结果(合格/不合格) + # Additional:最好能在不合格时附带理由 + + if not CONFIG.AI_Inspection_Enabled: + # 默认通过 + return True, "AI Inspection is disabled" + + # reason 长度限制在 250 字符以内 + if len(reason) > 250: + AI_Inspection_Info.objects.create( + Iperson=user, + Iroom=Room.objects.get(Rtitle=room_name), + Ireason=reason[:250], + Iresult=AI_Inspection_Info.Result.NOT_APPROVED, + Idetail="事由过长,超过250字符" + ) + return False, "事由过长,请限制在250字符以内!" + + # 获取该用户上一次提交审核记录的时间,若距离现在不足 1 分钟,则拒绝本次审核 + last_record = AI_Inspection_Info.objects.filter( + Iperson=user).order_by('-Itimestamp').first() + if last_record: + if (timezone.now() - last_record.Itimestamp).total_seconds() < 60: + return False, "请勿频繁提交审核请求,请稍后再试!" + + # 在此处实现 API 的调用,并处理 API 调用失败的情况 + if CONFIG.AI_Inspection_Method == "Ollama": + return Ollama_Inspection(room_name, reason, user) + + if CONFIG.AI_Inspection_Method == "GLM": + return GLM_Inspection(room_name, reason, user) + + return False, "AI Inspection method is not recognized" diff --git a/Appointment/views.py b/Appointment/views.py index c02aefedb..27df92f52 100644 --- a/Appointment/views.py +++ b/Appointment/views.py @@ -23,9 +23,9 @@ ) from Appointment.extern.wechat import MessageType, notify_appoint from Appointment.utils.utils import ( - get_conflict_appoints, - to_feedback_url, - get_total_appoint_time, + get_conflict_appoints, + to_feedback_url, + get_total_appoint_time, get_overlap_appoints ) from Appointment.utils.log import logger, get_user_logger @@ -41,6 +41,7 @@ ) from Appointment import jobs from Appointment.config import appointment_config as CONFIG +from Appointment.utils.AI_Inspection import AI_Inspection # 一些固定值 @@ -148,7 +149,6 @@ def renewLongtermAppoint(request): return redirect(message_url(context, reverse("Appointment:account"))) - @identity_check(redirect_field_name='origin') def account(request: HttpRequest): """ @@ -420,15 +420,16 @@ def arrange_time(request: HttpRequest): has_longterm_permission = get_participant(request.user).longterm # 用于前端使用 - allow_overlap = CONFIG.allow_overlap - max_appoint_time = int(CONFIG.max_appoint_time.total_seconds() // 3600) # 以小时计算 + allow_overlap = CONFIG.allow_overlap + max_appoint_time = int( + CONFIG.max_appoint_time.total_seconds() // 3600) # 以小时计算 is_person = request.user.is_person() - # 处理从URL参数传递过来的错误信息 + # 处理从URL参数传递过来的错误信息 html_display = {} html_display["warn_code"], html_display["warn_message"] = my_messages.get_request_message( request) - + # 获取房间编号 Rid = request.GET.get('Rid') try: @@ -465,7 +466,8 @@ def arrange_time(request: HttpRequest): for day in [start_day + timedelta(days=i) for i in range(7)]: used_time = get_total_appoint_time(get_participant(request.user), day) available_hour = CONFIG.max_appoint_time - used_time - available_hours[day.strftime('%a')] = int(available_hour.total_seconds() // 3600) # 以小时计算 + available_hours[day.strftime('%a')] = int( + available_hour.total_seconds() // 3600) # 以小时计算 # 获取预约时间的最大时间块id max_stamp_id = web_func.get_time_id(room, room.Rfinish, mode="leftopen") @@ -684,6 +686,7 @@ def _get_content_room(contents: dict) -> Room: assert room is not None, f'房间{room_id}不存在!' return room + def _get_content_students(contents: dict): students_id = contents.get('students') # TODO: 目前调用时一定存在,后续看情况是处理后调用本函数与否,修改检查方式 @@ -692,9 +695,10 @@ def _get_content_students(contents: dict): assert len(students) == len(students_id), '预约人信息有误,请检查后重新发起预约!' return students + def _add_appoint(contents: dict, start: datetime, finish: datetime, non_yp_num: int, - type: Appoint.Type = Appoint.Type.NORMAL, - notify_create: bool = True) -> tuple[Appoint | None, str]: + type: Appoint.Type = Appoint.Type.NORMAL, + notify_create: bool = True) -> tuple[Appoint | None, str]: ''' 创建一个预约,检查各种条件,屎山函数 @@ -881,7 +885,8 @@ def checkout_appoint(request: UserRequest): appoint_params=appoint_params, has_longterm_permission=has_longterm_permission, has_interview_permission=has_interview_permission, - interview_max_count=CONFIG.interview_max_num) + interview_max_count=CONFIG.interview_max_num, + AI_Inspection_Enabled=CONFIG.AI_Inspection_Enabled) # 提供搜索功能的数据 search_users = User.objects.filter_type(User.Type.PERSON) @@ -915,16 +920,21 @@ def checkout_appoint(request: UserRequest): # 检查是否未填写房间用途 if not contents['Ausage']: wrong("请输入房间用途!", render_context) + # 自动化审核房间用途是否合规 + is_valid, reason = AI_Inspection( + room.Rtitle, contents['Ausage'], request.user) + if not is_valid: + wrong(f"预约失败! {reason}", render_context) # 处理单人预约 if "students" not in contents.keys(): contents['students'] = [contents['Sid']] else: contents['students'].append(contents['Sid']) - + # 不允许用非活跃用户凑数 # 虽然在生成搜索列表时已经排除了非活跃用户,但这里再检查一次,以防万一 contents['students'] = list(filter( - lambda sid: User.objects.get(username = sid).active, + lambda sid: User.objects.get(username=sid).active, contents['students'] )) # 处理长期预约的times和interval参数 - 移到这里 @@ -941,7 +951,8 @@ def checkout_appoint(request: UserRequest): selected_ids = [w['id'] for w in stu_list if w['id'] in selected_ids] json_context.update(selected_ids=selected_ids) - render_context.update(contents=contents, show_clause=True, json_context=json_context) + render_context.update( + contents=contents, show_clause=True, json_context=json_context) return render(request, 'Appointment/checkout.html', render_context) # 检查长期预约周数是否填写 if is_longterm and not times: @@ -976,26 +987,26 @@ def checkout_appoint(request: UserRequest): # TODO: 隔周预约的处理可优化,根据start_week调整实际预约时间 start_time += timedelta(weeks=start_week) end_time += timedelta(weeks=start_week) - + # 预约时间检查 if ( applicant.Sid.is_person() - and not is_longterm + and not is_longterm and not is_interview and get_total_appoint_time(applicant, start_time.date()) + (end_time - start_time) > CONFIG.max_appoint_time ): wrong('您预约的时长已超过每日最大预约时长', render_context) - + # 检查预约者是否有同时段的预约 if ( applicant.Sid.is_person() - and not CONFIG.allow_overlap - and not is_longterm - and not is_interview + and not CONFIG.allow_overlap + and not is_longterm + and not is_interview and get_overlap_appoints(applicant, start_time, end_time).exists() ): wrong(f'您在该时间段已经有预约', render_context) - + if my_messages.get_warning(render_context)[0] is None: # 参数检查全部通过,下面开始创建预约 appoint_type = Appoint.Type.NORMAL @@ -1054,7 +1065,8 @@ def checkout_appoint(request: UserRequest): selected_ids = set(contents.pop('students')) selected_ids = [w['id'] for w in stu_list if w['id'] in selected_ids] json_context.update(selected_ids=selected_ids) - render_context.update(contents=contents, show_clause=True, json_context=json_context) + render_context.update( + contents=contents, show_clause=True, json_context=json_context) return render(request, 'Appointment/checkout.html', render_context) diff --git a/config_template.json b/config_template.json index 549f0c1a8..ca9f9062b 100644 --- a/config_template.json +++ b/config_template.json @@ -56,6 +56,13 @@ }, "semester_data": { "semester_start": "2023-02-20" + }, + "AI_Inspection": { + "enabled": false, + "method": "Ollama", + "Ollama_ADDR": "", + "GLM_API_Key": "", + "GLM_Timeout": 30 } }, "course": { diff --git a/templates/Appointment/checkout.html b/templates/Appointment/checkout.html index 535397eca..bd394ff12 100644 --- a/templates/Appointment/checkout.html +++ b/templates/Appointment/checkout.html @@ -39,6 +39,14 @@