diff --git a/Appointment/appoint/manage.py b/Appointment/appoint/manage.py
index c96bfc728..99f8a56d2 100644
--- a/Appointment/appoint/manage.py
+++ b/Appointment/appoint/manage.py
@@ -11,7 +11,6 @@
from Appointment.extern.wechat import MessageType, notify_appoint
from Appointment.extern.jobs import set_appoint_reminder
from utils.wrap import return_on_except, stringify_to
-from achievement.api import unlock_achievement
__all__ = [
@@ -186,8 +185,6 @@ def create_appoint(
# 如果预约者是个人,解锁成就-完成地下室预约 该部分尚未测试
user = appointer.Sid
- if user.is_person():
- unlock_achievement(user, '完成地下室预约')
return _success(appoint)
diff --git a/Appointment/summary.py b/Appointment/summary.py
deleted file mode 100644
index 7df4bded9..000000000
--- a/Appointment/summary.py
+++ /dev/null
@@ -1,374 +0,0 @@
-import os
-import json
-from datetime import datetime
-
-from django.http import HttpRequest
-from django.shortcuts import render, redirect
-from django.urls import reverse
-
-from Appointment.models import Room
-from Appointment.utils.identity import identity_check
-
-
-@identity_check(redirect_field_name='origin')
-def summary(request): # 主页
- Pid = ""
-
- try:
- if not Pid:
- Pid = request.user.username
- with open(f'Appointment/summary_info/{Pid}.txt', 'r', encoding='utf-8') as fp:
- myinfo = json.load(fp)
- except:
- return redirect(reverse("Appointment:logout"))
-
- Rid_list = {room.Rid: room.Rtitle.split(
- '(')[0] for room in Room.objects.all()}
-
- # page 0
- Sname = myinfo['Sname']
-
- # page 1
- all_appoint_num = 12649
- all_appoint_len = 19268.17
- all_appoint_len_day = round(all_appoint_len/24)
-
- # page 2
- appoint_make_num = int(myinfo['appoint_make_num'])
- appoint_make_num_pct = myinfo['rank_num']
- appoint_make_hour = round(myinfo['appoint_make_hour'], 2)
- appoint_make_hour_pct = myinfo['rank_hour']
- appoint_attend_num = int(myinfo['appoint_attend_num'])
- appoint_attend_hour = round(myinfo['appoint_attend_hour'], 2)
-
- # page 3
- hottest_room_1 = ['B214', Rid_list['B214'], 1952]
- hottest_room_2 = ['B220', Rid_list['B220'], 1715]
- hottest_room_3 = ['B221', Rid_list['B221'], 1661]
-
- # page 4
- Sfav_room_id = myinfo['favourite_room_id']
- if Sfav_room_id:
- Sfav_room_name = Rid_list[Sfav_room_id]
- Sfav_room_freq = int(myinfo['favourite_room_freq'])
-
- # page 5
- Smake_time_most = myinfo['make_time_most']
- if Smake_time_most:
- Smake_time_most = int(Smake_time_most)
-
- try:
- Suse_time_list = myinfo['use_time_list'].split(';')
- except:
- Suse_time_list = [0]*24
- Suse_time_list = list(map(lambda x: int(x), Suse_time_list))
- try:
- Suse_time_most = Suse_time_list.index(max(Suse_time_list))
- except:
- Suse_time_most = -1
- Suse_time_list_js = json.dumps(Suse_time_list[6:])
- Suse_time_list_label = [str(i) for i in range(6, 24)]
- Suse_time_list_label_js = json.dumps(Suse_time_list_label)
-
- # page 6
- Sfirst_appoint = myinfo['first_appoint']
- if Sfirst_appoint:
- Sfirst_appoint = Sfirst_appoint.split('|')
- Sfirst_appoint.append(Rid_list[Sfirst_appoint[4]])
-
- # page 7
- Skeywords = myinfo['usage']
- if Skeywords:
- Skeywords = Skeywords.split('|')
- Skeywords_for_len = Skeywords.copy()
- if '' in Skeywords_for_len:
- Skeywords_for_len.remove('')
- Skeywords_len = len(Skeywords_for_len)
- else:
- Skeywords_len = 0
-
- # page 8
- Sfriend = myinfo['friend']
- if Sfriend == '':
- Sfriend = None
- if Sfriend:
- Sfriend = Sfriend.split(';')
-
- # page 9 熬夜冠军
- aygj = myinfo['aygj']
- if aygj:
- aygj = aygj.split('|')
- aygj_num = 80
-
- # page 10 早起冠军
- zqgj = myinfo['zqgj']
- if zqgj:
- zqgj = zqgj.split('|')
- # print(zqgj)
- zqgj.insert(6, Rid_list[zqgj[5]])
- zqgj_num = 109
-
- # page 11 未雨绸缪
- wycm = myinfo['wycm']
- wycm_num = 44
-
- # page 12 极限操作
- jxcz = myinfo['jxcz']
- if jxcz:
- jxcz = jxcz.split('|')
- jxcz.insert(6, Rid_list[jxcz[5]])
- jxcz_num = 102
-
- # page 13 元培鸽王
- ypgw = myinfo['ypgw']
- ypgw_num = 22
-
- # page 14 新功能预告
- return render(request, 'Appointment/summary.html', locals())
-
-
-def summary2021(request: HttpRequest):
- # 年度总结
- from dm.summary import generic_info, person_info
-
- base_dir = 'test_data'
-
- logged_in = request.user.is_authenticated
- if logged_in:
- username = request.session.get("NP", "")
- if username:
- from app.utils import update_related_account_in_session
- update_related_account_in_session(request, username, shift=True)
-
- is_freshman = request.user.username.startswith('22')
- user_accept = request.GET.get('accept') == 'true'
- infos = generic_info()
- infos.update(
- logged_in=logged_in,
- is_freshman=is_freshman,
- user_accept=user_accept,
- )
-
- if user_accept and logged_in and not is_freshman:
- try:
- infos.update(person_info(request.user))
- with open(os.path.join(base_dir, 'rank_info.json')) as f:
- rank_info = json.load(f)
- sid = request.user.username
- for k in ['co_pct', 'func_appoint_pct', 'discuss_appoint_pct']:
- infos[k] = rank_info[k].index(
- sid) * 100 // len(rank_info[k])
- except:
- pass
- else:
- try:
- example_file = os.path.join(base_dir, 'example.json')
- with open(example_file) as f:
- infos.update(json.load(f))
- except:
- pass
-
- return render(request, 'Appointment/summary2021.html', infos)
-
-def summary2023(request: HttpRequest):
- # 2023年度总结
- base_dir = 'static/Appointment/assets/summary_data/summary2023'
-
- logged_in = request.user.is_authenticated
- if logged_in:
- username = request.session.get("NP", "")
- if username:
- from app.utils import update_related_account_in_session
- update_related_account_in_session(request, username, shift=True)
-
- user_accept = request.GET.get('accept') == 'true'
- user_cancel = request.GET.get('cancel') == 'true'
- infos = {}
-
- infos.update(logged_in=logged_in, user_accept=user_accept, user_cancel=user_cancel)
-
- if not user_accept or not logged_in or user_cancel:
- # 新生/不接受协议/未登录 展示样例
- example_file = os.path.join(base_dir, 'template.json')
- with open(example_file) as f:
- infos.update(json.load(f))
- if logged_in:
- with open(os.path.join(base_dir, 'summary2023.json'), 'r') as f:
- infos.update(home_Sname=json.load(f)[request.user.username].get('Sname', ''))
- else:
- # 读取年度总结中该用户的个人数据
- with open(os.path.join(base_dir, 'summary2023.json'), 'r') as f:
- infos.update(json.load(f)[request.user.username])
-
- infos.update(home_Sname = infos['Sname'])
-
- # 读取年度总结中该用户的排名数据
- with open(os.path.join(base_dir, 'rank2023.json'), 'r') as f:
- infos.update(json.load(f)[request.user.username])
-
- # 读取年度总结中所有用户的总体数据
- with open(os.path.join(base_dir, 'summary_overall_2023.json'), 'r') as f:
- infos.update(json.load(f))
-
- # 将数据中缺少的项利用white-template中的默认值补齐
- with open(os.path.join(base_dir, 'white-template.json'), 'r') as f:
- white_template = json.load(f)
- for key, value in white_template.items():
- if key not in infos.keys():
- infos[key] = value
-
- # 计算用户自注册起至今过去的天数
- _date_joint = datetime.fromisoformat(infos['date_joined'])
- _date_now = datetime.now()
- days_passed = (_date_now - _date_joint).days
- infos.update(days_passed=days_passed)
-
- # 处理导出的最常预约研讨室/功能室的数据格式是单元素list的情况
- Function_appoint_most_room = infos.get('Function_appoint_most_room')
- if Function_appoint_most_room is not None:
- if isinstance(Function_appoint_most_room, list):
- if Function_appoint_most_room:
- infos['Function_appoint_most_room'] = Function_appoint_most_room[0]
- else:
- infos['Function_appoint_most_room'] = ''
-
- Discuss_appoint_most_room = infos.get('Discuss_appoint_most_room')
- if Discuss_appoint_most_room is not None:
- if isinstance(Discuss_appoint_most_room, list):
- if Discuss_appoint_most_room:
- infos['Discuss_appoint_most_room'] = Discuss_appoint_most_room[0]
- else:
- infos['Discuss_appoint_most_room'] = ''
-
- # 将导出数据中iosformat的日期转化为只包含年、月、日的文字
- if infos.get('Discuss_appoint_longest_day'): # None or ''
- Discuss_appoint_longest_day = datetime.fromisoformat(infos['Discuss_appoint_longest_day'])
- infos['Discuss_appoint_longest_day'] = Discuss_appoint_longest_day.strftime("%Y年%m月%d日")
- if infos.get('Function_appoint_longest_day'):
- Function_appoint_longest_day = datetime.fromisoformat(infos['Function_appoint_longest_day'])
- infos['Function_appoint_longest_day'] = Function_appoint_longest_day.strftime("%Y年%m月%d日")
-
- # 对最长研讨室/功能室预约的小时数向下取整
- if infos.get('Discuss_appoint_longest_duration'):
- Discuss_appoint_longest_day_hours = infos['Discuss_appoint_longest_duration'].split('小时')[0]
- infos.update(Discuss_appoint_longest_day_hours = Discuss_appoint_longest_day_hours)
- else:
- infos.update(Discuss_appoint_longest_day_hours = 0)
-
- if infos.get('Function_appoint_longest_duration'):
- Function_appoint_longest_day_hours = infos['Function_appoint_longest_duration'].split('小时')[0]
- infos.update(Function_appoint_longest_day_hours = Function_appoint_longest_day_hours)
- else:
- infos.update(Function_appoint_longest_day_hours = 0)
-
- # 处理导出共同预约关键词数据格式为[co_keyword, appear_num]的情况
- if infos.get('co_keyword'):
- if isinstance(infos.get('co_keyword'), list):
- if infos['co_keyword']:
- co_keyword, num = infos['co_keyword']
- infos['co_keyword'] = co_keyword
- else:
- infos['co_keyword'] = ''
-
- # 将list格式的top3最热门课程转化为一个字符串
- hottest_courses_23_fall_dict = infos['hottest_courses_23_Fall']
- hottest_course_names_23_fall = '\n'.join([list(dic.keys())[0] for dic in hottest_courses_23_fall_dict])
- infos.update(hottest_course_names_23_fall=hottest_course_names_23_fall)
- hottest_courses_23_spring_dict = infos['hottest_courses_23_Spring']
- hottest_course_names_23_spring = '\n'.join([list(dic.keys())[0] for dic in hottest_courses_23_spring_dict])
- infos.update(hottest_course_names_23_spring=hottest_course_names_23_spring)
-
- # 根据最长连续签到天数授予用户称号
- max_consecutive_days = infos.get('max_consecutive_days')
- if max_consecutive_days is not None:
- if max_consecutive_days <= 3:
- infos.update(consecutive_days_name='初探新世界')
- elif max_consecutive_days <= 7:
- infos.update(consecutive_days_name='到此一游')
- elif max_consecutive_days <= 15:
- infos.update(consecutive_days_name='常住居民')
- else:
- infos.update(consecutive_days_name='永恒真爱粉')
- else:
- infos.update(consecutive_days_name='')
-
- # 处理用户创建学生小组过多的情况
- if infos.get('myclub_name'):
- myclub_name_list = infos['myclub_name'].split(',')
- if len(myclub_name_list) > 3:
- myclub_name_list = myclub_name_list[:3]
- infos.update(myclub_name=','.join(myclub_name_list) + '等')
-
- # 处理用户担任admin职务的小组数过多的情况
- if infos.get('admin_org_names'):
- admin_org_names = infos['admin_org_names']
- if len(admin_org_names) > 3:
- admin_org_names = admin_org_names[:3]
- infos.update(admin_org_names_str=','.join(admin_org_names) + '等')
- else:
- infos.update(admin_org_names_str=','.join(admin_org_names))
- else:
- infos.update(admin_org_names_str='')
-
- # 将小组活动预约top3关键词由list转为一个string
- if infos.get('act_top_three_keywords'):
- act_top_three_keywords = infos['act_top_three_keywords']
- infos.update(act_top_three_keywords_str=','.join(act_top_three_keywords))
- else:
- infos.update(act_top_three_keywords_str='')
-
- # 根据参加小组活动最频繁时间段授予用户称号
- most_act_common_hour = infos.get('most_act_common_hour')
- if most_act_common_hour is not None:
- if most_act_common_hour <= 10:
- infos.update(most_act_common_hour_name='用相聚开启元气满满的一天')
- elif most_act_common_hour <= 13:
- infos.update(most_act_common_hour_name='不如再用一顿美食为这次相聚做个注脚')
- elif most_act_common_hour <= 16:
- infos.update(most_act_common_hour_name='突击检查,瞌睡虫有没有出现?')
- elif most_act_common_hour <= 18:
- infos.update(most_act_common_hour_name='此刻的欢畅还有落霞余晖作伴')
- elif most_act_common_hour <= 23:
- infos.update(most_act_common_hour_name='夜色深沉时,每一个细胞都在期待着相约相聚')
- else:
- infos.update(most_act_common_hour_name='让星月陪我们狂歌竞夜')
- else:
- infos.update(most_act_common_hour_name='')
-
- # 计算参与的学生小组+书院课程小组数
- infos.update(club_course_num=infos.get('club_num', 0)+infos.get('course_org_num', 0))
-
- # 根据已选修书院课程种类数授予成就
- type_count = infos.get('type_count', 0)
- if type_count == 5:
- infos.update(type_count_name='五边形战士')
- elif type_count >= 2:
- infos.update(type_count_name='广泛涉猎')
- elif type_count == 1:
- infos.update(type_count_name='垂直深耕')
- else:
- infos.update(type_count_name='你先别急')
-
- # 计算2023年两学期平均书院课程预选数和选中数
- avg_preelect_num = (infos['preelect_course_23fall_num'] + infos['preelect_course_23spring_num']) / 2
- avg_elected_num = (infos['elected_course_23fall_num'] + infos['elected_course_23spring_num']) / 2
- infos.update(avg_preelect_num=avg_preelect_num, avg_elected_num=avg_elected_num)
-
- # 根据盲盒中奖率授予成就
- mystery_boxes_num = infos['mystery_boxes_num']
- # 处理导出数据中的typo
- if 'lukcy_mystery_boxes_num' in infos.keys():
- lucky_mystery_boxes_num = infos.pop('lukcy_mystery_boxes_num')
- infos.update(lucky_mystery_boxes_num=lucky_mystery_boxes_num)
- lucky_mystery_boxes_num = infos['lucky_mystery_boxes_num']
- # 防止除零错误
- if (lucky_mystery_boxes_num != 0):
- lucky_rate = mystery_boxes_num / lucky_mystery_boxes_num
- if lucky_rate >= 0.5:
- infos.update(mystery_boxes_name='恭迎欧皇加冕')
- else:
- infos.update(mystery_boxes_name='发出尖锐爆鸣的非酋')
- else:
- infos.update(mystery_boxes_name='')
-
- return render(request, 'Appointment/summary2023.html', infos)
diff --git a/Appointment/urls.py b/Appointment/urls.py
index ca35d35c6..d35fedc26 100644
--- a/Appointment/urls.py
+++ b/Appointment/urls.py
@@ -14,7 +14,7 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
'''
from django.urls import path
-from Appointment import views, summary, hardware_api
+from Appointment import views, hardware_api
# 参考:https://stackoverflow.com/questions/61254816/what-is-the-purpose-of-app-name-in-urls-py-in-django
# 一般情况下不需要使用 app_name
@@ -49,7 +49,4 @@
path('camera-check', hardware_api.cameracheck, name='cameracheck'),
path('display_getappoint', hardware_api.display_getappoint,
name='display_getappoint'),
- path('summary', summary.summary, name='summary'),
- path('summary/2021', summary.summary2021, name='summary2021'),
- path('summary/2023', summary.summary2023, name='summary2023'),
]
diff --git a/Appointment/utils/log.py b/Appointment/utils/log.py
index ab855a78a..6fb1de3d5 100644
--- a/Appointment/utils/log.py
+++ b/Appointment/utils/log.py
@@ -64,7 +64,7 @@ def _send_wechat(self, message: str, level: int = logging.ERROR):
from extern.wechat import send_wechat
send_wechat(
GLOBAL_CONFIG.debug_stuids,
- '地下室发生错误', message,
+ '房间发生错误', message,
url=f'/logs/?file={self.name}.log',
)
diff --git a/achievement/__init__.py b/achievement/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/achievement/admin.py b/achievement/admin.py
deleted file mode 100644
index 6b253a8e5..000000000
--- a/achievement/admin.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from django.contrib import admin
-
-from achievement.models import *
-from generic.admin import UserAdmin
-
-
-@admin.register(AchievementType)
-class AchievementTypeAdmin(admin.ModelAdmin):
- list_display = ['title', 'description']
- search_fields = ['title']
-
-
-@admin.register(Achievement)
-class AchievementAdmin(admin.ModelAdmin):
- list_display = ['name', 'description',
- 'achievement_type', 'hidden', 'auto_trigger']
- search_fields = ['name']
-
-
-@admin.register(AchievementUnlock)
-class AchievementUnlockAdmin(admin.ModelAdmin):
- list_display = ['user', 'achievement',
- 'time', 'private']
- search_fields = [*UserAdmin.suggest_search_fields('user'), 'achievement__name']
diff --git a/achievement/api.py b/achievement/api.py
deleted file mode 100644
index ae16b76ad..000000000
--- a/achievement/api.py
+++ /dev/null
@@ -1,173 +0,0 @@
-'''
-本部分包含所有解锁成就相关的API
-'''
-from datetime import datetime, date
-
-from django.db.models import Sum
-
-import utils.models.query as SQ
-from generic.models import User
-from app.models import CourseRecord, Course, NaturalPerson as Person
-from achievement.models import Achievement
-from achievement.utils import trigger_achievement, bulk_add_achievement_record, get_students_without_credit_record
-
-
-__all__ = [
- 'unlock_achievement',
- 'unlock_course_achievements',
- 'unlock_YQPoint_achievements',
- 'unlock_signin_achievements',
- 'unlock_credit_achievements',
-]
-
-
-def unlock_achievement(user: User, achievement_name: str) -> bool:
- '''
- 解锁成就
-
- :param user: 要解锁的用户
- :type user: User
- :param achievement_name: 要解锁的成就名
- :type achievement_name: str
- :return: 是否成功解锁
- :rtype: bool
- '''
- try:
- achivement = Achievement.objects.get(name=achievement_name)
- except:
- return False
- return trigger_achievement(user, achivement)
-
-
-def _unlock_by_value(user: User, acquired_value: float,
- sorted_achievements: list[tuple[float, str]]) -> bool:
- '''将所有超过了解锁阈值的成就解锁'''
- created = False
- for bound, achievement_name in sorted_achievements:
- if acquired_value < bound:
- break
- created |= unlock_achievement(user, achievement_name)
- return created
-
-
-''' 元气人生 '''
-
-''' 洁身自好 : 暂时不做 '''
-
-''' 严于律己 : 定时任务 '''
-
-''' 五育并举 '''
-
-
-def unlock_course_achievements(user: User) -> None:
- '''
- 解锁成就 包含五育并举与学时相关的成就的判断
- 这个不太清楚怎么调用合适 是专门写一个command(个人倾向) 还是写在view的homepage里面?
-
- :param user: 要查询的用户
- :type user: User
- '''
- records = SQ.sfilter([CourseRecord.person, Person.person_id], user
- ).filter(invalid=False)
- if not records:
- return
-
- # 统计有效学时
- total_hours = records.aggregate(
- total_hours=Sum('total_hours'))['total_hours']
- # 解锁成就
- _unlock_by_value(user, total_hours, [
- (32, '完成一半书院学分要求'),
- (64, '完成全部书院学分要求'),
- (96, '超额完成一半书院学分要求'),
- (128, '超额完成一倍书院学分要求'),
- ])
-
- # 德智体美劳检验
- course_types = set(SQ.qsvlist(records, CourseRecord.course, Course.type))
- COURSE_DICT = {
- Course.CourseType.MORAL: '德育',
- Course.CourseType.INTELLECTUAL: '智育',
- Course.CourseType.PHYSICAL: '体育',
- Course.CourseType.AESTHETICS: '美育',
- Course.CourseType.LABOUR: '劳动教育',
- }
- for course_type in course_types:
- unlock_achievement(user, '首次修习' + COURSE_DICT[course_type] + '课程')
-
-
-''' 志同道合 '''
-
-''' 元气满满 '''
-
-
-def unlock_YQPoint_achievements(user: User, start_time: datetime, end_time: datetime) -> None:
- '''
- 解锁成就 包含元气满满所有成就的判断
-
- :param user: 要查询的用户
- :type user: User
- :param start_time: 开始时间
- :type start_time: datetime
- :param end_time: 结束时间
- :type end_time: datetime
- '''
- # 计算收支情况
- # TODO: 存在循环引用,暂时放在这里,后续改为信号控制的方式后可改回
- from app.YQPoint_utils import get_income_expenditure
- income, expenditure = get_income_expenditure(user, start_time, end_time)
- _unlock_by_value(user, income, [
- (1, '首次获得元气值'),
- (10, '学期内获得10元气值'),
- (30, '学期内获得30元气值'),
- (50, '学期内获得50元气值'),
- (100, '学期内获得100元气值'),
- ])
- _unlock_by_value(user, expenditure, [
- (1, '首次消费元气值'),
- (10, '学期内消费10元气值'),
- (30, '学期内消费30元气值'),
- (50, '学期内消费50元气值'),
- (100, '学期内消费100元气值'),
- ])
-
-
-''' 三五成群 : 全部外部录入 '''
-
-''' 智慧生活 '''
-
-# 连续登录系列
-
-
-def unlock_signin_achievements(user: User, continuous_days: int) -> bool:
- '''
- 解锁成就
- 智慧生活-连续登录一周/一学期/一整年
-
- :param user: 要解锁的用户
- :type user: User
- :return: 是否成功解锁
- :rtype: bool
- '''
- created = _unlock_by_value(user, continuous_days, [
- (7, '连续登录一周'),
- (7 * 16, '连续登录一学期'),
- (365, '连续登录一整年'),
- ])
- return created
-
-def unlock_credit_achievements(start_date: date, end_date: date, achievement_name: str) -> None:
- '''
- 解锁成就
- 信用分相关成就激活判断
-
- :param start_date: 开始日期
- :type start_date: date
- :param end_date: 结束日期
- :type end_date: date
- :param achievement_name: 要解锁的成就名
- :type achievement_name: str
- '''
- students = get_students_without_credit_record(start_date, end_date)
- achievement = Achievement.objects.get(name=achievement_name)
- bulk_add_achievement_record(students, achievement)
diff --git a/achievement/apps.py b/achievement/apps.py
deleted file mode 100644
index 3c6023f5f..000000000
--- a/achievement/apps.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.apps import AppConfig
-
-
-class AchievementConfig(AppConfig):
- default_auto_field = "django.db.models.BigAutoField"
- name = "achievement"
diff --git a/achievement/jobs.py b/achievement/jobs.py
deleted file mode 100644
index a249cb4ec..000000000
--- a/achievement/jobs.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from datetime import date, timedelta
-
-from scheduler.periodic import periodical
-from achievement.models import Achievement
-from achievement.utils import bulk_add_achievement_record, get_students_by_grade
-from achievement.api import unlock_credit_achievements
-from semester.api import current_semester
-
-
-__all__ = [
- 'unlock_credit_achievements',
- 'new_school_year_achievements',
-]
-
-
-# 信用分相关成就激活判断 每月1日6点运行
-@periodical('cron', job_id='解锁信用分成就', day=1, hour=6, minute=0)
-def unlock_credit_achievements():
- semester = current_semester()
- today = date.today()
- last_month_lastday = today - timedelta(days=today.day)
- last_month_firstday = last_month_lastday.replace(day=1)
- DAYS_LIMIT = 21
- # 如果当前日期位于学期中间,进行'当月没有扣除信用分'成就的触发
- if semester.start_date <= today < semester.end_date: # 注意end_date是指放假开始的当天
- # 上月在学期内的天数超过阈值
- if (today - semester.start_date).days >= DAYS_LIMIT:
- unlock_credit_achievements(
- last_month_firstday, last_month_lastday, '当月没有扣除信用分')
- # 如果当前日期位于学期结束后
- elif today >= semester.end_date:
- # 进行'一学期没有扣除信用分'成就的触发
- unlock_credit_achievements(
- semester.start_date, semester.end_date-timedelta(days=1), '一学期没有扣除信用分')
- # 进行'当月没有扣除信用分'成就的触发
- if last_month_firstday < semester.end_date:
- # 上月在学期内的天数超过阈值
- if (semester.end_date - last_month_firstday).days >= DAYS_LIMIT:
- unlock_credit_achievements(
- last_month_firstday, last_month_lastday, '当月没有扣除信用分')
- # 简单起见,不妨每年7月1日进行'一学年没有扣除信用分'成就触发
- if today.month == 7:
- unlock_credit_achievements(
- today-timedelta(days=365), today, '一学年没有扣除信用分')
-
-
-def new_school_year_achievements():
- '''触发 元气人生-开启大学第二、三、四年'''
- for i, name in zip(range(2, 7), ['二', '三', '四', '五', '六']):
- achievement = Achievement.objects.get(name=f'开启大学生活第{name}年')
- bulk_add_achievement_record(get_students_by_grade(i), achievement)
diff --git a/achievement/management/__init__.py b/achievement/management/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/achievement/management/commands/__init__.py b/achievement/management/commands/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/achievement/management/commands/init_achievements.py b/achievement/management/commands/init_achievements.py
deleted file mode 100644
index e728627f1..000000000
--- a/achievement/management/commands/init_achievements.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-初始化 AchievementType Achievement
-假定achievement_type都存在
-"""
-from django.core.management.base import BaseCommand
-from achievement.models import AchievementType, Achievement
-
-
-class Command(BaseCommand):
- help = "init AchievementType Achievement"
-
- def handle(self, *args, **options):
- DISPLAYED = True
- HIDDEN = False
- AUTO = True
- MANUAL = False
- achievement_info = [('元气人生',
- [('完成游园会印章收集任务', 3, DISPLAYED, MANUAL),
- ('集齐游园会全部印章', 10, HIDDEN, MANUAL),
- ('开始大学第二年', 5, DISPLAYED, AUTO),
- ('开始大学第三年', 5, DISPLAYED, AUTO),
- ('开始大学第四年', 5, DISPLAYED, AUTO),
- ('开始大学第五年', 5, HIDDEN, AUTO),
- ('开始大学第六年', 5, HIDDEN, AUTO),
- ('完成军训', 1, DISPLAYED, AUTO),
- ('完成书院实践育人活动', 3, DISPLAYED, MANUAL),
- ('参与书院嘉年华', 2, DISPLAYED, MANUAL),
- ('本科顺利毕业', 35, DISPLAYED, AUTO)]),
- ('洁身自好',
- [('月度卫生检查通过', 1, DISPLAYED, MANUAL),
- ('一学期月度卫生检查均获得“优秀”评价', 10, HIDDEN, AUTO),
- ('一学年月度卫生检查均获得“优秀”评价', 15, HIDDEN, AUTO),
- ('本科月度卫生检查均获得“优秀”评价', 50, HIDDEN, AUTO)]),
- ('五育并举',
- [('首次报名书院课程', 1, DISPLAYED, AUTO),
- ('完成德育学分要求', 2, DISPLAYED, AUTO),
- ('完成智育学分要求', 2, DISPLAYED, AUTO),
- ('完成体育学分要求', 2, DISPLAYED, AUTO),
- ('完成美育学分要求', 2, DISPLAYED, AUTO),
- ('完成劳动教育学分要求', 2, DISPLAYED, AUTO),
- ('完成一半书院学分要求', 4, DISPLAYED, AUTO),
- ('完成全部书院学分要求', 5, DISPLAYED, AUTO),
- ('超额完成一半书院学分要求', 7, HIDDEN, AUTO),
- ('超额完成一倍书院学分要求', 10, HIDDEN, AUTO)]),
- ('志同道合',
- [('加入书院组织', 1, DISPLAYED, AUTO),
- ('参与书院俱乐部一半活动', 2, HIDDEN, AUTO),
- ('参与书院俱乐部全部活动', 5, HIDDEN, AUTO),
- ('成为书院小组负责人', 10, HIDDEN, AUTO),
- ('成为书院俱乐部负责人', 10, HIDDEN, AUTO),
- ('成为书院星级俱乐部负责人', 20, HIDDEN, AUTO),
- ('发起成立书院小组', 5, HIDDEN, AUTO)]),
- ('严于律己',
- [('当月没有扣除信用分', 0, DISPLAYED, AUTO),
- ('一学期没有扣除信用分', 2, HIDDEN, AUTO),
- ('一学年没有扣除信用分', 10, HIDDEN, AUTO),
- ('本科均没有扣除信用分', 20, HIDDEN, AUTO)]),
- ('元气满满',
- [('首次获得元气值', 0, DISPLAYED, AUTO),
- ('学期内获得10元气值', 0, DISPLAYED, AUTO),
- ('学期内获得30元气值', 0, HIDDEN, AUTO),
- ('学期内获得50元气值', 0, HIDDEN, AUTO),
- ('学期内获得100元气值', 0, HIDDEN, AUTO),
- ('首次消费元气值', 1, DISPLAYED, AUTO),
- ('学期内消费10元气值', 1, DISPLAYED, AUTO),
- ('学期内消费30元气值', 2, HIDDEN, AUTO),
- ('学期内消费50元气值', 5, HIDDEN, AUTO),
- ('学期内消费100元气值', 10, HIDDEN, AUTO)]),
- ('三五成群',
- [('加入宿舍群', 1, DISPLAYED, MANUAL),
- ('参与宿舍片区管理', 0, DISPLAYED, MANUAL),
- ('参与宿舍片区活动', 0, DISPLAYED, MANUAL)]),
- ('智慧生活',
- [('注册智慧书院', 2, DISPLAYED, AUTO),
- ('连续登录一周', 0, HIDDEN, AUTO),
- ('连续登录一学期', 20, HIDDEN, AUTO),
- ('连续登录一整年', 50, HIDDEN, AUTO),
- ('完成地下室预约', 1, DISPLAYED, AUTO),
- ('更新一次个人档案', 2, DISPLAYED, AUTO),
- ('编辑自己的学术地图', 10, DISPLAYED, AUTO),
- ('参与学术问答', 5, DISPLAYED, AUTO),
- ('使用一次反馈中心', 2, DISPLAYED, AUTO),
- ('使用一次元培书房查询', 2, DISPLAYED, AUTO)]),
- ('纪念成就',
- [('参与9月5日的团学联宣讲会', 0, DISPLAYED, MANUAL)]),]
- for achievement_type_name, achievement_list in achievement_info:
- try:
- achievement_type = AchievementType.objects.get(
- title=achievement_type_name)
- for achievement_name, reward_points, if_displayed, if_auto_trigger in achievement_list:
- Achievement.objects.update_or_create(
- name=achievement_name,
- description=achievement_name, # 默认重复一遍name
- achievement_type=achievement_type,
- hidden=not if_displayed,
- auto_trigger=if_auto_trigger,
- reward_points=reward_points
- )
- except AchievementType.DoesNotExist:
- print('AchievementType %s does not exist' %
- achievement_type_name)
- continue
diff --git a/achievement/management/commands/unlock_graduates_credit_achievements.py b/achievement/management/commands/unlock_graduates_credit_achievements.py
deleted file mode 100644
index 7e06b6a8a..000000000
--- a/achievement/management/commands/unlock_graduates_credit_achievements.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""
-解锁毕业生 '本科均没有扣除信用分' 成就
-"""
-from datetime import date, timedelta
-import pandas as pd
-
-from django.core.management.base import BaseCommand
-from achievement.models import Achievement
-from achievement.utils import bulk_add_achievement_record, get_students_without_credit_record
-
-
-class Command(BaseCommand):
- '''
- 从excel中导入成就 格式:学号
-
- filepath: excel文件路径
-
- 会自动判断是否有扣分记录
- '''
- help = "解锁毕业生 '本科均没有扣除信用分' 成就"
-
- def add_arguments(self, parser):
- parser.add_argument('filepath', type=str)
-
- def handle(self, *args, **options):
- # 读取 excel
- filepath = options['filepath']
- full_path = filepath
- data = None
- if filepath.endswith('xlsx') or filepath.endswith('xls'):
- data = pd.read_excel(f'{full_path}', sheet_name=None)
- elif filepath.endswith('csv'):
- data = pd.read_csv(f'{full_path}', dtype=object, encoding='utf-8')
- else:
- data = pd.read_table(f'{full_path}', dtype=object, encoding='utf-8')
-
- if data == None:
- print('文件格式不正确')
- return
-
- # 默认选取第一个sheet
- if type(data) == dict:
- data = data[list(data.keys())[0]]
-
- graduate_number = data['学号'].astype(str).tolist()
-
- # 读取学号
- today = date.today()
- students = get_students_without_credit_record(
- today-timedelta(days=365*6), today).filter(username__in=graduate_number)
- achievement = Achievement.objects.get(name='本科均没有扣除信用分')
- bulk_add_achievement_record(students, achievement)
diff --git a/achievement/management/commands/upload_achievements.py b/achievement/management/commands/upload_achievements.py
deleted file mode 100644
index ea81e2788..000000000
--- a/achievement/management/commands/upload_achievements.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""
-excel 上传成就
-"""
-
-from django.core.management.base import BaseCommand
-import pandas as pd
-
-from achievement.models import Achievement
-from achievement.utils import bulk_add_achievement_record
-from generic.models import User
-
-class Command(BaseCommand):
- """
- 从excel中导入成就 格式:学号 成就
-
- filepath: excel文件路径
- """
-
- help = "upload achievements from excel"
-
- def add_arguments(self, parser):
- parser.add_argument('filepath', type=str)
-
- def handle(self, *args, **options):
-
- # 读取 excel
- filepath = options['filepath']
- full_path = filepath
- data = None
- if filepath.endswith('xlsx') or filepath.endswith('xls'):
- data = pd.read_excel(f'{full_path}', sheet_name=None)
- elif filepath.endswith('csv'):
- data = pd.read_csv(f'{full_path}', dtype=object, encoding='utf-8')
- else:
- data = pd.read_table(f'{full_path}', dtype=object, encoding='utf-8')
-
- if data == None:
- print('文件格式不正确')
- return
-
- # 默认选取第一个sheet
- if type(data) == dict:
- data = data[list(data.keys())[0]]
-
- data['学号'] = data['学号'].astype(str)
-
- # 应用api接口分类批量上传成就
- grouped = data.groupby('成就')
- for achievement_name, achievement_data in grouped:
- user_number_list = list(set(achievement_data['学号'].values))
- user_list = User.objects.filter(username__in=user_number_list)
- bulk_add_achievement_record(user_list, Achievement.objects.get(name=achievement_name))
diff --git a/achievement/migrations/0001_initial.py b/achievement/migrations/0001_initial.py
deleted file mode 100644
index 3c21d832a..000000000
--- a/achievement/migrations/0001_initial.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# Generated by Django 4.2.3 on 2023-10-18 18:25
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- ]
-
- operations = [
- migrations.CreateModel(
- name='Achievement',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=100, verbose_name='名称')),
- ('description', models.TextField(verbose_name='描述')),
- ('hidden', models.BooleanField(default=False, verbose_name='隐藏')),
- ('auto_trigger', models.BooleanField(default=False, verbose_name='自动触发')),
- ('reward_points', models.PositiveIntegerField(default=0, verbose_name='奖励积分')),
- ],
- options={
- 'verbose_name': '成就',
- 'verbose_name_plural': '成就',
- },
- ),
- migrations.CreateModel(
- name='AchievementType',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=100, verbose_name='名称')),
- ('description', models.TextField(blank=True, verbose_name='描述')),
- ('badge', models.ImageField(upload_to='achievement/badges/', verbose_name='徽章')),
- ('avatar', models.ImageField(upload_to='achievement/avatars/', verbose_name='图标')),
- ],
- options={
- 'verbose_name': '成就类型',
- 'verbose_name_plural': '成就类型',
- },
- ),
- migrations.CreateModel(
- name='AchievementUnlock',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('time', models.DateTimeField(auto_now_add=True, verbose_name='解锁时间')),
- ('private', models.BooleanField(default=False, verbose_name='不公开')),
- ('achievement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='achievement.achievement', verbose_name='解锁成就')),
- ],
- options={
- 'verbose_name': '成就解锁记录',
- 'verbose_name_plural': '成就解锁记录',
- },
- ),
- ]
diff --git a/achievement/migrations/0002_initial.py b/achievement/migrations/0002_initial.py
deleted file mode 100644
index 1c0ceec60..000000000
--- a/achievement/migrations/0002_initial.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Generated by Django 4.2.3 on 2023-10-18 18:25
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- ('achievement', '0001_initial'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AddField(
- model_name='achievementunlock',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户'),
- ),
- migrations.AddField(
- model_name='achievement',
- name='achievement_type',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='achievement.achievementtype', verbose_name='类型'),
- ),
- migrations.AlterUniqueTogether(
- name='achievementunlock',
- unique_together={('user', 'achievement')},
- ),
- ]
diff --git a/achievement/migrations/__init__.py b/achievement/migrations/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/achievement/models.py b/achievement/models.py
deleted file mode 100644
index 707bd1855..000000000
--- a/achievement/models.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from django.db import models
-
-from utils.models.descriptor import admin_only
-from generic.models import User
-
-__all__ = ['AchievementType', 'Achievement', 'AchievementUnlock']
-
-
-class AchievementType(models.Model):
- class Meta:
- verbose_name = '成就类型'
- verbose_name_plural = verbose_name
-
- title = models.CharField('名称', max_length=100)
- description = models.TextField('描述', blank=True)
- badge = models.ImageField('徽章', upload_to='achievement/badges/')
- avatar = models.ImageField('图标', upload_to='achievement/avatars/')
-
- @admin_only
- def __str__(self):
- return self.title
-
- # Actual types in use (remove later)
- # UNDEFINED = (0, "未定义")
- # YUANQIRENSHENG = (1, "元气人生")
- # JIESHENZIHAO = (2, "洁身自好")
- # WUYUBINGJU = (3, "五育并举")
- # ZHITONGDAOHE = (4, "志同道合")
- # YANYULVJI = (5, "严于律己")
- # YUANQIMANMAN = (6, "元气满满")
- # SANWUCHENGQUN = (7, "三五成群")
- # ZHIHUISHWNGHUO = (8, "智慧生活")
-
-
-class Achievement(models.Model):
- class Meta:
- verbose_name = '成就'
- verbose_name_plural = verbose_name
-
- name = models.CharField('名称', max_length=100)
- description = models.TextField('描述')
- achievement_type = models.ForeignKey(
- AchievementType, on_delete=models.CASCADE, verbose_name='类型')
- hidden = models.BooleanField('隐藏', default=False)
- # Only used for filtering. Whether an achievement is auto-triggered is
- # not stored in the database.
- auto_trigger = models.BooleanField('自动触发', default=False)
-
- reward_points = models.PositiveIntegerField('奖励积分', default=0)
-
- @admin_only
- def __str__(self):
- return self.name
-
-
-class AchievementUnlock(models.Model):
- class Meta:
- verbose_name = '成就解锁记录'
- verbose_name_plural = verbose_name
- # XXX: 工具函数的并行安全性完全依赖于此约束
- unique_together = ['user', 'achievement']
-
- user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户')
- achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE,
- verbose_name='解锁成就')
- time = models.DateTimeField('解锁时间', auto_now_add=True)
- private = models.BooleanField('不公开', default=False)
diff --git a/achievement/tests.py b/achievement/tests.py
deleted file mode 100644
index 7ce503c2d..000000000
--- a/achievement/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/achievement/utils.py b/achievement/utils.py
deleted file mode 100644
index 41e2d58cb..000000000
--- a/achievement/utils.py
+++ /dev/null
@@ -1,184 +0,0 @@
-'''成就系统 API
-- 处理用户触发成就
-- 后台批量添加成就
-'''
-from datetime import date
-
-from django.db import transaction
-from django.db.models import QuerySet
-
-from generic.models import User, YQPointRecord, CreditRecord
-from app.models import Notification
-from achievement.models import Achievement, AchievementType, AchievementUnlock
-from utils.wrap import return_on_except
-from app.notification_utils import notification_create, bulk_notification_create
-from semester.api import current_semester
-from utils.marker import need_refactor
-
-
-__all__ = [
- 'personal_achievements',
- 'trigger_achievement',
- 'bulk_add_achievement_record',
- 'get_students_by_grade',
- 'get_students_without_credit_record',
-]
-
-
-def personal_achievements(user: User):
- unlocked_achievements = AchievementUnlock.objects.filter(user=user)
- invisible_achievements = list(unlocked_achievements.filter(private=True))
- visible_achievements = list(unlocked_achievements.filter(private=False))
-
- unlocked_ids = list(unlocked_achievements.values_list('achievement', flat=True))
- achievement_types = AchievementType.objects.all().order_by('id')
- # (类型,总数,已解锁成就,未解锁成就,隐藏成就)
- display_by_types: list[tuple[AchievementType, int,
- list[Achievement], list[Achievement], list[Achievement]]] = []
- for achievement_type in achievement_types:
- achievements = Achievement.objects.filter(achievement_type=achievement_type)
- unlocked = list(achievements.filter(pk__in=unlocked_ids))
- all_locked = achievements.exclude(pk__in=unlocked_ids)
- locked = list(all_locked.filter(hidden=False))
- hidden = list(all_locked.filter(hidden=True))
- display = (achievement_type, achievements.count(), unlocked, locked, hidden)
- display_by_types.append(display)
- return invisible_achievements, visible_achievements, display_by_types
-
-
-@return_on_except(False, Exception)
-@transaction.atomic
-def trigger_achievement(user: User, achievement: Achievement):
- '''
- 处理用户触发成就,添加单个解锁记录
- 若已解锁则不添加
- 按需发布通知与发放元气值奖励
-
- Args:
- - user (User): 触发该成就的用户
- - achievement (Achievement): 该成就
-
- Returns:
- - bool: 是否成功解锁
-
- Warning:
- 本函数保证原子化,且保证并行安全性,但后者实现存在风险
- '''
-
- # XXX: 并行安全性依赖于AchievementUnlock在数据库中的唯一性约束unique_together
- # 如果该约束被破坏,本函数将不再是安全的,但不易发现
-
- assert user.is_person(), '暂时只允许个人解锁成就'
-
- _, created = AchievementUnlock.objects.get_or_create(
- user=user,
- achievement=achievement
- )
-
- # 是否成功解锁
- assert created, '成就已解锁'
-
- content = f'恭喜您解锁新成就:{achievement.name}!'
- # 如果有奖励元气值
- if achievement.reward_points > 0:
- User.objects.modify_YQPoint(
- user,
- achievement.reward_points,
- source=achievement.name,
- source_type=YQPointRecord.SourceType.ACHIEVE
- )
- content += f'获得{achievement.reward_points}元气值奖励!'
- notification_create(
- receiver=user,
- sender=None,
- typename=Notification.Type.NEEDREAD,
- title=Notification.Title.ACHIEVE_INFORM,
- content=content
- )
-
- return True
-
-
-@return_on_except(False, Exception)
-@transaction.atomic
-def bulk_add_achievement_record(users: QuerySet[User], achievement: Achievement):
- '''
- 批量添加成就解锁记录
- 若已解锁则不添加
- 按需发布通知与发放元气值奖励
-
- Args:
- - users (QuerySet[User]): 待更改User的QuerySet
- - achievement (Achievement): 需添加的成就
-
- Returns:
- - bool: 是否成功添加
-
- Warning:
- 本函数保证原子化,且保证并行安全性,但后者实现存在风险
- '''
-
- # XXX: 并行安全性依赖于AchievementUnlock在数据库中的唯一性约束unique_together
- # 如果该约束被破坏,本函数将**重复解锁**成就。
- users_with_achievement = AchievementUnlock.objects.filter(
- achievement=achievement).values_list('user', flat=True)
-
- # 排除已经解锁的用户
- users_to_add = users.exclude(pk__in=users_with_achievement)
- # 批量添加成就解锁记录
- AchievementUnlock.objects.bulk_create([
- AchievementUnlock(user=user, achievement=achievement)
- for user in users_to_add
- ])
-
- content = f'恭喜您解锁新成就:{achievement.name}!'
-
- if achievement.reward_points > 0:
- User.objects.bulk_increase_YQPoint(
- users_to_add,
- achievement.reward_points,
- source=achievement.name,
- source_type=YQPointRecord.SourceType.ACHIEVE
- )
- content += f'获得{achievement.reward_points}元气值奖励!'
- bulk_notification_create(
- users_to_add,
- sender=None,
- typename=Notification.Type.NEEDREAD,
- title=Notification.Title.ACHIEVE_INFORM,
- content=content
- )
-
- return True
-
-
-@need_refactor
-def get_students_by_grade(grade: int) -> QuerySet[User]:
- '''
- 传入目标入学年份数,返回满足的、未毕业的学生User列表。
- 示例:今年是2023年,我希望返回入学第二年的user,即查找username前两位为22的user
- 仅限秋季学期开始后使用。
- '''
- semester_year = current_semester().year
- goal_year = semester_year - 2000 - grade + 1
- students = User.objects.filter_type(User.Type.STUDENT).filter(
- active=True, username__startswith=str(goal_year))
- return students
-
-
-def get_students_without_credit_record(start_date: date, end_date: date) -> QuerySet[User]:
- '''
- 获取一段时间内没有扣分记录的在读同学
-
- Args:
- - start_date: 查询起始时间
- - end_date: 查询结束时间
-
- Returns:
- - QuerySet[User]: 没有扣分记录的在读同学
- '''
- records = CreditRecord.objects.filter(
- time__date__gte=start_date, time__date__lte=end_date)
- students = User.objects.filter_type(User.Type.STUDENT).filter(
- active=True).exclude(pk__in=records.values_list('user', flat=True))
- return students
diff --git a/app/YQPoint_utils.py b/app/YQPoint_utils.py
deleted file mode 100644
index b6fc1fdd6..000000000
--- a/app/YQPoint_utils.py
+++ /dev/null
@@ -1,692 +0,0 @@
-import random
-from typing import List, Dict, Optional, Tuple
-from datetime import datetime, timedelta, date
-
-from django.db.models import QuerySet, Q, Exists, OuterRef
-from django.forms.models import model_to_dict
-
-from generic.models import User, YQPointRecord
-from app.config import CONFIG
-from app.utils_dependency import *
-from app.models import (
- Pool,
- PoolItem,
- PoolRecord,
- Notification,
- Organization,
- Participation,
-)
-from app.extern.wechat import WechatApp, WechatMessageLevel
-from app.notification_utils import bulk_notification_create, notification_create
-from achievement.api import unlock_signin_achievements
-
-
-__all__ = [
- 'add_signin_point',
- 'get_pools_and_items',
- 'buy_exchange_item',
- 'buy_lottery_pool',
- 'buy_random_pool',
- 'run_lottery',
- 'get_income_expenditure',
-]
-
-
-DAY2POINT = CONFIG.yqpoint.signin_points
-MAX_CHECK_DAYS = len(DAY2POINT)
-
-
-def get_signin_infos(user: User, detailed_days: int = MAX_CHECK_DAYS,
- check_days: int = None, today: date = None,
- signin_today: bool = True):
- '''
- 获取一定日期内每天的签到信息
-
- :param user: 要查询的用户
- :type user: User
- :param detailed_days: 显示详细签到信息的天数, defaults to None
- :type detailed_days: int, optional
- :param check_days: 查询天数(包括今天), defaults to None
- :type check_days: int, optional
- :param today: 查询的当天, defaults to None
- :type today: date, optional
- :param signin_today: 计算连续签到天数时认为今天已签到, defaults to True
- :type signin_today: bool, optional
- :return: 已连续签到天数,和今天起共detailed_days天的签到信息
- :rtype: tuple[int, list[bool] | None]
- '''
- if today is None:
- today = datetime.now().date()
- day_check_kws = {}
- if check_days is not None:
- day_check_kws.update(time__date__gt=today - timedelta(days=check_days))
- signin_days = set(YQPointRecord.objects.filter(
- user=user,
- source_type=YQPointRecord.SourceType.CHECK_IN,
- **day_check_kws,
- ).order_by('time').values_list('time__date', flat=True).distinct())
- # 获取连续签到天数
- last_day = today
- if signin_today:
- last_day -= timedelta(days=1)
- while last_day in signin_days:
- last_day -= timedelta(days=1)
- continuous_days = (today - last_day).days - 1
- if signin_today:
- continuous_days += 1
- if detailed_days is not None:
- # 从今天开始,第前n天是否签到(今天不计入本次签到)
- # 可用来提供提示信息
- detailed_infos = [
- (today - timedelta(days=day)) in signin_days
- for day in range(detailed_days)
- ]
- else:
- detailed_infos = None
- return continuous_days, detailed_infos
-
-
-def distribution2point(distribution: list, day_type: int) -> int:
- '''根据获取积分分布和当日类别,获取应获得的实际元气值'''
- result = distribution[day_type]
- if isinstance(result, (tuple, list)) and len(result) == 2:
- result = random.randint(*result)
- return result
-
-
-def add_signin_point(user: User):
- '''
- 用户获得今日签到的积分,并返回用户提示信息
-
- :param user: 签到的用户
- :type user: User
- :return: 本次签到获得的积分,以及应看到的提示(若为空则显示默认提示)
- :rtype: tuple[int, str]
- '''
- # 获取已连续签到的日期和近几天签到信息
- continuous_days, signed_in = get_signin_infos(
- user, MAX_CHECK_DAYS, signin_today=True)
- day_type = (continuous_days - 1) % MAX_CHECK_DAYS
- # 连续签到的基础元气值,可以从文件中读取,此类写法便于分析
- add_point = distribution2point(DAY2POINT, day_type)
- User.objects.modify_YQPoint(user, add_point, "每日登录",
- YQPointRecord.SourceType.CHECK_IN)
- # 元气值活动等获得的额外元气值
- bonus_point = 0
- if bonus_point:
- User.objects.modify_YQPoint(user, bonus_point, "登录额外奖励",
- YQPointRecord.SourceType.CHECK_IN)
- # 顺便进行解锁成就检验
- unlock_signin_achievements(user, continuous_days)
- # 用户应看到的信息
- user_display = [
- f'今日首次签到,获得{add_point}元气值!',
- f'连续签到{continuous_days}天,获得{add_point}元气值!',
- f'连续签到{continuous_days}天,获得{add_point}元气值,连续签到{7}天有惊喜!',
- f'连续签到{continuous_days}天,获得{add_point}元气值!',
- f'连续签到{continuous_days}天,再签到{2}天即可获得大量元气值!',
- f'连续签到{continuous_days}天,获得{add_point}元气值,明日可获得大量元气值!',
- f'第7日签到,获得{add_point}元气值!',
- ][day_type]
- # 获取的额外元气值可能需要提示
- if bonus_point:
- pass
- total_point = add_point + bonus_point
- return total_point, user_display
-
-
-def get_pools_and_items(pool_type: Pool.Type, user: User, frontend_dict: Dict[str, any]):
- """
- 获取某一种类的所有当前开放的pool的前端所需信息。如果用户未参加奖池关联的活动,这个奖池的信息不会被返回。
-
- :param pool_type: pool种类
- :type pool_type: Pool.Type
- :param user: 当前用户
- :type user: User
- :param frontend_dict: 前端字典
- :type frontend_dict: Dict[str, any]
- """
- assert hasattr(user, 'naturalperson'), "非个人用户发起了奖池兑换请求"
- pools = Pool.objects.filter(
- Q(type=pool_type) & Q(start__lte=datetime.now())
- & (Q(end__isnull=True) | Q(end__gte=datetime.now() - timedelta(days=1)))
- & (Q(activity__isnull=True) | Exists(
- Participation.objects.filter(
- activity = OuterRef('activity'),
- person = user.naturalperson,
- status = Participation.AttendStatus.ATTENDED,
- )
- ))
- )
-
- pools_info = []
- # 此列表中含有若干dict,每个dict对应一个待展示的pool,例如:
- # {
- # "title": "xxx", "type": "兑换/抽奖/盲盒",
- # "entry_time": 1, # 对于盲盒/抽奖奖池,一个用户最多能买几次
- # "ticket_price": 1, # 盲盒/抽奖奖池价格
- # "start": "2022-9-4", "end": "2022-9-5", # end可能为空
- # "redeem_start": "2022-9-10", "redeem_end": "2022-9-20", # 指线下获取奖品实物的时间,均可能为空
- #
- # "status": 0/1, # 0表示进行中的奖池,1表示结束一天内的抽奖奖池
- # "items": [], # 含有若干dict,每个dict代表该奖池中的一个poolitem
- # # key包括"id", "origin_num", "consumed_num", "exchange_price",
- # # "exchange_limit", "is_big_prize", "is_empty",
- # # "prize__name", "prize__more_info", "prize__stock",
- # # "prize__reference_price", "prize__image", "prize__id",
- # # 以及origin_num-consumed_num得到的remain_num
- # # 如果是兑换类奖池,还有my_exchange_time,即当前用户兑换过该item多少次
- # "my_entry_time": 0, # 当前用户进过抽奖/盲盒奖池多少次
- # "records_num": 0, # 抽奖/盲盒奖池总共被买了多少次
- # "capacity": 0, # 盲盒奖池最多能被买多少次(即包括谢谢参与在内的所有poolitem的数量和)
- # "results": { # 已结束的抽奖奖池有这一项,表示抽奖结果,
- # # 其中包含"big_prize_results"和"normal_prize_results"两个列表
- # # 每个列表中又是若干词典,每个词典表示一种奖品的获奖情况(这些词典按奖品参考价格的降序排列),
- # # 其key包括prize_name、prize_image和winners,其中winners是NaturalPerson.name的list,即这种奖品的获奖者列表
- # "big_prize_results": [
- # {"prize_name": "大奖1", "prize_image": "imageurl", "winners": ["张三", "李四"]},
- # {"prize_name": "大奖2", "prize_image": "imageurl", "winners": ["Alice"]},
- # ],
- # "normal_prize_results": [
- # {"prize_name": "奖品1", "prize_image": "imageurl", "winners": ["王五"]},
- # {"prize_name": "奖品2", "prize_image": "imageurl", "winners": ["Alice", "Bob"]},
- # ]
- # }
- # }
-
- for pool in pools:
- this_pool_info = model_to_dict(pool)
- if pool.start <= datetime.now() and (pool.end is None or pool.end >= datetime.now()):
- this_pool_info["status"] = 0
- else:
- this_pool_info["status"] = 1
-
- this_pool_info["capacity"] = pool.get_capacity()
- this_pool_items = list(pool.items.filter(prize__isnull=False).values(
- "id", "origin_num", "consumed_num", "exchange_price",
- "exchange_limit", "is_big_prize",
- "prize__name", "prize__more_info", "prize__stock",
- "prize__reference_price", "prize__image", "prize__id", "exchange_attributes",
- ))
- for item in this_pool_items:
- item["remain_num"] = item["origin_num"] - item["consumed_num"]
- this_pool_info["items"] = sorted(
- this_pool_items, key=lambda x: -x["remain_num"]) # 按剩余数量降序排序,已卖完的在最后
- if pool_type != Pool.Type.EXCHANGE:
- this_pool_info["my_entry_time"] = PoolRecord.objects.filter(
- user=user, pool=pool).count()
- this_pool_info["records_num"] = PoolRecord.objects.filter(
- pool=pool).count()
- if pool_type == Pool.Type.RANDOM:
- for item in this_pool_items:
- # 此处显示的是抽奖概率,目前使用原始的占比
- percent = (100 * item["origin_num"] /
- this_pool_info["capacity"])
- if percent == int(percent):
- percent = int(percent)
- elif round(percent, 1) != 0:
- # 保留最低精度
- percent = round(percent, 1)
- item["probability"] = percent
- # LOTTERY类的pool不需要capacity
- else:
- for item in this_pool_items:
- item["my_exchange_time"] = PoolRecord.objects.filter(
- user=user, pool=pool, prize=item["prize__id"]).count()
- # EXCHANGE类的pool不需要capcity和records_num和my_entry_time
-
- if this_pool_info["status"] == 1: # 如果是刚结束的抽奖,需要填充results
- big_prize_items = PoolItem.objects.filter(
- pool=pool, is_big_prize=True).order_by("-prize__reference_price")
- normal_prize_items = PoolItem.objects.filter(
- pool=pool, is_big_prize=False).order_by("-prize__reference_price")
- big_prizes_and_winners = []
- normal_prizes_and_winners = []
-
- for big_prize_item in big_prize_items:
- big_prizes_and_winners.append(
- {"prize_name": big_prize_item.prize.name, "prize_image": big_prize_item.prize.image})
- winner_names = list(PoolRecord.objects.filter(
- pool=pool, prize=big_prize_item.prize).values_list(
- "user__name", flat=True)) # TODO: 需要distinct()吗?
- big_prizes_and_winners[-1]["winners"] = winner_names
- for normal_prize_item in normal_prize_items:
- if normal_prize_item.is_empty:
- continue
- normal_prizes_and_winners.append(
- {"prize_name": normal_prize_item.prize.name, "prize_image": normal_prize_item.prize.image})
- winner_names = list(PoolRecord.objects.filter(
- pool=pool, prize=normal_prize_item.prize).values_list(
- "user__name", flat=True)) # TODO: 需要distinct()吗?
- normal_prizes_and_winners[-1]["winners"] = winner_names
- this_pool_info["results"] = {}
- this_pool_info["results"]["big_prize_results"] = big_prizes_and_winners
- this_pool_info["results"]["normal_prize_results"] = normal_prizes_and_winners
-
- pools_info.append(this_pool_info)
-
- frontend_dict["pools_info"] = pools_info
-
-
-def check_user_pool(user: User, pool: Pool) -> None | str:
- """
- 检查用户 user 是否已经毕业,以及是否参加了 pool 所关联的活动;当前是否在奖池的运行时间内。
-
- :param user: 当前用户
- :type user: User
- :param pool: 待购买的物品所属奖池
- :type pool: Pool
- :return: 如果检查通过,返回None。否则,返回一个可以作为 wrong() 函数参数的字符串。
- :rtype: None | str
- """
- # 检查奖池运行时间
- if pool.start > datetime.now():
- return '当前奖池时间未开始!'
- if pool.end is not None and pool.end < datetime.now():
- return '当前奖池时间已结束!'
-
- # 检查用户是否已经毕业
- if not user.active:
- return '您已毕业!'
-
- # 检查用户是否参加了相关的活动
- if pool.activity is not None:
- assert hasattr(user, 'naturalperson'), "非个人用户发起了奖池兑换请求"
- participates = Participation.objects.filter(
- activity = pool.activity,
- person = user.naturalperson,
- status = Participation.AttendStatus.ATTENDED,
- )
- if not participates.exists():
- return '该奖池为"' + str(pool.activity) + '"活动限定奖池,请先参加再来购买!'
-
- return None
-
-
-def buy_exchange_item(
- user: User, poolitem_id: str,
- attributes: dict[str, str] = {}) -> MESSAGECONTEXT:
- """
- 购买兑换奖池的某个奖品
-
- :param user: 当前用户
- :type user: User
- :param poolitem_id: 待购买的奖池奖品id,因为是前端传过来的所以是str
- :type poolitem_id: str
- :return: 表明购买结果的warn_code和warn_message
- :rtype: MESSAGECONTEXT
- """
- # 检查奖品是否可以购买
- try:
- poolitem_id = int(poolitem_id)
- poolitem = PoolItem.objects.get(
- id=poolitem_id, pool__type=Pool.Type.EXCHANGE)
- except:
- return wrong('奖品不存在!')
- if poolitem.origin_num - poolitem.consumed_num <= 0:
- return wrong('奖品已售罄!')
- other_errors = check_user_pool(user, poolitem.pool)
- if other_errors:
- return wrong(other_errors)
-
- my_exchanged_time = PoolRecord.objects.filter(
- user=user, pool=poolitem.pool, prize=poolitem.prize).count()
- if my_exchanged_time >= poolitem.exchange_limit:
- return wrong('您兑换该奖品的次数已达上限!')
-
- for exchange_attribute in poolitem.exchange_attributes:
- if exchange_attribute['name'] not in attributes:
- return wrong('请填写完整的兑换信息!')
- if attributes[exchange_attribute['name']] not in exchange_attribute['range']:
- return wrong('兑换信息填写错误!')
-
- try:
- with transaction.atomic():
- poolitem = PoolItem.objects.select_for_update().get(
- id=poolitem_id, pool__type=Pool.Type.EXCHANGE)
- assert poolitem.pool.start <= datetime.now(), "兑换时间未开始!"
- assert poolitem.pool.end is None or poolitem.pool.end >= datetime.now(), "兑换时间已结束!"
- assert poolitem.origin_num - poolitem.consumed_num > 0, "奖品已售罄!"
- my_exchanged_time = PoolRecord.objects.filter(
- user=user, pool=poolitem.pool, prize=poolitem.prize).count()
- assert my_exchanged_time < poolitem.exchange_limit, '您兑换该奖品的次数已达上限!'
- assert user.YQpoint >= poolitem.exchange_price, '您的元气值不足,兑换失败!'
-
- # 更新奖品状态
- poolitem.consumed_num += 1
- poolitem.save()
-
- # 创建兑换记录
- PoolRecord.objects.create(
- user=user,
- pool=poolitem.pool,
- prize=poolitem.prize,
- attributes=attributes,
- status=PoolRecord.Status.UN_REDEEM,
- )
-
- # 扣除元气值
- User.objects.modify_YQPoint(
- user,
- -poolitem.exchange_price,
- source=f'兑换奖池:{poolitem.pool.title}-{poolitem.prize.name}',
- source_type=YQPointRecord.SourceType.CONSUMPTION
- )
- except AssertionError as e:
- return wrong(str(e))
-
- return succeed('兑换成功!')
-
-
-def buy_lottery_pool(user: User, pool_id: str) -> MESSAGECONTEXT:
- """
- 购买抽奖奖池
-
- :param user: 当前用户
- :type user: User
- :param pool_id: 待购买的奖池id,因为是前端传过来的所以是str
- :type pool_id: str
- :return: 表明购买结果的warn_code和warn_message
- :rtype: MESSAGECONTEXT
- """
- # 检查抽奖奖池状态
- try:
- pool_id = int(pool_id)
- pool = Pool.objects.get(id=pool_id, type=Pool.Type.LOTTERY)
- except:
- return wrong('抽奖不存在!')
- my_entry_time = PoolRecord.objects.filter(pool=pool, user=user).count()
- if my_entry_time >= pool.entry_time:
- return wrong('您在本奖池中抽奖的次数已达上限!')
- other_errors = check_user_pool(user, pool)
- if other_errors:
- return wrong(other_errors)
-
- try:
- with transaction.atomic():
- pool = Pool.objects.select_for_update().get(id=pool_id, type=Pool.Type.LOTTERY)
- assert pool.start <= datetime.now(), '抽奖未开始!'
- assert pool.end is None or pool.end >= datetime.now(), '抽奖已结束!'
- my_entry_time = PoolRecord.objects.filter(
- pool=pool, user=user).count()
- assert my_entry_time < pool.entry_time, '您在本奖池中抽奖的次数已达上限!'
- assert user.YQpoint >= pool.ticket_price, '您的元气值不足,兑换失败!'
-
- # 创建抽奖记录
- PoolRecord.objects.create(
- user=user,
- pool=pool,
- status=PoolRecord.Status.LOTTERING,
- )
-
- # 扣除元气值
- User.objects.modify_YQPoint(
- user,
- -pool.ticket_price,
- source=f'抽奖奖池:{pool.title}',
- source_type=YQPointRecord.SourceType.CONSUMPTION
- )
- except AssertionError as e:
- return wrong(str(e))
-
- return succeed('成功进行一次抽奖!您可以在抽奖时间结束后查看抽奖结果~')
-
-
-def select_random_prize(poolitems: QuerySet[PoolItem], select_num: Optional[int] = None) -> List[int]:
- """
- 实现无放回随机抽取select_num个PoolItem(的id),初始时每种PoolItem有origin_num-consumed_num个
-
- :param poolitems: 待抽取的PoolItem构成的QuerySet(每个元素表示一种PoolItem而非一个)
- :type poolitems: QuerySet[PoolItem]
- :param select_num: 抽几个,若为None则抽取所有奖品,也即对poolitems做一次shuffle, defaults to None
- :type select_num: Optional[int], optional
- :return: 抽出的poolitem的id组成的list,长度等于select_num
- :rtype: List[int]
- """
- assert poolitems.count() > 0
-
- num_all_items = 0 # 奖品的总数
- item_dict = {} # int: PoolItem,实现把一个自然数区间映射到一种奖品
- for item in poolitems:
- if item.origin_num - item.consumed_num <= 0:
- continue
- item_dict[num_all_items] = item
- num_all_items += item.origin_num - item.consumed_num
-
- if select_num is None: # 不给出select_num就默认抽取所有奖品,也即对poolitems做一次shuffle
- select_num = num_all_items
- assert select_num <= num_all_items
-
- selected_idx = random.sample(
- range(num_all_items), select_num) # 选出select_num个序号
- selected_items_id = []
- for idx in selected_idx:
- for key in sorted(item_dict.keys(), reverse=True):
- if idx >= key: # 寻找idx落入的区间
- selected_items_id.append(
- item_dict[key].id) # 把idx映射为PoolItem.id
- break
- return selected_items_id
-
-
-def buy_random_pool(user: User, pool_id: str) -> Tuple[MESSAGECONTEXT, int, int]:
- """
- 购买盲盒
-
- :param user: 当前用户
- :type user: User
- :param pool_id: 待购买的奖池id,因为是前端传过来的所以是str
- :type pool_id: str
- :return: 表明购买结果的warn_code和warn_message;买到的prize的id(如果购买失败就是-1);
- 表明盲盒结果的一个int:2表示无反应、1表示开出空盒、0表示开出奖品
- :rtype: Tuple[MESSAGECONTEXT, int, int]
- """
- # 检查盲盒奖池状态
- try:
- pool_id = int(pool_id)
- pool = Pool.objects.get(id=pool_id, type=Pool.Type.RANDOM)
- except:
- return wrong('盲盒不存在!'), -1, 2
- my_entry_time = PoolRecord.objects.filter(pool=pool, user=user).count()
- if my_entry_time >= pool.entry_time:
- return wrong('您兑换这款盲盒的次数已达上限!'), -1, 2
- total_entry_time = PoolRecord.objects.filter(pool=pool).count()
- capacity = pool.get_capacity()
- if capacity <= total_entry_time:
- return wrong('盲盒已售罄!'), -1, 2
- other_errors = check_user_pool(user, pool)
- if other_errors:
- return wrong(other_errors), -1, 2
-
- try:
- with transaction.atomic():
- pool = Pool.objects.select_for_update().get(id=pool_id, type=Pool.Type.RANDOM)
- assert pool.start <= datetime.now(), '盲盒兑换时间未开始!'
- assert pool.end is None or pool.end >= datetime.now(), '盲盒兑换时间已结束!'
- my_entry_time = PoolRecord.objects.filter(
- pool=pool, user=user).count()
- assert my_entry_time < pool.entry_time, '您兑换这款盲盒的次数已达上限!'
- assert user.YQpoint >= pool.ticket_price, '您的元气值不足,兑换失败!'
- total_entry_time = PoolRecord.objects.filter(pool=pool).count()
- capacity = pool.get_capacity()
- assert capacity > total_entry_time, '盲盒已售罄!'
-
- # 开盒,修改poolitem记录,创建poolrecord记录
- items = pool.items.select_for_update().all()
- real_item_id = select_random_prize(items, 1)[0]
- modify_item: PoolItem = PoolItem.objects.select_for_update().get(id=real_item_id)
- modify_item.consumed_num += 1
- modify_item.save()
-
- if modify_item.is_empty: # 如果是空盲盒,没法兑奖,record的状态记为NOT_LUCKY
- item_status = PoolRecord.Status.NOT_LUCKY
- else:
- item_status = PoolRecord.Status.UN_REDEEM
- PoolRecord.objects.create(
- user=user,
- pool=pool,
- status=item_status,
- prize=modify_item.prize,
- )
-
- # 扣除元气值
- User.objects.modify_YQPoint(
- user,
- -pool.ticket_price,
- source=f'盲盒奖池:{pool.title}',
- source_type=YQPointRecord.SourceType.CONSUMPTION
- )
- # 如果抽到了空盒子,按照设定值对用户给予元气值补偿并返回相应的提示
- if modify_item.is_empty:
- compensate_YQPoint = random.randint(
- pool.empty_YQPoint_compensation_lowerbound, pool.empty_YQPoint_compensation_upperbound)
- if compensate_YQPoint == 0:
- return succeed(f'兑换盲盒成功!您抽到了空盒子,但是很遗憾这次没有元气值补偿QAQ'), -1, 1
- User.objects.modify_YQPoint(
- user,
- compensate_YQPoint,
- source=f'盲盒奖池:{pool.title}空盒子补偿',
- source_type=YQPointRecord.SourceType.COMPENSATION
- )
- return succeed(f'兑换盲盒成功!您抽到了空盒子,获得{compensate_YQPoint}点元气值补偿!'), -1, 1
- if modify_item.prize is None:
- return succeed('兑换盲盒成功!'), -1, 1
- return succeed('兑换盲盒成功!'), modify_item.prize.id, int(modify_item.is_empty)
- except AssertionError as e:
- return wrong(str(e)), -1, 2
-
-
-@transaction.atomic
-def run_lottery(pool_id: int):
- """
- 抽奖;更新PoolRecord表和PoolItem表;给所有参与者发送通知
-
- :param pool_id: 待抽取的抽奖奖池id
- :type pool_id: int
- """
- # 部分参考了course_utils.py的draw_lots函数
- pool = Pool.objects.select_for_update().get(id=pool_id, type=Pool.Type.LOTTERY)
- assert not PoolRecord.objects.filter( # 此时pool关联的所有records都应该是LOTTERING
- pool=pool).exclude(status=PoolRecord.Status.LOTTERING).exists()
- related_records = PoolRecord.objects.filter(
- pool=pool, status=PoolRecord.Status.LOTTERING)
- records_num = related_records.count()
- if records_num == 0:
- return
-
- # 抽奖
- record_ids_and_participant_ids = list(
- related_records.values("id", "user__id"))
- items = pool.items.all()
- user2prize_names = {d["user__id"]: []
- for d in record_ids_and_participant_ids} # 便于发通知
- winner_record_id2item_id = {} # poolrecord.id: poolitem.id,便于更新poolrecord
- loser_record_ids = [] # poolrecord.id,便于更新poolrecord
- num_all_items = 0 # 该奖池中奖品总数
- for item in items:
- num_all_items += item.origin_num - item.consumed_num
- if num_all_items >= records_num: # 抽奖记录数少于或等于奖品数,人人有奖,给每个记录分配一个随机奖品
- shuffled_items = select_random_prize(
- items, records_num) # 随机选出待发放的奖品
- for i in range(records_num): # 遍历所有记录,每个记录都有奖品
- user2prize_names[record_ids_and_participant_ids[i]["user__id"]].append(
- items.get(id=shuffled_items[i]).prize.name
- )
- winner_record_id2item_id[record_ids_and_participant_ids[i]
- ["id"]] = shuffled_items[i]
- else: # 抽奖记录数多于奖品数,给每个奖品分配一个中奖者
- for item in items: # 遍历所有奖品,每个奖品都会送给一个记录
- for i in range(item.origin_num - item.consumed_num):
- winner_record_index = random.randint(
- 0, len(record_ids_and_participant_ids) - 1)
- user2prize_names[record_ids_and_participant_ids[winner_record_index]["user__id"]].append(
- item.prize.name)
- winner_record_id2item_id[record_ids_and_participant_ids[winner_record_index]["id"]] = item.id
- # 因为记录多,奖品少,这里肯定不会pop成空列表
- record_ids_and_participant_ids.pop(winner_record_index)
- # pop剩下的就是没中奖的那些记录
- loser_record_ids = [d["id"]
- for d in record_ids_and_participant_ids]
-
- # 更新数据库
- for winner_record_id, poolitem_id in winner_record_id2item_id.items():
- record = PoolRecord.objects.select_for_update().get(id=winner_record_id)
- item = PoolItem.objects.select_for_update().get(id=poolitem_id)
- record.status = PoolRecord.Status.UN_REDEEM
- record.prize = item.prize
- record.time = datetime.now()
- item.consumed_num += 1
- record.save()
- item.save()
- for loser_record_id in loser_record_ids:
- record = PoolRecord.objects.select_for_update().get(id=loser_record_id)
- record.status = PoolRecord.Status.NOT_LUCKY
- record.time = datetime.now()
- record.save()
-
- # 给中奖的同学发送通知
- sender = Organization.objects.get(
- oname=CONFIG.yqpoint.org_name).get_user()
- for user_id in user2prize_names.keys():
- receiver = User.objects.get(id=user_id)
- typename = Notification.Type.NEEDREAD
- title = Notification.Title.LOTTERY_INFORM
- content = f"恭喜您在奖池【{pool.title}】中抽中奖品"
- for prize_name in user2prize_names[user_id]:
- content += f"【{prize_name}】" # 可能出现重复,即一种奖品中了好几次,不过感觉问题也不太大
- notification_create(
- receiver=receiver,
- sender=sender,
- typename=typename,
- title=title,
- content=content,
- # URL=f'', # TODO: 我的奖品页面?
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT,
- level=WechatMessageLevel.IMPORTANT),
- )
-
- # 给没中奖的同学发送通知
- receivers = PoolRecord.objects.filter(
- id__in=loser_record_ids,
- ).values_list("user", flat=True)
- receivers = User.objects.filter(id__in=receivers)
- content = f"很抱歉通知您,您在奖池【{pool.title}】中没有中奖"
-
- if len(receivers) > 0:
- bulk_notification_create(
- receivers=receivers,
- sender=sender,
- typename=typename,
- title=title,
- content=content,
- # URL=f'', # TODO: 我的奖品页面?
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT,
- level=WechatMessageLevel.IMPORTANT),
- )
-
-
-def get_income_expenditure(
- user: User, start_time: datetime, end_time: datetime
-) -> tuple[int, int]:
- '''获取用户一段时间内收支情况
-
- Args:
- user(Usesr): 要查询的用户
- start_time(datetime): 开始时间
- end_time(datetime): 结束时间
-
- Returns:
- tuple[int, int]: 收入, 支出
- '''
- # 根据user选出YQPointRecord
- records = YQPointRecord.objects.filter(
- user=user, time__gte=start_time, time__lte=end_time)
- # 统计时期内收支情况
- income = 0
- expenditure = 0
- for record in records:
- if record.delta >= 0:
- income += record.delta
- else:
- expenditure += abs(record.delta)
- return income, expenditure
diff --git a/app/YQPoint_views.py b/app/YQPoint_views.py
deleted file mode 100644
index 25de82e1a..000000000
--- a/app/YQPoint_views.py
+++ /dev/null
@@ -1,154 +0,0 @@
-from generic.models import YQPointRecord
-from app.views_dependency import *
-from app.models import (
- Prize,
- Pool,
- PoolRecord,
-)
-from app.YQPoint_utils import (
- get_pools_and_items,
- buy_exchange_item,
- buy_lottery_pool,
- buy_random_pool,
-)
-from app.utils import get_sidebar_and_navbar
-
-__all__ = [
- 'myYQPoint',
- 'myPrize',
- 'showPools',
-]
-
-
-class myYQPoint(ProfileTemplateView):
- template_name = 'myYQPoint.html'
- page_name = '我的元气值'
- http_method_names = ['get']
-
- def prepare_get(self):
- html_display = {}
- my_messages.transfer_message_context(self.request.GET, html_display)
- html_display.update(YQPoint=self.request.user.YQpoint)
- self.extra_context.update(html_display=html_display)
- return self.get
-
- def get(self):
- YQPoint = self.request.user.YQpoint
- received_set = YQPointRecord.objects.filter(
- user=self.request.user,
- ).exclude(source_type=YQPointRecord.SourceType.CONSUMPTION).order_by("-time")
-
- send_set = YQPointRecord.objects.filter(
- user=self.request.user,
- source_type=YQPointRecord.SourceType.CONSUMPTION,
- ).order_by("-time")
- return self.render(YQPoint=YQPoint, received_set=received_set, send_set=send_set)
-
-
-
-class myPrize(ProfileTemplateView):
- template_name = 'myPrize.html'
- page_name = '我的奖品'
- http_method_names = ['get']
-
- def prepare_get(self):
- html_display = {}
- my_messages.transfer_message_context(self.request.GET, html_display)
- self.extra_context.update(html_display=html_display)
- return self.get
-
- def get(self):
- lottery_set = PoolRecord.objects.filter(
- user=self.request.user,
- pool__type=Pool.Type.LOTTERY,
- status__in=[
- PoolRecord.Status.LOTTERING,
- PoolRecord.Status.NOT_LUCKY,
- PoolRecord.Status.UN_REDEEM],
- ).order_by("-time")
-
- exchange_set = PoolRecord.objects.filter(
- user=self.request.user,
- status__in=[
- PoolRecord.Status.UN_REDEEM,
- PoolRecord.Status.REDEEMED,
- PoolRecord.Status.OVERDUE],
- ).order_by("-status", "-time")
- return self.render(lottery_set=lottery_set, exchange_set=exchange_set)
-
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def showPools(request: UserRequest) -> HttpResponse:
- """
- 展示各种奖池的页面,可以通过POST请求发起兑换/抽奖/买盲盒
-
- :param request
- :type request: HttpRequest
- :return
- :rtype: HttpResponse
- """
- if request.user.is_org():
- return redirect(message_url(wrong("只有个人账号可以进入此页面!")))
-
- frontend_dict = {"exchange_pools_info": {},
- "lottery_pools_info": {}, "random_pools_info": {}}
- frontend_dict["current_pool"] = -1 # 当前所在的tab
- # 2表示无效果,1表示开出空盒(谢谢参与),0表示开出奖品
- frontend_dict["random_pool_effect_code"] = 2
-
- # 用户是否处于活跃状态。已经毕业的用户只能查看奖池,不能参与兑换
- frontend_dict['active_user'] = request.user.active
-
- # POST表明发起兑换/抽奖/买盲盒
- if request.method == "POST" and request.POST:
- if request.POST.get('submit_exchange', '') != '':
- context = buy_exchange_item(
- request.user,
- poolitem_id=request.POST['submit_exchange'],
- attributes={k: v for k, v in request.POST.items()
- if k not in ['pool_id', 'submit_exchange']})
- my_messages.transfer_message_context(
- context, frontend_dict, normalize=True)
- frontend_dict["current_pool"] = int(request.POST["pool_id"])
- elif request.POST.get('submit_lottery', '') != '':
- context = buy_lottery_pool(
- request.user, pool_id=request.POST['submit_lottery'])
- my_messages.transfer_message_context(
- context, frontend_dict, normalize=True)
- frontend_dict["current_pool"] = int(request.POST["submit_lottery"])
- elif request.POST.get('submit_random', '') != '':
- context, prize_id, frontend_dict["random_pool_effect_code"] = buy_random_pool(
- request.user, pool_id=request.POST['submit_random'])
- my_messages.transfer_message_context(
- context, frontend_dict, normalize=True)
- if prize_id != -1: # 表明成功购买了一个盲盒
- prize = Prize.objects.get(id=prize_id)
- # 供前端展示盲盒开出的结果
- frontend_dict["random_pool_effect_name"] = prize.name
- frontend_dict["random_pool_effect_image"] = prize.image
- frontend_dict["current_pool"] = int(request.POST["submit_random"])
-
- get_pools_and_items(Pool.Type.EXCHANGE, request.user,
- frontend_dict["exchange_pools_info"])
- get_pools_and_items(Pool.Type.LOTTERY, request.user,
- frontend_dict["lottery_pools_info"]) # 这里包含结束一天以内的
- get_pools_and_items(Pool.Type.RANDOM, request.user,
- frontend_dict["random_pools_info"])
-
- frontend_dict["my_YQpoint"] = request.user.YQpoint # 元气值余额
-
- if frontend_dict["current_pool"] == -1:
- if len(frontend_dict["exchange_pools_info"]["pools_info"]):
- frontend_dict["current_pool"] = frontend_dict["exchange_pools_info"]["pools_info"][0]["id"]
- elif len(frontend_dict["lottery_pools_info"]["pools_info"]):
- frontend_dict["current_pool"] = frontend_dict["lottery_pools_info"]["pools_info"][0]["id"]
- elif len(frontend_dict["random_pools_info"]["pools_info"]):
- frontend_dict["current_pool"] = frontend_dict["random_pools_info"]["pools_info"][0]["id"]
-
-
- frontend_dict["bar_display"] = get_sidebar_and_navbar(
- request.user, "元气值商城")
- return render(request, "showPools.html", frontend_dict)
diff --git a/app/academic_utils.py b/app/academic_utils.py
deleted file mode 100644
index 9a6cbe1c1..000000000
--- a/app/academic_utils.py
+++ /dev/null
@@ -1,580 +0,0 @@
-from collections import defaultdict
-from typing import cast
-
-from django.http import HttpRequest
-
-from app.utils_dependency import *
-from app.models import (
- NaturalPerson,
- AcademicTag,
- AcademicEntry,
- AcademicTagEntry,
- AcademicTextEntry,
- AcademicQA,
- User,
- Chat,
-)
-from app.utils import get_person_or_org
-from app.comment_utils import showComment
-
-__all__ = [
- 'get_search_results',
- 'chats2Display',
- 'comments2display',
- 'get_js_tag_list',
- 'get_text_list',
- 'get_tag_status',
- 'get_text_status',
- 'update_tag_entry',
- 'update_text_entry',
- 'update_academic_map',
- 'get_wait_audit_student',
- 'audit_academic_map',
- 'have_entries',
- 'get_tags_for_search',
-]
-
-
-def get_search_results(query: str) -> dict[str, dict[str, list[str]]]:
- # TODO: 更新文档
- """
- 根据提供的关键词获取搜索结果。
- """
-
- # 搜索所有含有关键词的公开的学术地图项目,忽略大小写
- academic_tags = AcademicTagEntry.objects.filter(
- tag__tag_content__icontains=query,
- status=AcademicEntry.EntryStatus.PUBLIC,
- ).values_list(
- SQ.f(AcademicEntry.person, NaturalPerson.person_id, User.username),
- SQ.f(AcademicTagEntry.tag, AcademicTag.atype),
- SQ.f(AcademicTagEntry.tag, AcademicTag.tag_content),
- )
- academic_texts = AcademicTextEntry.objects.filter(
- content__icontains=query,
- status=AcademicEntry.EntryStatus.PUBLIC,
- ).values_list(
- SQ.f(AcademicEntry.person, NaturalPerson.person_id, User.username),
- SQ.f(AcademicTextEntry.atype),
- SQ.f(AcademicTextEntry.content),
- )
-
- # 根据tag/text对应的人,整合学术地图项目
- # 直接使用defaultdict会导致前端items不可用,因为Django先尝试以键访问,并得到空列表
- academic_map_dict = defaultdict(lambda: defaultdict(list[str]))
- for sid, ty, content in academic_tags:
- academic_map_dict[sid][AcademicTag.Type(ty).label].append(content)
-
- for sid, ty, content in academic_texts:
- academic_map_dict[sid][AcademicTextEntry.Type(ty).label].append(content)
-
- return {k: dict(v) for k, v in academic_map_dict.items()} # 转化为字典
-
-
-def chats2Display(user: User, sent: bool) -> dict[str, list[dict]]:
- """
- 把我收到/我发出的所有chat转化为供前端展示的两个列表,分别是进行中chat的信息、和其他chat的信息
-
- :param chats: 我收到/我发出的所有chat
- :type chats: QuerySet[Chat]
- :param sent: 若为True则表示我发出的,否则表示我收到的
- :type sent: bool
- :return: 一个词典,key为progressing和not_progressing,value分别是进行中chat的列表、和其他chat的列表
- :rtype: dict[str, list[dict]]
- """
- not_progressing_chats = []
- progressing_chats = []
-
- if sent:
- chats = Chat.objects.filter(questioner=user).order_by(
- "-modify_time", "-time")
- else:
- chats = Chat.objects.filter(respondent=user).order_by(
- "-modify_time", "-time")
-
- for chat in chats:
- chat_dict = {}
- chat_dict['id'] = chat.id
-
- chat_dict['questioner_anonymous'] = chat.questioner_anonymous
- # 目前根据回答者是否匿名,来区分定向和非定向提问
- chat_dict['respondent_anonymous'] = chat.respondent_anonymous
-
- is_questioner = user == chat.questioner
- chat_dict['is_questioner'] = is_questioner
-
- chat_dict['questioner_name'] = get_person_or_org(
- chat.questioner).get_display_name()
- chat_dict['respondent_name'] = get_person_or_org(
- chat.respondent).get_display_name()
-
- if is_questioner:
- chat_dict['academic_url'] = get_person_or_org(
- chat.respondent).get_absolute_url()
- else:
- chat_dict['academic_url'] = get_person_or_org(
- chat.questioner).get_absolute_url()
-
- if len(chat.title) >= 12:
- chat_dict['title'] = chat.title[:12] + "……"
- else:
- chat_dict['title'] = chat.title or "无主题"
-
- chat_dict['status'] = chat.get_status_display()
- chat_dict['start_time'] = chat.time
- chat_dict['last_modification_time'] = chat.modify_time
- chat_dict['chat_url'] = f"/viewQA/{chat.id}" # 问答详情的url,超链接放在title上
- chat_dict['message_count'] = chat.comments.count()
-
- if chat.status == Chat.Status.PROGRESSING:
- progressing_chats.append(chat_dict)
- else:
- not_progressing_chats.append(chat_dict)
-
- return {
- "progressing": progressing_chats,
- "not_progressing": not_progressing_chats
- }
-
-
-def comments2display(chat: Chat, user: User) -> dict:
- """
- 获取一个chat中的所有comment并转化为前端展示所需的形式(复用了comment_utils.py/showComment)
- """
- questioner_anonymous = chat.questioner_anonymous
- respondent_anonymous = chat.respondent_anonymous
- is_questioner = user == chat.questioner
-
- anonymous_users = []
- if questioner_anonymous:
- anonymous_users.append(chat.questioner)
- if respondent_anonymous:
- anonymous_users.append(chat.respondent)
-
- context = dict()
- messages = showComment(chat, anonymous_users)
- # TODO: 统一用AcademicQA模型后,不建议再用chat.id,而应该使用AcademicQA的id
- context.update(
- title=chat.title or "无主题",
- chat_id=chat.id,
- messages=messages,
- )
- if not messages:
- context.update(not_found_messages="当前问答没有信息.")
-
- # 若commentable为True,则前端会给出评论区和“关闭当前问答”的按钮
- context.update(
- status=chat.get_status_display(),
- commentable=chat.status == Chat.Status.PROGRESSING,
- anonymous_chat=chat.questioner_anonymous,
- accept_anonymous=chat.respondent.accept_anonymous_chat,
- answered=chat.comments.filter(commentator=chat.respondent).exists()
- )
-
- context.update(
- is_questioner=is_questioner,
- is_anonymous=questioner_anonymous if is_questioner else respondent_anonymous,
- questioner_anonymous=questioner_anonymous,
- respondent_anonymous=respondent_anonymous,
- )
-
- my_name = get_person_or_org(user).get_display_name()
- questioner_info = get_person_or_org(chat.questioner)
- respondent_info = get_person_or_org(chat.respondent)
- academic_url = (respondent_info.get_absolute_url()
- if is_questioner else questioner_info.get_absolute_url())
-
- context.update(
- my_name=my_name,
- questioner_name=questioner_info.get_display_name(),
- respondent_name=respondent_info.get_display_name(),
- academic_url=academic_url
- )
-
- # 在对方匿名时,提供一些简单的信息
- if is_questioner:
- qa: AcademicQA = AcademicQA.objects.get(chat_id=chat.id)
- context['rating'] = qa.rating
- if not qa.directed:
- context['respondent_tags'] = list(qa.keywords)
- else:
- context['respondent_tags'] = []
- return context
-
- try:
- major = AcademicTagEntry.objects.get(
- person=chat.questioner, tag__atype=AcademicTag.Type.MAJOR)
- major_display = major.content
- except:
- major_display = ""
- # TODO: 暂时没用上,但是可能有用,先留着
- context['questioner_tags'] = [
- chat.questioner.username[:2] + "级", major_display
- ]
- return context
-
-
-def get_js_tag_list(author: NaturalPerson, type: AcademicTag.Type,
- selected: bool,
- status_in: list[AcademicEntry.EntryStatus] | None = None) -> list[dict]:
- """
- 用于前端显示支持搜索的专业/项目列表,返回形如[{id, content}]的列表。
-
- :param author: 作者自然人信息
- :type author: NaturalPerson
- :param type: 标记所需的tag类型
- :type type: AcademicTag.Type
- :param selected: 用于标记是否获取本人已有的专业项目,selected代表获取前端默认选中的项目
- :type selected: bool
- :param status_in: 所要检索的状态的列表,默认为None,表示搜索全部
- :type status_in: list[AcademicEntry.EntryStatus]
- :return: 所有专业/项目组成的List[dict],key值如上所述
- :rtype: list[dict]
- """
- if selected:
- all_my_tags = AcademicTagEntry.objects.activated().filter(person=author)
- if status_in is not None:
- all_my_tags = all_my_tags.filter(status__in=status_in)
- tags = all_my_tags.filter(tag__atype=type).values(
- 'tag__id', 'tag__tag_content')
- js_list = [{"id": tag['tag__id'], "text": tag['tag__tag_content']}
- for tag in tags]
- else:
- tags = AcademicTag.objects.filter(atype=type)
- js_list = [{"id": tag.id, "text": tag.tag_content} for tag in tags]
-
- return js_list
-
-
-def get_text_list(author: NaturalPerson, type: AcademicTextEntry.Type,
- status_in: list[AcademicEntry.EntryStatus] | None = None) -> list[str]:
- """
- 获取自己的所有类型为type的TextEntry的内容列表。
-
- :param author: 作者自然人信息
- :type author: NaturalPerson
- :param type: TextEntry的类型
- :type type: AcademicTextEntry.Type
- :param status_in: 所要检索的状态的列表,默认为None,表示搜索全部
- :type status_in: list
- :return: 含有所有类型为type的TextEntry的content的list
- :rtype: list[str]
- """
- all_my_text = AcademicTextEntry.objects.activated().filter(person=author, atype=type)
- if status_in is not None:
- all_my_text = all_my_text.filter(status__in=status_in)
- text_list = [text.content for text in all_my_text]
- return text_list
-
-
-def get_tag_status(person: NaturalPerson, type: AcademicTag.Type) -> str:
- """
- 获取person的类型为type的TagEntry的公开状态。
- 如果person没有类型为type的TagEntry,返回"公开"。
-
- :param person: 需要获取公开状态的人
- :type person: NaturalPerson
- :param type: TagEntry的类型
- :type type: AcademicTag.Type
- :return: 公开状态,返回"公开/私密"
- :rtype: str
- """
- # 首先获取person所有的TagEntry
- all_tag_entries = AcademicTagEntry.objects.activated().filter(
- person=person, tag__atype=type)
-
- if all_tag_entries.exists():
- # 因为所有类型为type的TagEntry的公开状态都一样,所以直接返回第一个entry的公开状态
- entry = all_tag_entries[0]
- return "私密" if entry.status == AcademicEntry.EntryStatus.PRIVATE else "公开"
- else:
- return "公开"
-
-
-def get_text_status(person: NaturalPerson, type: AcademicTextEntry.Type) -> str:
- """
- 获取person的类型为type的TextEntry的公开状态。
- 如果person没有类型为type的TextEntry,返回"公开"。
-
- :param person: 需要获取公开状态的人
- :type person: NaturalPerson
- :param type: TextEntry的类型
- :type type: AcademicTextEntry.Type
- :return: 公开状态,返回"公开/私密"
- :rtype: str
- """
- # 首先获取person所有的类型为type的TextEntry
- all_text_entries = AcademicTextEntry.objects.activated().filter(person=person,
- atype=type)
-
- if all_text_entries.exists():
- # 因为所有类型为type的TextEntry的公开状态都一样,所以直接返回第一个entry的公开状态
- entry = all_text_entries[0]
- return "私密" if entry.status == AcademicEntry.EntryStatus.PRIVATE else "公开"
- else:
- return "公开"
-
-
-def update_tag_entry(person: NaturalPerson,
- tag_ids: list[str],
- status: bool,
- type: AcademicTag.Type) -> None:
- """
- 更新TagEntry的工具函数。
-
- :param person: 需要更新学术地图的人
- :type person: NaturalPerson
- :param tag_ids: 含有一系列tag_id(未经类型转换)的list
- :type tag_ids: list[str]
- :param status: tag_ids对应的所有tags的公开状态
- :type status: bool
- :param type: tag_ids对应的所有tags的类型
- :type type: AcademicTag.Type
- """
- # 首先获取person所有的TagEntry
- all_tag_entries = AcademicTagEntry.objects.activated().filter(
- person=person, tag__atype=type)
- # 标签类型无需审核
- updated_status = (AcademicEntry.EntryStatus.PUBLIC
- if status == "公开" else
- AcademicEntry.EntryStatus.PRIVATE)
-
- for entry in all_tag_entries:
- if not str(entry.tag.id) in tag_ids:
- # 如果用户原有的TagEntry的id在tag_ids中未出现,则将其状态设置为“已弃用”
- entry.status = AcademicEntry.EntryStatus.OUTDATE
- entry.save()
- else:
- # 如果出现,直接更新其状态,并将这个id从tag_ids移除
- entry.status = updated_status
- entry.save()
- tag_ids.remove(str(entry.tag.id))
-
- # 接下来遍历的tag_id都是要新建的tag
- for tag_id in tag_ids:
- AcademicTagEntry.objects.create(
- person=person, tag=AcademicTag.objects.get(id=int(tag_id)),
- status=updated_status
- )
-
-
-def update_text_entry(person: NaturalPerson,
- contents: list[str],
- status: bool,
- type: AcademicTextEntry.Type) -> None:
- """
- 更新TextEntry的工具函数。
-
- :param person: 需要更新学术地图的人
- :type person: NaturalPerson
- :param tag_ids: 含有一系列TextEntry的内容的list
- :type tag_ids: list[str]
- :param status: 该用户所有类型为type的TextEntry的公开状态
- :type status: bool
- :param type: contents对应的TextEntry的类型
- :type type: AcademicTextEntry.Type
- """
- # 首先获取person所有的类型为type的TextEntry
- all_text_entries = AcademicTextEntry.objects.activated().filter(person=person,
- atype=type)
- updated_status = (AcademicEntry.EntryStatus.WAIT_AUDIT
- if status == "公开" else
- AcademicEntry.EntryStatus.PRIVATE)
- previous_num = len(all_text_entries)
-
- # 即将修改/创建的entry总数一定不小于原有的,因此先遍历原有的entry,判断是否更改/删除
- for i, entry in enumerate(all_text_entries):
- if entry.content != contents[i]:
- # 内容发生修改,需要先将原有的content设置为“已弃用”
- entry.status = AcademicEntry.EntryStatus.OUTDATE
- entry.save()
- if contents[i] != "": # 只有新的entry的内容不为空才创建
- AcademicTextEntry.objects.create(
- person=person, atype=type, content=contents[i],
- status=updated_status,
- )
- elif entry.status != updated_status:
- # 内容未修改但status修改,只更新entry的状态,不删除
- entry.status = updated_status
- entry.save()
-
- # 接下来遍历的entry均为需要新建的
- for content in contents[previous_num:]:
- if content != "":
- AcademicTextEntry.objects.create(
- person=person, atype=type, content=content,
- status=AcademicEntry.EntryStatus.WAIT_AUDIT if status == "公开"
- else AcademicEntry.EntryStatus.PRIVATE
- )
-
-
-def update_academic_map(request: HttpRequest) -> dict:
- """
- 从前端获取填写的学术地图信息,并在数据库中进行更新,返回含有成功/错误信息的dict。
-
- :param request: http请求
- :type request: HttpRequest
- :return: 含成功/错误信息的dict,用于redirect后页面的前端展示
- :rtype: dict
- """
- # 首先从select栏获取所有选中的TagEntry
- majors = request.POST.getlist('majors')
- minors = request.POST.getlist('minors')
- double_degrees = request.POST.getlist('double_degrees')
- projects = request.POST.getlist('projects')
-
- # 然后从其余栏目获取即将更新的TextEntry
- scientific_research_num = int(request.POST['scientific_research_num'])
- challenge_cup_num = int(request.POST['challenge_cup_num'])
- internship_num = int(request.POST['internship_num'])
- scientific_direction_num = int(request.POST['scientific_direction_num'])
- graduation_num = int(request.POST['graduation_num'])
- scientific_research = [request.POST[f'scientific_research_{i}']
- for i in range(scientific_research_num+1)]
- challenge_cup = [request.POST[f'challenge_cup_{i}']
- for i in range(challenge_cup_num+1)]
- internship = [request.POST[f'internship_{i}']
- for i in range(internship_num+1)]
- scientific_direction = [request.POST[f'scientific_direction_{i}']
- for i in range(scientific_direction_num+1)]
- graduation = [request.POST[f'graduation_{i}']
- for i in range(graduation_num+1)]
-
- # 对上述五个列表中的所有填写项目,检查是否超过数据库要求的字数上限
- def max_length_of(items): return max(
- [len(item) for item in items]) if len(items) > 0 else 0
- MAX_LENGTH = 4095
- if max_length_of(scientific_research) > MAX_LENGTH:
- return wrong("您设置的本科生科研经历太长啦!请修改~")
- elif max_length_of(challenge_cup) > MAX_LENGTH:
- return wrong("您设置的挑战杯经历太长啦!请修改~")
- elif max_length_of(internship) > MAX_LENGTH:
- return wrong("您设置的实习经历太长啦!请修改~")
- elif max_length_of(scientific_direction) > MAX_LENGTH:
- return wrong("您设置的科研方向太长啦!请修改~")
- elif max_length_of(graduation) > MAX_LENGTH:
- return wrong("您设置的毕业去向太长啦!请修改~")
-
- # 从checkbox获取所有栏目的公开状态
- major_status = request.POST['major_status']
- minor_status = request.POST['minor_status']
- double_degree_status = request.POST['double_degree_status']
- project_status = request.POST['project_status']
- scientific_research_status = request.POST['scientific_research_status']
- challenge_cup_status = request.POST['challenge_cup_status']
- internship_status = request.POST['internship_status']
- scientific_direction_status = request.POST['scientific_direction_status']
- graduation_status = request.POST['graduation_status']
-
- # 获取前端信息后对数据库进行更新
- with transaction.atomic():
- assert request.user.is_authenticated
- user = cast(User, request.user)
- me = get_person_or_org(user, User.Type.PERSON)
-
- # 首先更新自己的TagEntry
- update_tag_entry(me, majors, major_status, AcademicTag.Type.MAJOR)
- update_tag_entry(me, minors, minor_status, AcademicTag.Type.MINOR)
- update_tag_entry(me, double_degrees, double_degree_status,
- AcademicTag.Type.DOUBLE_DEGREE)
- update_tag_entry(me, projects, project_status,
- AcademicTag.Type.PROJECT)
-
- # 然后更新自己的TextEntry
- update_text_entry(
- me, scientific_research, scientific_research_status,
- AcademicTextEntry.Type.SCIENTIFIC_RESEARCH
- )
- update_text_entry(
- me, challenge_cup, challenge_cup_status,
- AcademicTextEntry.Type.CHALLENGE_CUP
- )
- update_text_entry(
- me, internship, internship_status,
- AcademicTextEntry.Type.INTERNSHIP
- )
- update_text_entry(
- me, scientific_direction, scientific_direction_status,
- AcademicTextEntry.Type.SCIENTIFIC_DIRECTION
- )
- update_text_entry(
- me, graduation, graduation_status,
- AcademicTextEntry.Type.GRADUATION
- )
-
- # 最后更新是否允许他人提问
- accept_chat = request.POST["accept_chat"]
- user.accept_chat = True if accept_chat == "True" else False
- user.save()
-
- return succeed("学术地图修改成功!")
-
-
-def get_wait_audit_student() -> set[NaturalPerson]:
- """
- 获取当前审核中的AcademicEntry对应的学生,因为要去重,所以返回一个集合
-
- :return: 当前审核中的AcademicEntry对应的NaturalPerson组成的集合
- :rtype: set[NaturalPerson]
- """
- wait_audit_tag_entries = AcademicTagEntry.objects.filter(
- status=AcademicEntry.EntryStatus.WAIT_AUDIT)
- wait_audit_text_entries = AcademicTextEntry.objects.filter(
- status=AcademicEntry.EntryStatus.WAIT_AUDIT)
-
- wait_audit_students = set()
- for entry in wait_audit_tag_entries:
- wait_audit_students.add(entry.person)
- for entry in wait_audit_text_entries:
- wait_audit_students.add(entry.person)
-
- return wait_audit_students
-
-
-def audit_academic_map(author: NaturalPerson) -> None:
- """
- 审核通过某用户的记录。
- :param author: 被审核用户
- :type author: NaturalPerson
- """
- # 筛选所有待审核的记录
- AcademicTagEntry.objects.activated().filter(
- person=author, status=AcademicEntry.EntryStatus.WAIT_AUDIT).update(
- status=AcademicEntry.EntryStatus.PUBLIC)
-
- AcademicTextEntry.objects.activated().filter(
- person=author, status=AcademicEntry.EntryStatus.WAIT_AUDIT).update(
- status=AcademicEntry.EntryStatus.PUBLIC)
-
-
-def have_entries(author: NaturalPerson,
- status_in: list[AcademicEntry.EntryStatus]) -> bool:
- """
- 判断用户有无status属性为public/wait_audit...的学术地图条目(tag和text)
- :param author: 条目作者用户
- :type author: NaturalPerson
- :param status_in: AcademicEntry.EntryStatus构成的list
- :type status_in: list[AcademicEntry.EntryStatus]
- :return: 是否有该类别的条目
- :rtype: bool
- """
- all_tag_entries = AcademicTagEntry.objects.activated().filter(
- person=author, status__in=status_in)
- all_text_entries = (AcademicTextEntry.objects.activated().filter(
- person=author, status__in=status_in))
- return bool(all_tag_entries) or bool(all_text_entries)
-
-
-def get_tags_for_search():
- tags = AcademicTag.objects.all()
- tag_contents = set()
- for t in tags:
- tag_contents.add(t.tag_content)
-
- id = 0
- tags_for_search = []
- for t in tag_contents:
- tags_for_search.append({'id': id, 'text': t})
- id += 1
-
- return tags_for_search
diff --git a/app/academic_views.py b/app/academic_views.py
deleted file mode 100644
index 201b5fb6a..000000000
--- a/app/academic_views.py
+++ /dev/null
@@ -1,254 +0,0 @@
-from generic.utils import to_search_indices
-from app.views_dependency import *
-from app.models import (
- AcademicTag,
- AcademicTextEntry,
- Chat,
- NaturalPerson,
-)
-from app.academic_utils import (
- chats2Display,
- comments2display,
- get_js_tag_list,
- get_text_list,
- get_tag_status,
- get_text_status,
- update_academic_map,
- get_wait_audit_student,
- audit_academic_map,
- get_tags_for_search,
-)
-from app.utils import (
- get_sidebar_and_navbar,
- get_person_or_org,
-)
-from achievement.api import unlock_achievement
-
-__all__ = [
- 'ShowChats',
- 'ViewChat',
- 'modifyAcademic',
- 'auditAcademic',
- 'applyAuditAcademic',
-]
-
-
-class ShowChats(ProfileTemplateView):
-
- http_method_names = ['get']
- template_name = 'academic/showChats.html'
- page_name = '学术地图问答'
-
- def prepare_get(self):
- if not self.request.user.is_person():
- # 后续或许可以开放任意的聊天
- return self.wrong('请使用个人账号访问问答中心页面!')
- return self.get
-
- def get(self) -> HttpResponse:
- self.extra_context.update({
- 'sent_chats': chats2Display(self.request.user, sent=True),
- 'received_chats': chats2Display(self.request.user, sent=False),
- 'stu_list': to_search_indices(User.objects.filter_type(User.Type.PERSON)),
- 'tag_list': get_tags_for_search(),
- })
-
- return self.render()
-
-
-class ViewChat(ProfileTemplateView):
-
- http_method_names = ['get']
- template_name = 'academic/viewChat.html'
- page_name = '学术地图问答'
-
- def setup(self, request: HttpRequest, chat_id: int):
- self.chat_id = chat_id
- return super().setup(request, chat_id=chat_id)
-
- def prepare_get(self):
- possible_chat = Chat.objects.filter(id=self.chat_id).first()
- if possible_chat is None:
- return self.wrong('问答不存在')
- chat: Chat = possible_chat
- if self.request.user not in [chat.questioner, chat.respondent]:
- return self.wrong('您只能访问自己参与的问答!')
- self.chat = chat
- return self.get
-
- def get(self) -> HttpResponse:
- user = self.request.user
- self.extra_context.update(comments2display(self.chat, user))
- return self.render()
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def modifyAcademic(request: UserRequest) -> HttpResponse:
- """
- 学术地图编辑界面
-
- :param request: http请求
- :type request: HttpRequest
- :return: http响应
- :rtype: HttpResponse
- """
- frontend_dict = {}
-
- if not request.user.is_person():
- return redirect(message_url(wrong("只有个人才可以修改自己的学术地图!")))
-
- # POST表明编辑界面发起修改
- if request.method == "POST" and request.POST:
- try:
- context = update_academic_map(request)
- if context["warn_code"] == 1: # 填写的TextEntry太长导致填写失败
- return redirect(message_url(context, "/modifyAcademic/"))
- else: # warn_code == 2,表明填写成功
- unlock_achievement(request.user, "编辑自己的学术地图") # 解锁成就-编辑自己的学术地图
- return redirect(message_url(context, "/stuinfo/#tab=academic_map"))
- except:
- return redirect(message_url(wrong("修改过程中出现意料之外的错误,请联系工作人员处理!")))
-
- # 不是POST,说明用户希望编辑学术地图,下面准备前端展示量
- # 获取所有专业/项目的列表,左右前端select框的下拉选项
- me = get_person_or_org(request.user)
- frontend_dict.update(
- major_list=get_js_tag_list(me, AcademicTag.Type.MAJOR, selected=False),
- minor_list=get_js_tag_list(me, AcademicTag.Type.MINOR, selected=False),
- double_degree_list=get_js_tag_list(
- me, AcademicTag.Type.DOUBLE_DEGREE, selected=False),
- project_list=get_js_tag_list(
- me, AcademicTag.Type.PROJECT, selected=False),
- )
-
- # 获取用户已有的专业/项目的列表,用于select的默认选中项
- frontend_dict.update(
- selected_major_list=get_js_tag_list(
- me, AcademicTag.Type.MAJOR, selected=True),
- selected_minor_list=get_js_tag_list(
- me, AcademicTag.Type.MINOR, selected=True),
- selected_double_degree_list=get_js_tag_list(
- me, AcademicTag.Type.DOUBLE_DEGREE, selected=True),
- selected_project_list=get_js_tag_list(
- me, AcademicTag.Type.PROJECT, selected=True),
- )
-
- # 获取用户已有的TextEntry的contents,用于TextEntry填写栏的前端预填写
- scientific_research_list = get_text_list(
- me, AcademicTextEntry.Type.SCIENTIFIC_RESEARCH
- )
- challenge_cup_list = get_text_list(
- me, AcademicTextEntry.Type.CHALLENGE_CUP
- )
- internship_list = get_text_list(
- me, AcademicTextEntry.Type.INTERNSHIP
- )
- scientific_direction_list = get_text_list(
- me, AcademicTextEntry.Type.SCIENTIFIC_DIRECTION
- )
- graduation_list = get_text_list(
- me, AcademicTextEntry.Type.GRADUATION
- )
- frontend_dict.update(
- scientific_research_list=scientific_research_list,
- challenge_cup_list=challenge_cup_list,
- internship_list=internship_list,
- scientific_direction_list=scientific_direction_list,
- graduation_list=graduation_list,
- scientific_research_num=len(scientific_research_list),
- challenge_cup_num=len(challenge_cup_list),
- internship_num=len(internship_list),
- scientific_direction_num=len(scientific_direction_list),
- graduation_num=len(graduation_list),
- )
-
- # 最后获取每一种atype对应的entry的公开状态,如果没有则默认为公开
- major_status = get_tag_status(me, AcademicTag.Type.MAJOR)
- minor_status = get_tag_status(me, AcademicTag.Type.MINOR)
- double_degree_status = get_tag_status(me, AcademicTag.Type.DOUBLE_DEGREE)
- project_status = get_tag_status(me, AcademicTag.Type.PROJECT)
- scientific_research_status = get_text_status(
- me, AcademicTextEntry.Type.SCIENTIFIC_RESEARCH
- )
- challenge_cup_status = get_text_status(
- me, AcademicTextEntry.Type.CHALLENGE_CUP
- )
- internship_status = get_text_status(
- me, AcademicTextEntry.Type.INTERNSHIP
- )
- scientific_direction_status = get_text_status(
- me, AcademicTextEntry.Type.SCIENTIFIC_DIRECTION
- )
- graduation_status = get_text_status(
- me, AcademicTextEntry.Type.GRADUATION
- )
-
- status_dict = dict(
- major_status=major_status,
- minor_status=minor_status,
- double_degree_status=double_degree_status,
- project_status=project_status,
- scientific_research_status=scientific_research_status,
- challenge_cup_status=challenge_cup_status,
- internship_status=internship_status,
- scientific_direction_status=scientific_direction_status,
- graduation_status=graduation_status,
- )
- frontend_dict.update(status_dict)
-
- # 获取“全部公开”checkbox的选中状态与公开的type数量/总type数
- frontend_dict["all_status"] = "私密" if "私密" in status_dict.values() else "公开"
- frontend_dict["public_num"] = list(status_dict.values()).count("公开")
- frontend_dict["total_num"] = len(status_dict)
-
- # 获取用户是否允许他人提问
- frontend_dict["accept_chat"] = request.user.accept_chat
-
- # 最后获取侧边栏信息
- frontend_dict["bar_display"] = get_sidebar_and_navbar(
- request.user, "修改学术地图")
- frontend_dict["warn_code"] = request.GET.get('warn_code', 0)
- frontend_dict["warn_message"] = request.GET.get('warn_message', "")
- return render(request, "academic/modify.html", frontend_dict)
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def auditAcademic(request: UserRequest) -> HttpResponse:
- """
- 供教师使用的页面,展示所有待审核的学术地图
-
- :param request
- :type request: HttpRequest
- :return
- :rtype: HttpResponse
- """
- # 身份检查
- person = get_person_or_org(request.user)
- if not (request.user.is_person() and person.is_teacher()):
- return redirect(message_url(wrong('只有教师账号可进入学术地图审核页面!')))
-
- frontend_dict = {}
- frontend_dict["bar_display"] = get_sidebar_and_navbar(
- request.user, "审核学术地图")
- frontend_dict["student_list"] = get_wait_audit_student()
-
- return render(request, "academic/audit.html", frontend_dict)
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def applyAuditAcademic(request: HttpRequest):
- if not NaturalPerson.objects.get_by_user(request.user).is_teacher():
- return JsonResponse(wrong("只有老师才能执行审核操作!"))
- try:
- author = NaturalPerson.objects.get_by_user(request.POST.get("author_id"))
- # 需要回传作者的person_id.id
- audit_academic_map(author)
- return JsonResponse(succeed("审核成功!"))
- except:
- return JsonResponse(wrong("审核发布时发生未知错误,请联系管理员!"))
diff --git a/app/activity_utils.py b/app/activity_utils.py
deleted file mode 100644
index 15f0240dd..000000000
--- a/app/activity_utils.py
+++ /dev/null
@@ -1,978 +0,0 @@
-"""
-activity_utils.py
-
-这个文件应该只被 ./activity_views.py, ./scheduler_func.py 依赖
-依赖于 ./utils.py, ./wechat_send.py, ./notification_utils.py
-
-scheduler_func 依赖于 wechat_send 依赖于 utils
-
-文件中参数存在 activity 的函数需要在 transaction.atomic() 块中进行。
-如果存在预期异常,抛出 ActivityException,否则抛出其他异常
-"""
-import io
-import base64
-import random
-from typing import Iterable
-from datetime import datetime, timedelta
-
-import qrcode
-
-from utils.http.utils import build_full_url
-import utils.models.query as SQ
-from generic.models import User, YQPointRecord
-from scheduler.adder import ScheduleAdder
-from scheduler.cancel import remove_job
-from app.utils_dependency import *
-from app.models import (
- User,
- NaturalPerson as Person,
- Organization,
- Organization as Org,
- OrganizationType as OrgType,
- Position,
- Activity,
- Participation,
- Notification,
- ActivityPhoto,
-)
-from app.utils import get_person_or_org, if_image
-from app.notification_utils import (
- notification_create,
- bulk_notification_create,
- notification_status_change,
-)
-from app.extern.wechat import WechatApp, WechatMessageLevel
-from app.log import logger
-
-
-__all__ = [
- 'changeActivityStatus',
- 'notifyActivity',
- 'ActivityException',
- 'create_activity',
- 'modify_activity',
- 'accept_activity',
- 'reject_activity',
- 'apply_activity',
- 'cancel_activity',
- 'withdraw_activity',
- 'get_activity_QRcode',
- 'create_participate_infos',
- 'modify_participants',
- 'weekly_summary_orgs',
- 'available_participants',
-]
-
-
-"""
-使用方式:
-scheduler.add_job(changeActivityStatus, "date",
- id=f"activity_{aid}_{to_status}", run_date, args)
-注意:
- 1、当 cur_status 不为 None 时,检查活动是否为给定状态
- 2、一个活动每一个目标状态最多保留一个定时任务
-允许的状态变换:
- 2、报名中 -> 等待中
- 3、等待中 -> 进行中
- 4、进行中 -> 已结束
-活动变更为进行中时,更新报名成功人员状态
-"""
-
-
-@logger.secure_func('活动状态更新异常')
-@logger.secure_func('检查活动状态', exc_type=AssertionError)
-@transaction.atomic
-def changeActivityStatus(aid, cur_status, to_status):
- '''
- 幂等;可能发生异常;包装器负责处理异常
- 必须提供cur_status,则会在转换状态前检查前后状态,每次只能变更一个阶段
- 即:报名中->等待中->进行中->结束
- 状态不符合时,抛出AssertionError
- '''
- activity = Activity.objects.select_for_update().get(id=aid)
- if cur_status is None:
- raise AssertionError('未提供当前状态,不允许进行活动状态修改')
- if cur_status == Activity.Status.CANCELED:
- return
- assert cur_status == activity.status, f'<{activity.status}>与期望的状态<{cur_status}>不符'
-
- FSM = [
- (Activity.Status.APPLYING, Activity.Status.WAITING),
- (Activity.Status.WAITING, Activity.Status.PROGRESSING),
- (Activity.Status.PROGRESSING, Activity.Status.END),
- ]
- LIMITED_STATUS = list(set(map(lambda x: x[0], FSM)))
- if cur_status in LIMITED_STATUS:
- assert (cur_status, to_status) in FSM, f'不能从{cur_status}变更到{to_status}'
-
- if to_status == Activity.Status.WAITING:
- if activity.bidding:
- draw_lots(activity)
-
- # 活动变更为进行中时,修改参与人参与状态
- elif to_status == Activity.Status.PROGRESSING:
- unchecked = SQ.sfilter(Participation.activity, activity).filter(
- status=Participation.AttendStatus.APPLYSUCCESS)
- if activity.need_checkin:
- unchecked.update(status=Participation.AttendStatus.UNATTENDED)
- else:
- unchecked.update(status=Participation.AttendStatus.ATTENDED)
-
- # if not activity.valid:
- # # 活动开始后,未审核自动通过
- # activity.valid = True
- # notification = Notification.objects.get(
- # relate_instance=activity,
- # status=Notification.Status.UNDONE,
- # title=Notification.Title.VERIFY_INFORM
- # )
- # notification_status_change(notification, Notification.Status.DONE)
-
- # 结束,计算元气值
- elif (to_status == Activity.Status.END
- and activity.category != Activity.ActivityCategory.COURSE):
- activity.settle_yqpoint(status=to_status)
- # 过早进行这个修改,将被写到activity待执行的保存中,导致失败后调用activity.save仍会调整状态
- activity.status = to_status
- activity.save()
-
-
-"""
-需要在 transaction 中使用
-所有涉及到 activity 的函数,都应该先锁 activity
-"""
-
-
-def draw_lots(activity: Activity):
- participation = SQ.sfilter(Participation.activity, activity)
- participation: QuerySet[Participation]
- l = len(participation.filter(status=Participation.AttendStatus.APPLYING))
-
- engaged = len(participation.filter(
- status__in=[Participation.AttendStatus.APPLYSUCCESS,
- Participation.AttendStatus.UNATTENDED,
- Participation.AttendStatus.ATTENDED]
- ))
-
- leftQuota = activity.capacity - engaged
-
- if l <= leftQuota:
- participation.filter(
- status__in=[Participation.AttendStatus.APPLYING,
- Participation.AttendStatus.APPLYFAILED]
- ).update(status=Participation.AttendStatus.APPLYSUCCESS)
- activity.current_participants = engaged + l
- else:
- lucky_ones = random.sample(range(l), leftQuota)
- activity.current_participants = activity.capacity
- for i, participant in enumerate(participation.select_for_update().filter(
- status__in=[Participation.AttendStatus.APPLYING,
- Participation.AttendStatus.APPLYFAILED]
- )):
- if i in lucky_ones:
- participant.status = Participation.AttendStatus.APPLYSUCCESS
- else:
- participant.status = Participation.AttendStatus.APPLYFAILED
- participant.save()
- # 签到成功的转发通知和微信通知
- receivers = SQ.qsvlist(participation.filter(
- status=Participation.AttendStatus.APPLYSUCCESS
- ), Participation.person, Person.person_id)
- receivers = User.objects.filter(id__in=receivers)
- sender = activity.organization_id.get_user()
- typename = Notification.Type.NEEDREAD
- content = f'您好!您参与抽签的活动“{activity.title}”报名成功!请准时参加活动!'
- URL = f'/viewActivity/{activity.id}'
- if len(receivers) > 0:
- bulk_notification_create(
- receivers=receivers,
- sender=sender,
- typename=typename,
- title=Notification.Title.ACTIVITY_INFORM,
- content=content,
- URL=URL,
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT, level=WechatMessageLevel.IMPORTANT),
- )
- # 抽签失败的同学发送通知
- receivers = SQ.qsvlist(participation.filter(
- status=Participation.AttendStatus.APPLYFAILED
- ), Participation.person, Person.person_id)
- receivers = User.objects.filter(id__in=receivers)
- content = f'很抱歉通知您,您参与抽签的活动“{activity.title}”报名失败!'
- if len(receivers) > 0:
- bulk_notification_create(
- receivers=receivers,
- sender=sender,
- typename=typename,
- title=Notification.Title.ACTIVITY_INFORM,
- content=content,
- URL=URL,
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT, level=WechatMessageLevel.IMPORTANT),
- )
-
-
-"""
-使用方式:
-scheduler.add_job(notifyActivityStart, "date",
- id=f"activity_{aid}_{start_notification}", run_date, args)
-"""
-
-
-def _participant_uids(activity: Activity) -> list[int]:
- participant_person_id = SQ.qsvlist(Participation.objects.filter(
- SQ.sq(Participation.activity, activity),
- SQ.mq(Participation.status, IN=[Participation.AttendStatus.APPLYSUCCESS,
- Participation.AttendStatus.APPLYING])
- ), Participation.person)
- return SQ.qsvlist(Person.objects.activated().filter(
- id__in=participant_person_id), Person.person_id)
-
-
-def _subscriber_uids(activity: Activity) -> list[int]:
- return SQ.qsvlist(Person.objects.activated().exclude(
- id__in=activity.organization_id.unsubscribers.all()
- ), Person.person_id)
-
-
-@logger.secure_func('活动消息发送异常')
-def notifyActivity(aid: int, msg_type: str, msg=""):
- activity = Activity.objects.get(id=aid)
- inner = activity.inner
- title = Notification.Title.ACTIVITY_INFORM
- if msg_type == "newActivity":
- title = activity.title
- msg = f"您关注的小组{activity.organization_id.oname}发布了新的活动。"
- msg += f"\n开始时间: {activity.start.strftime('%Y-%m-%d %H:%M')}"
- msg += f"\n活动地点: {activity.location}"
- receivers = User.objects.filter(id__in=_subscriber_uids(activity))
- publish_kws = dict(app=WechatApp.TO_SUBSCRIBER)
- elif msg_type == "remind":
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- nowtime = datetime.now()
- notifications = Notification.objects.filter(
- relate_instance=activity,
- start_time__gt=nowtime + timedelta(seconds=60),
- title=Notification.Title.PENDING_INFORM,
- )
- if len(notifications) > 0:
- return
- msg = f"您参与的活动 <{activity.title}> 即将开始。"
- msg += f"\n开始时间: {activity.start.strftime('%Y-%m-%d %H:%M')}"
- msg += f"\n活动地点: {activity.location}"
- receivers = User.objects.filter(id__in=_participant_uids(activity))
- publish_kws = dict(app=WechatApp.TO_PARTICIPANT)
-
- elif msg_type == 'modification_sub':
- receivers = User.objects.filter(id__in=_subscriber_uids(activity))
- publish_kws = dict(app=WechatApp.TO_SUBSCRIBER)
- elif msg_type == 'modification_par':
- receivers = User.objects.filter(id__in=_participant_uids(activity))
- publish_kws = dict(
- app=WechatApp.TO_PARTICIPANT,
- level=WechatMessageLevel.IMPORTANT,
- )
- elif msg_type == "modification_sub_ex_par":
- receiver_uids = list(set(_subscriber_uids(activity)) -
- set(_participant_uids(activity)))
- receivers = User.objects.filter(id__in=receiver_uids)
- publish_kws = dict(app=WechatApp.TO_SUBSCRIBER)
-
- # 应该用不到了,调用的时候分别发给 par 和 sub
- # 主要发给两类用户的信息往往是不一样的
- elif msg_type == 'modification_all':
- receiver_uids = list(set(_subscriber_uids(activity)) |
- set(_participant_uids(activity)))
- receivers = User.objects.filter(id__in=receiver_uids)
- publish_kws = dict(app=WechatApp.TO_SUBSCRIBER)
- elif msg_type == 'newCourseActivity':
- title = activity.title
- msg = f"课程{activity.organization_id.oname}发布了新的课程活动。"
- msg += f"\n开始时间: {activity.start.strftime('%Y-%m-%d %H:%M')}"
- msg += f"\n活动地点: {activity.location}"
- receiver_uids = list(set(_subscriber_uids(activity)) |
- set(_participant_uids(activity)))
- receivers = User.objects.filter(id__in=receiver_uids)
- publish_kws = dict(app=WechatApp.TO_SUBSCRIBER)
- else:
- raise ValueError(f"msg_type参数错误: {msg_type}")
-
- if inner and publish_kws.get('app') == WechatApp.TO_SUBSCRIBER:
- member_id_list = SQ.qsvlist(Position.objects.activated().filter(
- org=activity.organization_id), Position.person, Person.person_id)
- receivers = receivers.filter(id__in=member_id_list)
-
- success, _ = bulk_notification_create(
- receivers=list(receivers),
- sender=activity.organization_id.get_user(),
- typename=Notification.Type.NEEDREAD,
- title=title,
- content=msg,
- URL=f"/viewActivity/{aid}",
- relate_instance=activity,
- to_wechat=publish_kws,
- )
- assert success, "批量创建通知并发送时失败"
-
-
-def get_activity_QRcode(activity):
- auth_code = GLOBAL_CONFIG.hasher.encode(str(activity.id))
- url = build_full_url(f'checkinActivity/{activity.id}?auth={auth_code}')
- qr = qrcode.QRCode(
- version=2,
- error_correction=qrcode.constants.ERROR_CORRECT_L,
- box_size=5,
- border=5,
- )
- qr.add_data(url)
- qr.make(fit=True)
- img = qr.make_image()
- io_buffer = io.BytesIO()
- img.save(io_buffer, "png")
- data = base64.encodebytes(io_buffer.getvalue()).decode()
- return "data:image/png;base64," + str(data)
-
-
-class ActivityException(Exception):
- def __init__(self, msg):
- self.msg = msg
-
- def __str__(self):
- return self.msg
-
-
-# 时间合法性的检查,检查时间是否在当前时间的一个月以内,并且检查开始的时间是否早于结束的时间,
-def check_ac_time(start_time: datetime, end_time: datetime) -> bool:
- now_time = datetime.now()
- month_late = now_time + timedelta(days=30)
- if not start_time < end_time:
- return False
- if now_time < start_time < month_late:
- return True # 时间所处范围正确
-
- return False
-
-
-def activity_base_check(request, edit=False):
- '''正常情况下检查出错误会抛出不含错误信息的AssertionError,不抛出ActivityException'''
-
- context = dict()
-
- # title, introduction, location 创建时不能为空
- context["title"] = request.POST["title"]
- context["introduction"] = request.POST["introduction"]
- context["location"] = request.POST["location"]
- assert len(context["title"]) > 0
- assert len(context["introduction"]) > 0
- assert len(context["location"]) > 0
-
- # url,就不支持了 http 了,真的没必要
- context["url"] = request.POST["URL"]
- if context["url"] != "":
- assert context["url"].startswith(
- "http://") or context["url"].startswith("https://")
-
- # 在审核通过后,这些不可修改
- signscheme = int(request.POST["signscheme"])
- if signscheme:
- context["bidding"] = True
- else:
- context["bidding"] = False
-
- # examine_teacher 需要特殊检查
- context["examine_teacher"] = request.POST.get("examine_teacher")
-
- # 预报备
- context["recorded"] = False
- if request.POST.get("recorded"):
- context["recorded"] = True
-
- # 时间
- act_start = datetime.strptime(
- request.POST["actstart"], "%Y-%m-%d %H:%M") # 活动报名时间
- act_end = datetime.strptime(
- request.POST["actend"], "%Y-%m-%d %H:%M") # 活动报名结束时间
- context["start"] = act_start
- context["end"] = act_end
- assert check_ac_time(act_start, act_end)
-
- # create 或者调整报名时间,都是要确保活动不要立刻截止报名
- now_time = datetime.now()
- if not edit or request.POST.get("adjust_apply_ddl"):
- prepare_scheme = int(request.POST["prepare_scheme"])
- prepare_times = Activity.EndBeforeHours.prepare_times
- prepare_time = prepare_times[prepare_scheme]
- signup_end = act_start - timedelta(hours=prepare_time)
- assert now_time <= signup_end
- context["endbefore"] = prepare_scheme
- context["signup_end"] = signup_end
- else:
- # 修改但不调整报名截止时间,后面函数自己查
- context["adjust_apply"] = False
-
- # 人数限制
- capacity = request.POST.get("maxpeople")
- no_limit = request.POST.get("unlimited_capacity")
- if no_limit is not None:
- capacity = 10000
- if capacity is not None and capacity != "":
- capacity = int(capacity)
- assert capacity >= 0
- context["capacity"] = capacity
-
- # 需要签到
- if request.POST.get("need_checkin"):
- context["need_checkin"] = True
-
- # 内部活动
- if request.POST.get("inner"):
- context["inner"] = True
- else:
- context["inner"] = False
-
- # 图片 优先使用上传的图片
- announcephoto = request.FILES.getlist("images")
- if len(announcephoto) > 0:
- pic = announcephoto[0]
- assert if_image(pic) == 2
- else:
- if request.POST.get("picture1"):
- pic = request.POST.get("picture1")
- elif request.POST.get("picture2"):
- pic = request.POST.get("picture2")
- elif request.POST.get("picture3"):
- pic = request.POST.get("picture3")
- elif request.POST.get("picture4"):
- pic = request.POST.get("picture4")
- else:
- pic = request.POST.get("picture5")
-
- if pic is None:
- template_id = request.POST.get("template_id")
- if template_id:
- context["template_id"] = int(template_id)
- else:
- assert edit
- else:
- context["pic"] = pic
-
- return context
-
-
-def _set_change_status(activity: Activity, current, next, time, replace):
- ScheduleAdder(changeActivityStatus, id=f'activity_{activity.id}_{next}',
- run_time=time, replace=replace)(activity.id, current, next)
-
-
-def _set_jobs_to_status(activity: Activity, replace: bool) -> Activity.Status:
- now_time = datetime.now()
- status = Activity.Status.END
- if now_time < activity.end:
- status, next = Activity.Status.PROGRESSING, Activity.Status.END
- _set_change_status(activity, status, next, activity.end, replace)
- if now_time < activity.start:
- status, next = Activity.Status.WAITING, Activity.Status.PROGRESSING
- _set_change_status(activity, status, next, activity.start, replace)
- if now_time < activity.apply_end:
- status, next = Activity.Status.APPLYING, Activity.Status.WAITING
- _set_change_status(activity, status, next, activity.apply_end, replace)
- if now_time < activity.start - timedelta(minutes=15):
- reminder = ScheduleAdder(notifyActivity, id=f'activity_{activity.id}_remind',
- run_time=activity.start - timedelta(minutes=15), replace=replace)
- reminder(activity.id, 'remind')
- return status
-
-
-def create_activity(request):
- '''
- 检查活动,合法时寻找该活动,不存在时创建
- 返回(activity.id, created)
-
- ---
- 检查不合格时抛出AssertionError
- - 不再假设ActivityException特定语义,暂不抛出该类异常
- '''
-
- context = activity_base_check(request)
-
- # 查找是否有类似活动存在
- old_ones = Activity.objects.activated().filter(
- title=context["title"],
- start=context["start"],
- introduction=context["introduction"],
- location=context["location"]
- )
- if len(old_ones) == 0:
- old_ones = Activity.objects.filter(
- title=context["title"],
- start=context["start"],
- introduction=context["introduction"],
- location=context["location"],
- status=Activity.Status.REVIEWING,
- )
- if len(old_ones):
- assert len(old_ones) == 1, "创建活动时,已存在的相似活动不唯一"
- return old_ones[0].id, False
-
- # 审批老师存在
- examine_teacher = Person.objects.get_teacher(context["examine_teacher"])
-
- # 检查完毕,创建活动
- org = get_person_or_org(request.user, UTYPE_ORG)
- activity = Activity.objects.create(
- title=context["title"],
- organization_id=org,
- examine_teacher=examine_teacher,
- introduction=context["introduction"],
- location=context["location"],
- capacity=context["capacity"],
- URL=context["url"],
- start=context["start"],
- end=context["end"],
- bidding=context["bidding"],
- apply_end=context["signup_end"],
- inner=context["inner"],
- )
- activity.endbefore = context["endbefore"]
- if context.get("need_checkin"):
- activity.need_checkin = True
- if context["recorded"]:
- # 预报备活动,先开放报名,再审批
- activity.recorded = True
- activity.status = Activity.Status.APPLYING
- notifyActivity(activity.id, "newActivity")
- _set_jobs_to_status(activity, replace=False)
-
- activity.save()
-
- if context.get("template_id"):
- template = Activity.objects.get(id=context["template_id"])
- photo = ActivityPhoto.objects.get(
- type=ActivityPhoto.PhotoType.ANNOUNCE, activity=template)
- photo.pk = None
- photo.id = None
- photo.activity = activity
- photo.save()
- else:
- ActivityPhoto.objects.create(
- image=context["pic"], type=ActivityPhoto.PhotoType.ANNOUNCE, activity=activity)
-
- notification_create(
- receiver=examine_teacher.person_id,
- sender=request.user,
- typename=Notification.Type.NEEDDO,
- title=Notification.Title.VERIFY_INFORM,
- content="您有一个活动待审批",
- URL=f"/examineActivity/{activity.id}",
- relate_instance=activity,
- to_wechat=dict(app=WechatApp.AUDIT),
- )
-
- return activity.id, True
-
-
-def modify_activity(request, activity):
-
- if activity.status == Activity.Status.REVIEWING:
- modify_reviewing_activity(request, activity)
- elif activity.status == Activity.Status.APPLYING or activity.status == Activity.Status.WAITING:
- modify_accepted_activity(request, activity)
- else:
- raise ValueError
-
-
-"""
-检查 修改审核中活动 的 request
-审核中,只需要修改内容,不需要通知
-但如果修改了审核老师,需要再通知新的审核老师,并 close 原审核请求
-"""
-
-
-def modify_reviewing_activity(request, activity):
-
- context = activity_base_check(request, edit=True)
-
- if context.get("adjust_apply") is not None:
- # 注意这里是不调整
- assert context["adjust_apply"] == False
- assert activity.apply_end <= context["start"] - timedelta(hours=1)
- else:
- activity.apply_end = context["signup_end"]
- activity.endbefore = context["endbefore"]
-
- activity.title = context["title"]
- activity.introduction = context["introduction"]
- activity.location = context["location"]
- activity.capacity = context["capacity"]
- activity.URL = context["url"]
- activity.start = context["start"]
- activity.end = context["end"]
- activity.bidding = context["bidding"]
- if context.get("need_checkin"):
- activity.need_checkin = True
- else:
- activity.need_checkin = False
- if context.get("inner"):
- activity.inner = True
- else:
- activity.inner = False
- activity.save()
-
- # 图片
- if context.get("pic") is not None:
- pic = activity.photos.get(type=ActivityPhoto.PhotoType.ANNOUNCE)
- pic.image = context["pic"]
- pic.save()
-
-
-"""
-对已经通过审核的活动进行修改
-不能修改预算,元气值支付模式,审批老师
-只能修改时间,地点,URL, 简介,向同学收取元气值时的元气值数量
-
-# 这个实际上应该是 activated/valid activity
-"""
-
-
-def modify_accepted_activity(request, activity):
-
- # TODO 删除任务,注册新任务
-
- to_participants = [f"您参与的活动{activity.title}发生变化"]
- # to_subscribers = [f"您关注的活动{activity.title}发生变化"]
- if activity.location != request.POST["location"]:
- to_participants.append("活动地点修改为" + request.POST["location"])
- activity.location = request.POST["location"]
-
- # 时间改变
- act_start = datetime.strptime(request.POST["actstart"], "%Y-%m-%d %H:%M")
- now_time = datetime.now()
- assert now_time < act_start
-
- if request.POST.get("adjust_apply_ddl"):
- prepare_scheme = int(request.POST["prepare_scheme"])
- prepare_times = Activity.EndBeforeHours.prepare_times
- prepare_time = prepare_times[prepare_scheme]
- signup_end = act_start - timedelta(hours=prepare_time)
- assert now_time <= signup_end
- activity.apply_end = signup_end
- activity.endbefore = prepare_scheme
- # to_subscribers.append(f"活动报名截止时间调整为{signup_end.strftime('%Y-%m-%d %H:%M')}")
- to_participants.append(
- f"活动报名截止时间调整为{signup_end.strftime('%Y-%m-%d %H:%M')}")
- else:
- signup_end = activity.apply_end
- assert signup_end + timedelta(hours=1) <= act_start
-
- if activity.start != act_start:
- # to_subscribers.append(f"活动开始时间调整为{act_start.strftime('%Y-%m-%d %H:%M')}")
- to_participants.append(
- f"活动开始时间调整为{act_start.strftime('%Y-%m-%d %H:%M')}")
- activity.start = act_start
-
- if signup_end < now_time and activity.status == Activity.Status.WAITING:
- activity.status = Activity.Status.APPLYING
-
- if request.POST.get("unlimited_capacity"):
- capacity = 10000
- else:
- capacity = int(request.POST["maxpeople"])
- assert capacity > 0
- if capacity < len(SQ.sfilter(Participation.activity, activity).filter(
- status=Participation.AttendStatus.APPLYSUCCESS
- )):
- raise ActivityException(f"当前成功报名人数已超过{capacity}人!")
- activity.capacity = capacity
-
- if request.POST.get("need_checkin"):
- activity.need_checkin = True
- else:
- activity.need_checkin = False
-
- # 内部活动
- if request.POST.get("inner"):
- activity.inner = True
- else:
- activity.inner = False
-
- activity.end = datetime.strptime(request.POST["actend"], "%Y-%m-%d %H:%M")
- assert activity.start < activity.end
- activity.URL = request.POST["URL"]
- activity.introduction = request.POST["introduction"]
- activity.save()
-
- _set_jobs_to_status(activity, replace=True)
-
- # if len(to_subscribers) > 1:
- # notifyActivity(activity.id, "modification_sub_ex_par", "\n".join(to_subscribers))
- if len(to_participants) > 1:
- notifyActivity(activity.id, "modification_par",
- "\n".join(to_participants))
-
-
-def accept_activity(request, activity):
-
- # 审批通过
- activity.valid = True
-
- # 通知
- notification = Notification.objects.get(
- relate_instance=activity,
- status=Notification.Status.UNDONE,
- title=Notification.Title.VERIFY_INFORM
- )
- notification_status_change(notification, Notification.Status.DONE)
-
- notification_create(
- receiver=activity.organization_id.get_user(),
- sender=request.user,
- typename=Notification.Type.NEEDREAD,
- title=Notification.Title.ACTIVITY_INFORM,
- content=f"您的活动{activity.title}已通过审批。",
- URL=f"/viewActivity/{activity.id}",
- relate_instance=activity,
- to_wechat=dict(app=WechatApp.AUDIT),
- )
-
- if activity.status == Activity.Status.REVIEWING:
- activity.status = _set_jobs_to_status(activity, replace=False)
-
- activity.save()
-
-
-def _remove_jobs(activity: Activity, *jobs):
- for job in jobs:
- remove_job(f"activity_{activity.id}_{job}")
-
-
-def _remove_activity_jobs(activity: Activity):
- _remove_jobs(activity, 'remind', Activity.Status.WAITING,
- Activity.Status.PROGRESSING, Activity.Status.END)
-
-
-def reject_activity(request, activity):
- # 审批过,这个叫 valid 不太合适......
- activity.valid = True
-
- # 通知
- notification = Notification.objects.get(
- relate_instance=activity,
- status=Notification.Status.UNDONE,
- title=Notification.Title.VERIFY_INFORM
- )
- notification_status_change(notification, Notification.Status.DONE)
-
- if activity.status == Activity.Status.REVIEWING:
- activity.status = Activity.Status.REJECT
- else:
- Notification.objects.filter(
- relate_instance=activity
- ).update(status=Notification.Status.DELETE)
- # 曾将所有报名的人的状态改为申请失败
- notifyActivity(activity.id, "modification_par",
- f"您报名的活动{activity.title}已取消。")
- activity.status = Activity.Status.CANCELED
- _remove_activity_jobs(activity)
-
- notification = notification_create(
- receiver=activity.organization_id.get_user(),
- sender=request.user,
- typename=Notification.Type.NEEDREAD,
- title=Notification.Title.ACTIVITY_INFORM,
- content=f"您的活动{activity.title}被拒绝。",
- URL=f"/viewActivity/{activity.id}",
- relate_instance=activity,
- to_wechat=dict(app=WechatApp.AUDIT),
- )
-
- activity.save()
-
-
-# 调用的时候用 try
-def apply_activity(request, activity: Activity):
- '''这个函数在正常情况下只应该抛出提示错误信息的ActivityException'''
- context = dict()
- context["success"] = False
-
- payer = Person.objects.get_by_user(request.user)
-
- if activity.inner:
- position = Position.objects.activated().filter(
- person=payer, org=activity.organization_id)
- if len(position) == 0:
- # 按理说这里也是走不到的,前端会限制
- raise ActivityException(
- f"该活动是{activity.organization_id}内部活动,暂不开放对外报名。")
-
- try:
- participant = Participation.objects.select_for_update().get(
- SQ.sq(Participation.activity, activity),
- SQ.sq(Participation.person, payer),
- )
- participated = True
- except:
- participated = False
- if participated:
- if (
- participant.status == Participation.AttendStatus.APPLYSUCCESS or
- participant.status == Participation.AttendStatus.APPLYING
- ):
- raise ActivityException("您已报名该活动。")
- elif participant.status != Participation.AttendStatus.CANCELED:
- raise ActivityException(f"您的报名状态异常,当前状态为:{participant.status}")
-
- if not activity.bidding:
- if activity.current_participants < activity.capacity:
- activity.current_participants += 1
- else:
- raise ActivityException("活动已报满,请稍后再试。")
- else:
- activity.current_participants += 1
-
- if not participated:
- participant = Participation.objects.create(**dict([
- (SQ.f(Participation.activity), activity),
- (SQ.f(Participation.person), payer),
- ]))
- if not activity.bidding:
- participant.status = Participation.AttendStatus.APPLYSUCCESS
- else:
- participant.status = Participation.AttendStatus.APPLYING
-
- participant.save()
- activity.save()
-
-
-def cancel_activity(request, activity):
-
- if activity.status == Activity.Status.REVIEWING:
- activity.status = Activity.Status.ABORT
- activity.save()
- # 修改老师的通知
- notification = Notification.objects.get(
- relate_instance=activity,
- status=Notification.Status.UNDONE
- )
- notification_status_change(notification, Notification.Status.DELETE)
- return
-
- if activity.status == Activity.Status.PROGRESSING:
- if activity.start.day == datetime.now().day and datetime.now() < activity.start + timedelta(days=1):
- pass
- else:
- raise ActivityException("活动已于一天前开始,不能取消。")
-
- if activity.status == Activity.Status.CANCELED:
- raise ActivityException("活动已取消。")
-
- org = Organization.objects.select_for_update().get(
- organization_id=request.user
- )
-
- activity.status = Activity.Status.CANCELED
- notifyActivity(activity.id, "modification_par",
- f"您报名的活动{activity.title}已取消。")
- notification = Notification.objects.get(
- relate_instance=activity,
- typename=Notification.Type.NEEDDO
- )
- notification_status_change(notification, Notification.Status.DELETE)
-
- # 活动取消后,曾将所有相关状态变为申请失败
-
- _remove_activity_jobs(activity)
- activity.save()
-
-
-def withdraw_activity(request, activity: Activity):
-
- np = Person.objects.get_by_user(request.user)
- participant = Participation.objects.select_for_update().get(
- SQ.sq(Participation.activity, activity),
- SQ.sq(Participation.person, np),
- status__in=[
- Activity.Status.WAITING,
- Participation.AttendStatus.APPLYING,
- Participation.AttendStatus.APPLYSUCCESS,
- Participation.AttendStatus.CANCELED,
- ],
- )
- if participant.status == Participation.AttendStatus.CANCELED:
- raise ActivityException("已退出活动。")
- participant.status = Participation.AttendStatus.CANCELED
- activity.current_participants -= 1
-
- participant.save()
- activity.save()
-
-
-@transaction.atomic
-def create_participate_infos(activity: Activity, persons: Iterable[Person], **fields):
- '''批量创建一系列参与信息,并返回创建的参与信息'''
- participantion = [
- Participation(**dict([
- (SQ.f(Participation.activity), activity),
- (SQ.f(Participation.person), person),
- ]), **fields) for person in persons
- ]
- return Participation.objects.bulk_create(participantion)
-
-
-@transaction.atomic
-def _update_new_participants(activity: Activity, new_participant_uids: list[str]) -> int:
- '''更新活动的参与者,返回新增的参与者数量,不修改活动'''
- status = Participation.AttendStatus.ATTENDED
- participants = SQ.qsvlist(SQ.sfilter(Participation.activity, activity).filter(
- status=status).select_for_update(), Participation.person)
- # 获取需要添加的参与者
- new_participant_nps = SQ.mfilter(Person.person_id, User.username,
- IN=new_participant_uids)
- new_participant_nps = new_participant_nps.exclude(id__in=participants)
- # 此处必须执行查询,否则是lazy query,会导致后面的bulk_increase_YQPoint出错
- new_participant_ids = SQ.qsvlist(new_participant_nps, Person.person_id)
- new_participants = User.objects.filter(id__in=new_participant_ids)
- # 为添加的参与者增加元气值
- point = activity.eval_point()
- User.objects.bulk_increase_YQPoint(
- new_participants, point, '参加活动', YQPointRecord.SourceType.ACTIVITY)
- create_participate_infos(activity, new_participant_nps, status=status)
- return len(new_participant_nps)
-
-
-@transaction.atomic
-def _delete_outdate_participants(activity: Activity, new_participant_uids: list[str]):
- '''删除过期的参与者,收回元气值,返回删除的参与者的数量,不修改活动'''
- # 获取需要删除的参与者
- participation = SQ.sfilter(Participation.activity, activity).filter(
- status=Participation.AttendStatus.ATTENDED).select_for_update()
- removed_participation = participation.exclude(
- SQ.mq(Participation.person, Person.person_id, User.username,
- IN=new_participant_uids))
- removed_participant_ids = SQ.qsvlist(removed_participation,
- Participation.person, Person.person_id)
- removed_participants = User.objects.filter(id__in=removed_participant_ids)
- # 为删除的参与者撤销元气值发放
- point = activity.eval_point()
- User.objects.bulk_withdraw_YQPoint(removed_participants, point,
- '撤销参加活动', YQPointRecord.SourceType.CONSUMPTION)
- return removed_participation.delete()[0]
-
-
-@transaction.atomic
-def modify_participants(activity: Activity, new_participant_uids: list[str]):
- '''将参与信息修改为指定的参与者,参与者已经检查合法性,暂不允许删除'''
- new_uids = new_participant_uids
- # activity.current_participants -= _delete_outdate_participants(activity, new_uids)
- activity.current_participants += _update_new_participants(activity, new_uids)
- activity.save()
-
-
-def weekly_summary_orgs() -> QuerySet[Organization]:
- '''允许进行每周总结的组织'''
- VALID_ORG_TYPES = ['团委', '学学学委员会', '学学学学会', '学生会']
- return SQ.mfilter(Org.otype, OrgType.otype_name, IN=VALID_ORG_TYPES)
-
-
-def available_participants() -> QuerySet[User]:
- '''允许参与活动的人'''
- return SQ.mfilter(User.id, IN=SQ.qsvlist(Person.objects.activated(), Person.person_id))
diff --git a/app/activity_views.py b/app/activity_views.py
deleted file mode 100644
index ffe262020..000000000
--- a/app/activity_views.py
+++ /dev/null
@@ -1,1278 +0,0 @@
-import os
-import io
-import urllib.parse
-from datetime import datetime, timedelta
-from typing import Literal
-
-from django.db import transaction
-from django.db.models import F
-import csv
-import qrcode
-
-from generic.models import User
-from generic.utils import to_search_indices
-from app.views_dependency import *
-from app.view.base import ProfileTemplateView
-from app.models import (
- NaturalPerson,
- OrganizationType,
- Position,
- Activity,
- ActivityPhoto,
- Participation,
- ActivitySummary,
-)
-from app.activity_utils import (
- ActivityException,
- create_activity,
- modify_activity,
- accept_activity,
- reject_activity,
- apply_activity,
- cancel_activity,
- withdraw_activity,
- get_activity_QRcode,
- create_participate_infos,
- modify_participants,
- weekly_summary_orgs,
- available_participants,
-)
-from app.comment_utils import addComment, showComment
-from app.utils import (
- get_person_or_org,
- escape_for_templates,
-)
-
-__all__ = [
- 'viewActivity', 'getActivityInfo', 'checkinActivity',
- 'addActivity', 'activityCenter', 'examineActivity',
- 'offlineCheckinActivity', 'finishedActivityCenter', 'activitySummary',
- 'WeeklyActivitySummary',
-]
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def viewActivity(request: HttpRequest, aid=None):
- """
- 页面逻辑:
- 1. 方法为 GET 时,展示一个活动的详情。
- a. 如果当前用户是个人,有立即报名/已报名的 button
- b. 如果当前用户是小组,并且是该活动的所有者,有修改和取消活动的 button
- 2. 方法为 POST 时,通过 option 确定操作
- a. 如果修改活动,跳转到 addActivity
- b. 如果取消活动,本函数处理
- c. 如果报名活动,本函数处理 ( 还未实现 )
- # TODO
- 个人操作,包括报名与取消
- ----------------------------
- 活动逻辑
- 1. 活动开始前一小时,不能修改活动
- 2. 活动开始当天晚上之前,不能再取消活动 ( 目前用的 12 小时,感觉基本差不多 )
- """
-
- """
- aname = str(request.POST["aname"]) # 活动名称
- organization_id = request.POST["organization_id"] # 小组id
- astart = request.POST["astart"] # 默认传入的格式为 2021-07-21 21:00:00
- afinish = request.POST["afinish"]
- content = str(request.POST["content"])
- URL = str(request.POST["URL"]) # 活动推送链接
- QRcode = request.POST["QRcode"] # 收取元气值的二维码
- capacity = request.POST["capacity"] # 活动举办的容量
- """
-
- aid = int(aid)
- activity: Activity = Activity.objects.get(id=aid)
- org = activity.organization_id
- me = get_person_or_org(request.user)
- ownership = False
- if request.user.is_org() and org == me:
- ownership = True
- examine = False
- if request.user.is_person() and activity.examine_teacher == me:
- examine = True
- if not (ownership or examine) and activity.status in [
- Activity.Status.REVIEWING,
- Activity.Status.ABORT,
- Activity.Status.REJECT,
- ]:
- return redirect(message_url(wrong('该活动暂不可见!')))
-
- html_display = dict()
- inform_share, alert_message = utils.get_inform_share(me)
-
- if request.method == "POST" and request.POST:
- option = request.POST.get("option")
- if option == "cancel":
- try:
- if activity.status in [
- Activity.Status.REJECT,
- Activity.Status.ABORT,
- Activity.Status.END,
- Activity.Status.CANCELED,
- ]:
- return redirect(message_url(wrong('该活动已结束,不可取消!'), request.path))
- if not ownership:
- return redirect(message_url(wrong('您没有修改该活动的权限!'), request.path))
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- cancel_activity(request, activity)
- succeed("成功取消活动。", html_display)
- except ActivityException as e:
- wrong(str(e), html_display)
- except Exception as e:
- raise
-
- elif option == "edit":
- if (
- activity.status == Activity.Status.APPLYING
- or activity.status == Activity.Status.REVIEWING
- ):
- return redirect(f"/editActivity/{aid}")
- if activity.status == Activity.Status.WAITING:
- if activity.start + timedelta(hours=1) < datetime.now():
- return redirect(f"/editActivity/{aid}")
- wrong(f"距离活动开始前1小时内将不再允许修改活动。如确有雨天等意外情况,请及时取消活动。", html_display)
- else:
- wrong(f"活动状态为{activity.status}, 不能修改。", html_display)
-
- elif option == "apply":
- try:
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=int(aid))
- if activity.status != Activity.Status.APPLYING:
- return redirect(message_url(wrong('活动不在报名状态!'), request.path))
- apply_activity(request, activity)
- if activity.bidding:
- succeed(f"活动申请中,请等待报名结果。", html_display)
- else:
- succeed(f"报名成功。", html_display)
- except ActivityException as e:
- wrong(str(e), html_display)
- except Exception as e:
- raise
-
- elif option == "quit":
- try:
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- if activity.status not in [
- Activity.Status.APPLYING,
- Activity.Status.WAITING,
- ]:
- return redirect(message_url(wrong('当前状态不允许取消报名!'), request.path))
- withdraw_activity(request, activity)
- if activity.bidding:
- html_display["warn_message"] = f"已取消申请。"
- else:
- html_display["warn_message"] = f"已取消报名。"
- html_display["warn_code"] = 2
-
- except ActivityException as e:
- html_display["warn_code"] = 1
- html_display["warn_message"] = str(e)
- except Exception as e:
- raise
-
- elif option == "checkinoffline":
- # 进入调整签到界面
- if activity.status != Activity.Status.END:
- return redirect(message_url(wrong('活动尚未结束!'), request.path))
- if not ownership:
- return redirect(message_url(wrong('您没有调整签到信息的权限!'), request.path))
- return redirect(f"/offlineCheckinActivity/{aid}")
-
- elif option == "sign" or option == "enroll": # 下载活动签到信息或者报名信息
- if not ownership:
- return redirect(message_url(wrong('没有下载权限!'), request.path))
- return utils.export_activity(activity, option)
- elif option == "cancelInformShare":
- me.inform_share = False
- me.save()
- return redirect("/welcome/")
- elif option == "ActivitySummary":
- try:
- re = ActivitySummary.objects.get(activity=activity,
- status__in=[ActivitySummary.Status.WAITING, ActivitySummary.Status.CONFIRMED])
- return redirect(f"/modifyEndActivity/?apply_id={re.id}")
- except:
- return redirect(f"/modifyEndActivity/")
- else:
- return redirect(message_url(wrong('无效的请求!'), request.path))
-
- elif request.method == "GET":
- my_messages.transfer_message_context(request.GET, html_display)
-
- # 下面这些都是展示前端页面要用的
- title = activity.title
- org_name = org.oname
- org_avatar_path = org.get_user_ava()
- org_type = OrganizationType.objects.get(otype_id=org.otype_id).otype_name
- start_month = activity.start.month
- start_date = activity.start.day
- duration = activity.end - activity.start
- duration = duration - timedelta(microseconds=duration.microseconds)
- prepare_times = Activity.EndBeforeHours.prepare_times
- apply_deadline = activity.apply_end.strftime("%Y-%m-%d %H:%M")
- introduction = activity.introduction
- show_url = True # 前端使用量
- aURL = activity.URL
- if (aURL is None) or (aURL == ""):
- show_url = False
- bidding = activity.bidding
- current_participants = activity.current_participants
- status = activity.status
- capacity = activity.capacity
- if capacity == -1 or capacity == 10000:
- capacity = "INF"
- if activity.bidding:
- apply_manner = "抽签模式"
- else:
- apply_manner = "先到先得"
- # person 表示是否是个人而非小组
- person = False
- if request.user.is_person():
- """
- 老师能否报名活动?
- if me.identity == NaturalPerson.Identity.STUDENT:
- person = True
- """
- person = True
- try:
- participant = Participation.objects.get(
- SQ.sq(Participation.activity, activity),
- SQ.sq(Participation.person, me))
- # pStatus 是参与状态
- pStatus = participant.status
- except:
- pStatus = "未参与"
- if pStatus == "放弃":
- pStatus = "未参与"
-
- # 签到
- need_checkin = activity.need_checkin
- show_QRcode = activity.need_checkin and activity.status in [
- Activity.Status.APPLYING,
- Activity.Status.WAITING,
- Activity.Status.PROGRESSING
- ]
-
- if activity.inner and request.user.is_person():
- position = Position.objects.activated().filter(
- person=me, org=activity.organization_id)
- if len(position) == 0:
- not_inner = True
-
- if ownership and need_checkin:
- aQRcode = get_activity_QRcode(activity)
-
- # 活动宣传图片 ( 一定存在 )
- photo: ActivityPhoto = activity.photos.get(
- type=ActivityPhoto.PhotoType.ANNOUNCE)
- # 不是static静态文件夹里的文件,而是上传到media/activity的图片
- firstpic = photo.get_image_path()
-
- # 总结图片,不一定存在
- summary_photo_exists = False
- if activity.status == Activity.Status.END:
- try:
- summary_photos = activity.photos.filter(
- type=ActivityPhoto.PhotoType.SUMMARY)
- summary_photo_exists = True
- except Exception as e:
- pass
-
- # 参与者, 无论报名是否通过
- participants = Participation.objects.filter(
- SQ.sq(Participation.activity, activity),
- status__in=[
- Participation.AttendStatus.APPLYING,
- Participation.AttendStatus.APPLYSUCCESS,
- Participation.AttendStatus.ATTENDED,
- Participation.AttendStatus.UNATTENDED,
- ])
- people_list = NaturalPerson.objects.activated().filter(
- id__in=SQ.qsvlist(participants, Participation.person))
-
- # 新版侧边栏,顶栏等的呈现,采用bar_display,必须放在render前最后一步,但这里render太多了
- # TODO: 整理好代码结构,在最后统一返回
- bar_display = utils.get_sidebar_and_navbar(
- request.user, navbar_name="活动信息", title_name=title)
- # 补充一些呈现信息
- # bar_display["title_name"] = "活动信息"
- # bar_display["navbar_name"] = "活动信息"
-
- # 浏览次数,必须在render之前
- # 为了防止发生错误的存储,让数据库直接更新浏览次数,并且不再显示包含本次浏览的数据
- Activity.objects.filter(id=activity.id).update(
- visit_times=F('visit_times')+1)
- # activity.visit_times += 1
- # activity.save()
- return render(request, "activity/info.html", locals())
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def getActivityInfo(request: HttpRequest):
- '''
- 通过GET获得活动信息表下载链接
- GET参数?activityid=id&infotype=sign[&output=id,name,gender,telephone][&format=csv|excel]
- GET参数?activityid=id&infotype=qrcode
- activity_id : 活动id
- infotype : sign or qrcode or 其他(以后可以拓展)
- sign报名信息:
- output : [可选]','分隔的需要返回的的field名
- [默认]id,name,gender,telephone
- format : [可选]csv or excel
- [默认]csv
- qrcode签到二维码
- example: http://127.0.0.1:8000/getActivityInfo?activityid=1&infotype=sign
- example: http://127.0.0.1:8000/getActivityInfo?activityid=1&infotype=sign&output=id,wtf
- example: http://127.0.0.1:8000/getActivityInfo?activityid=1&infotype=sign&format=excel
- example: http://127.0.0.1:8000/getActivityInfo?activityid=1&infotype=qrcode
- TODO: 前端页面待对接
- '''
-
- # check activity existence
- activity_id = request.GET.get("activityid", None)
- activity = Activity.objects.get(id=activity_id)
-
- # check organization existance and ownership to activity
- organization = get_person_or_org(request.user, UTYPE_ORG)
- assert activity.organization_id == organization, f"{organization}不是活动的组织者"
-
- info_type = request.GET.get("infotype", None)
- assert info_type in ["sign", "qrcode"], "不支持的infotype"
-
- info_type: Literal["sign", "qrcode"]
-
- if info_type == "sign": # get registration information
- # make sure registration is over
- assert activity.status != Activity.Status.REVIEWING, "活动正在审核"
- assert activity.status != Activity.Status.CANCELED, "活动已取消"
- assert activity.status != Activity.Status.APPLYING, "报名尚未截止"
-
- # get participants
- participants = SQ.sfilter(Participation.activity, activity).filter(
- status=Participation.AttendStatus.APPLYSUCCESS
- )
-
- # get required fields
- output = request.GET.get("output", "id,name,gender,telephone")
- fields = output.split(",")
-
- # check field existence
- allowed_fields = ["id", "name", "gender", "telephone"]
- for field in fields:
- assert field in allowed_fields, f"不允许的字段名{field}"
-
- filename = f"{activity_id}-{info_type}-{output}"
- content = map(
- lambda paticipant: map(lambda key: paticipant[key], fields),
- participants,
- )
-
- format = request.GET.get("format", "csv")
- assert format in ["csv"], f"不支持的格式{format}"
- format: Literal["csv"]
- if format == "csv":
- buffer = io.StringIO()
- csv.writer(buffer).writerows(content), buffer.seek(0)
- response = HttpResponse(buffer, content_type="text/csv")
- response["Content-Disposition"] = f"attachment; filename={filename}.csv"
- return response # downloadable
-
- elif info_type == "qrcode":
- # checkin begins 1 hour ahead
- assert datetime.now() > activity.start - timedelta(hours=1), "签到未开始"
- checkin_url = f"/checkinActivity?activityid={activity.id}"
- origin_url = request.scheme + "://" + request.META["HTTP_HOST"]
- checkin_url = urllib.parse.urljoin(
- origin_url, checkin_url) # require full path
-
- buffer = io.BytesIO()
- qr = qrcode.QRCode(version=1, box_size=10, border=5)
- qr.add_data(checkin_url), qr.make(fit=True)
- img = qr.make_image(fill_color="black", back_color="white")
- img.save(buffer, "jpeg"), buffer.seek(0)
- return HttpResponse(buffer, content_type="img/jpeg")
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def checkinActivity(request: UserRequest, aid=None):
- if not request.user.is_person():
- return redirect(message_url(wrong('签到失败:请使用个人账号签到')))
- try:
- np = get_person_or_org(request.user)
- aid = int(aid)
- activity = Activity.objects.get(id=aid)
- varifier = request.GET["auth"]
- except:
- return redirect(message_url(wrong('签到失败!')))
- if varifier != GLOBAL_CONFIG.hasher.encode(str(aid)):
- return redirect(message_url(wrong('签到失败:活动校验码不匹配')))
-
- # context = wrong('发生意外错误') # 理应在任何情况都生成context, 如果没有就让包装器捕获吧
- if activity.status == Activity.Status.END:
- context = wrong("活动已结束,不再开放签到。")
- elif (
- activity.status == Activity.Status.PROGRESSING or
- (activity.status == Activity.Status.WAITING
- and datetime.now() + timedelta(hours=1) >= activity.start)
- ):
- try:
- with transaction.atomic():
- participant = Participation.objects.select_for_update().get(
- SQ.sq(Participation.activity, activity),
- SQ.sq(Participation.person, np),
- status__in=[
- Participation.AttendStatus.UNATTENDED,
- Participation.AttendStatus.APPLYSUCCESS,
- Participation.AttendStatus.ATTENDED,
- ]
- )
- if participant.status == Participation.AttendStatus.ATTENDED:
- context = succeed("您已签到,无需重复签到!")
- else:
- participant.status = Participation.AttendStatus.ATTENDED
- participant.save()
- context = succeed("签到成功!")
- except:
- context = wrong("您尚未报名该活动!")
-
- else:
- context = wrong("活动开始前一小时开放签到,请耐心等待!")
-
- # TODO 在 activity_info 里加更多信息
- return redirect(message_url(context, f"/viewActivity/{aid}"))
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def addActivity(request: UserRequest, aid=None):
- """
- 发起活动与修改活动页
- ---------------
- 页面逻辑:
-
- 该函数处理 GET, POST 两种请求,发起和修改两类操作
- 1. 访问 /addActivity/ 时,为创建操作,要求用户是小组;
- 2. 访问 /editActivity/aid 时,为编辑操作,要求用户是该活动的发起者
- 3. GET 请求创建活动的界面,placeholder 为 prompt
- 4. GET 请求编辑活动的界面,表单的 placeholder 会被修改为活动的旧值。
- """
- # TODO 定时任务
-
- # 检查:不是超级用户,必须是小组,修改是必须是自己
- html_display = {}
- # assert valid 已经在check_user_access检查过了
- me = get_person_or_org(request.user) # 这里的me应该为小组账户
- if aid is None:
- if not request.user.is_org():
- return redirect(message_url(wrong('小组账号才能添加活动!')))
- if me.oname == CONFIG.yqpoint.org_name:
- return redirect("/showActivity")
- edit = False
- else:
- aid = int(aid)
- activity = Activity.objects.get(id=aid)
- if request.user.is_person():
- # 自动更新request.user
- html_display = utils.user_login_org(
- request, activity.organization_id)
- if html_display['warn_code'] == 1:
- return redirect(message_url(html_display))
- else: # 成功以小组账号登陆
- me = activity.organization_id
- if activity.organization_id != me:
- return redirect(message_url(wrong("无法修改其他小组的活动!")))
- edit = True
-
- # 处理 POST 请求
- # 在这个界面,不会返回render,而是直接跳转到viewactivity,可以不设计bar_display
- if request.method == "POST" and request.POST:
-
- if not edit:
- with transaction.atomic():
- aid, created = create_activity(request)
- if not created:
- return redirect(message_url(
- succeed('存在信息相同的活动,已为您自动跳转!'),
- f'/viewActivity/{aid}'))
- return redirect(f"/editActivity/{aid}")
-
- # 仅这几个阶段可以修改
- if (
- activity.status != Activity.Status.REVIEWING and
- activity.status != Activity.Status.APPLYING and
- activity.status != Activity.Status.WAITING
- ):
- return redirect(message_url(wrong('当前活动状态不允许修改!'),
- f'/viewActivity/{activity.id}'))
-
- # 处理 comment
- if request.POST.get("comment_submit"):
- # 创建活动只能在审核时添加评论
- assert not activity.valid
- context = addComment(
- request, activity, activity.examine_teacher.person_id)
- # 评论内容不为空,上传文件类型为图片会在前端检查,这里有错直接跳转
- assert context["warn_code"] == 2, context["warn_message"]
- # 成功后重新加载界面
- succeed("评论成功。", html_display)
- # return redirect(f"/editActivity/{aid}")
- else:
- try:
- # 只能修改自己的活动
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- org = get_person_or_org(request.user)
- assert activity.organization_id == org
- modify_activity(request, activity)
- succeed("修改成功。", html_display)
- except ActivityException as e:
- wrong(str(e), html_display)
-
- # 下面的操作基本如无特殊说明,都是准备前端使用量
- defaultpics = [{"src": f"/static/assets/img/announcepics/{i+1}.JPG",
- "id": f"picture{i+1}"} for i in range(5)]
- html_display["applicant_name"] = me.oname
- html_display["app_avatar_path"] = me.get_user_ava()
-
- use_template = False
- if request.method == "GET" and request.GET.get("template"):
- use_template = True
- template_id = int(request.GET["template"])
- activity = Activity.objects.get(id=template_id)
- if not edit and not use_template:
- available_teachers = NaturalPerson.objects.teachers()
- else:
- org = get_person_or_org(request.user)
-
- # 没过审,可以编辑评论区
- if not activity.valid:
- commentable = True
- front_check = True
- if use_template:
- commentable = False
- # 全可编辑
- full_editable = False
- accepted = False
- if activity.status == Activity.Status.REVIEWING:
- full_editable = True
- accepted = True
- # 部分可编辑
- # 活动只能在开始 1 小时前修改
- elif (
- activity.status == Activity.Status.APPLYING
- or activity.status == Activity.Status.WAITING
- ) and datetime.now() + timedelta(hours=1) < activity.start:
- accepted = True
- else:
- # 不是三个可以评论的状态
- commentable = front_check = False
-
- # 决定状态的变量
- # None/edit/examine ( 小组申请活动/小组编辑/老师审查 )
- # full_editable/accepted/None ( 小组编辑活动:除审查老师外全可修改/部分可修改/全部不可改 )
- # full_editable 为 true 时,accepted 也为 true
- # commentable ( 是否可以评论 )
-
- # 下面是前端展示的变量
-
- title = utils.escape_for_templates(activity.title)
- location = utils.escape_for_templates(activity.location)
- apply_end = activity.apply_end.strftime("%Y-%m-%d %H:%M")
- # apply_end_for_js = activity.apply_end.strftime("%Y-%m-%d %H:%M")
- start = activity.start.strftime("%Y-%m-%d %H:%M")
- end = activity.end.strftime("%Y-%m-%d %H:%M")
- introduction = escape_for_templates(activity.introduction)
- url = utils.escape_for_templates(activity.URL)
-
- endbefore = activity.endbefore
- bidding = activity.bidding
- signscheme = "先到先得"
- if bidding:
- signscheme = "抽签模式"
- capacity = activity.capacity
- no_limit = False
- if capacity == 10000:
- no_limit = True
- examine_teacher = activity.examine_teacher.name
- status = activity.status
- available_teachers = NaturalPerson.objects.teachers()
- need_checkin = activity.need_checkin
- inner = activity.inner
- if not use_template:
- comments = showComment(activity)
- photo = str(activity.photos.get(
- type=ActivityPhoto.PhotoType.ANNOUNCE).image)
- uploaded_photo = False
- if str(photo).startswith("activity"):
- uploaded_photo = True
- photo_path = photo
- photo = os.path.basename(photo)
- else:
- photo_id = "picture" + os.path.basename(photo).split(".")[0]
-
- html_display["today"] = datetime.now().strftime("%Y-%m-%d")
- if not edit:
- bar_display = utils.get_sidebar_and_navbar(request.user, "活动发起")
- else:
- bar_display = utils.get_sidebar_and_navbar(request.user, "修改活动")
-
- return render(request, "activity/application.html", locals())
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def activityCenter(request: UserRequest):
- """
- 活动信息的聚合界面
- 只有老师和小组才能看到,老师看到检查者是自己的,小组看到发起方是自己的
- """
- me = get_person_or_org(request.user) # 获取自身
- is_teacher = False # 该变量同时用于前端
- if request.user.is_person():
- is_teacher = me.is_teacher()
- if not is_teacher:
- return redirect(message_url(wrong('学生账号不能进入活动立项页面!')))
- if is_teacher:
- all_instances = {
- "undone": Activity.objects.activated(
- only_displayable=False).filter(examine_teacher=me.id, valid=False),
- "done": Activity.objects.activated(
- only_displayable=False).filter(examine_teacher=me.id, valid=True)
- }
- else:
- all_instances = {
- "undone": Activity.objects.activated(
- only_displayable=False).filter(organization_id=me.id, valid=False),
- "done": Activity.objects.activated(
- only_displayable=False).filter(organization_id=me.id, valid=True)
- }
-
- all_instances = {key: value.order_by(
- "-modify_time", "-time") for key, value in all_instances.items()}
- bar_display = utils.get_sidebar_and_navbar(request.user, "活动立项")
-
- # 前端不允许元气值中心创建活动
- if request.user.is_org() and me.oname == CONFIG.yqpoint.org_name:
- YQPoint_Source_Org = True
-
- return render(request, "activity/center.html", locals() | dict(user=request.user))
-
-
-@login_required(redirect_field_name="origin")
-@logger.secure_view()
-def examineActivity(request: UserRequest, aid: int | str):
- try:
- assert request.user.is_valid()
- assert request.user.is_person()
- me = get_person_or_org(request.user)
- activity = Activity.objects.get(id=int(aid))
- assert activity.examine_teacher == me
- except:
- return redirect(message_url(wrong('没有审核权限!')))
-
- html_display = {}
-
- if request.method == "POST" and request.POST:
-
- if (
- activity.status != Activity.Status.REVIEWING and
- activity.status != Activity.Status.APPLYING and
- activity.status != Activity.Status.WAITING
- ):
- return redirect(message_url(wrong('当前活动状态不可审核!')))
- if activity.valid:
- return redirect(message_url(succeed('活动已审核!')))
-
- if request.POST.get("comment_submit"):
- context = addComment(
- request, activity, activity.organization_id.get_user())
- # 评论内容不为空,上传文件类型为图片会在前端检查,这里有错直接跳转
- assert context["warn_code"] == 2
- succeed("评论成功。", html_display)
-
- elif request.POST.get("review_accepted"):
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(
- id=int(aid)
- )
- accept_activity(request, activity)
- succeed("活动已通过审核。", html_display)
- else:
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(
- id=int(aid)
- )
- reject_activity(request, activity)
- succeed("活动已被拒绝。", html_display)
-
- # 状态量,无可编辑量
- examine = True
- commentable = not activity.valid
- if (
- activity.status != Activity.Status.REVIEWING and
- activity.status != Activity.Status.APPLYING and
- activity.status != Activity.Status.WAITING
- ):
- commentable = False
-
- # 展示变量
- title = utils.escape_for_templates(activity.title)
- location = utils.escape_for_templates(activity.location)
- apply_end = activity.apply_end.strftime("%Y-%m-%d %H:%M")
- start = activity.start.strftime("%Y-%m-%d %H:%M")
- end = activity.end.strftime("%Y-%m-%d %H:%M")
- introduction = escape_for_templates(activity.introduction)
-
- url = utils.escape_for_templates(activity.URL)
- endbefore = activity.endbefore
- bidding = activity.bidding
- signscheme = "先到先得"
- if bidding:
- signscheme = "投点参与"
- capacity = activity.capacity
- no_limit = False
- if capacity == 10000:
- no_limit = True
- examine_teacher = activity.examine_teacher.name
- html_display["today"] = datetime.now().strftime("%Y-%m-%d")
- html_display["app_avatar_path"] = activity.organization_id.get_user_ava()
- html_display["applicant_name"] = activity.organization_id.oname
- bar_display = utils.get_sidebar_and_navbar(request.user)
- status = activity.status
- comments = showComment(activity)
-
- examine_pic = activity.photos.get(type=ActivityPhoto.PhotoType.ANNOUNCE)
- # 不是static静态文件夹里的文件,而是上传到media/activity的图片
- if str(examine_pic.image)[0] == 'a':
- examine_pic.image = MEDIA_URL + str(examine_pic.image)
- intro_pic = examine_pic.image
-
- need_checkin = activity.need_checkin
-
- bar_display = utils.get_sidebar_and_navbar(request.user, "活动审核")
- # bar_display["title_name"] = "审查活动"
- # bar_display["narbar_name"] = "审查活动"
- return render(request, "activity/application.html", locals())
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def offlineCheckinActivity(request: HttpRequest, aid):
- '''
- 修改签到记录,只有举办活动的组织账号可查看和修改
-
- :param request: 修改请求
- :type request: HttpRequest
- :param aid: 活动id
- :type aid: int
- :return: 修改签到页面
- :rtype: HttpResponse
- '''
- try:
- me = get_person_or_org(request.user)
- aid = int(aid)
- src = request.GET.get('src')
- activity = Activity.objects.get(id=aid)
- assert me == activity.organization_id and request.user.is_org()
- except:
- return redirect(message_url(wrong('请不要随意访问其他网页!')))
-
- member_participation = Participation.objects.filter(
- SQ.sq(Participation.activity, activity),
- status__in=[
- Participation.AttendStatus.UNATTENDED,
- Participation.AttendStatus.ATTENDED,
- ])
-
- if request.method == "POST" and request.POST:
- option = request.POST.get("option")
- if option == "saveSituation":
- # 修改签到状态
-
- member_userids = SQ.qsvlist(member_participation, Participation.person)
- attend_pids, unattend_pids = [], []
- for pid in member_userids:
- checkin = request.POST.get(f"checkin_{pid}")
- if checkin == "yes":
- attend_pids.append(pid)
- elif checkin == "no":
- unattend_pids.append(pid)
- with transaction.atomic():
- member_participation.select_for_update().filter(
- SQ.mq(Participation.person, IN=attend_pids)).update(
- status=Participation.AttendStatus.ATTENDED)
- member_participation.select_for_update().filter(
- SQ.mq(Participation.person, IN=unattend_pids)).update(
- status=Participation.AttendStatus.UNATTENDED)
- # 修改成功之后根据src的不同返回不同的界面,1代表聚合页面,2代表活动主页
- if src == "course_center":
- return redirect(message_url(
- succeed("修改签到信息成功。"), f"/showCourseActivity/"))
- else:
- return redirect(message_url(
- succeed("修改签到信息成功。"), f"/viewActivity/{aid}"))
-
- bar_display = utils.get_sidebar_and_navbar(request.user,
- navbar_name="调整签到信息")
- member_participation = member_participation.select_related(
- SQ.f(Participation.person))
- render_context = dict(bar_display=bar_display, member_list=member_participation)
- return render(request, "activity/modify_checkin.html", render_context)
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def finishedActivityCenter(request: HttpRequest):
- """
- 之前被用为报销信息的聚合界面,现已将报销删去,留下总结图片的功能
- 对审核老师进行了特判
- """
- is_auditor = False
- if request.user.is_person():
- try:
- person = get_person_or_org(request.user)
- is_auditor = person.is_teacher()
- assert is_auditor
- except:
- return redirect(message_url(wrong("请不要使用个人账号申请活动结项!")))
-
- if is_auditor:
- all_instances = {
- "undone": ActivitySummary.objects.filter(
- activity__examine_teacher=person,
- status=ActivitySummary.Status.WAITING).order_by("-time"),
- "done": ActivitySummary.objects.filter(
- activity__examine_teacher=person).exclude(
- status=ActivitySummary.Status.WAITING).order_by("-time")
- }
-
- else:
- all_instances = {
- "undone": ActivitySummary.objects.filter(
- activity__organization_id__organization_id=request.user,
- status=ActivitySummary.Status.WAITING).order_by("-time"),
- "done": ActivitySummary.objects.filter(
- activity__organization_id__organization_id=request.user
- ).exclude(status=ActivitySummary.Status.WAITING).order_by("-time")
- }
-
- # 判断是否有权限进行每周活动总结
- weekly_summary_active = (request.user.is_org() and
- get_person_or_org(request.user) in weekly_summary_orgs())
-
- # 前端使用
- context = dict(
- bar_display=utils.get_sidebar_and_navbar(request.user, "活动结项"),
- all_instances=all_instances,
- user=request.user,
- weekly_summary_active=weekly_summary_active,
- )
- return render(request, "activity/finished_center.html", context)
-
-
-# 新建+修改+取消+审核 报销信息
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def activitySummary(request: UserRequest):
- html_display = {}
-
- # ———————————————— 读取可能存在的申请 为POST和GET做准备 ————————————————
-
- # 设置application为None, 如果非None则自动覆盖
- application = None
- # 根据是否有newid来判断是否是第一次
- apply_id = request.GET.get("apply_id", None)
- # 获取前端页面中可能存在的提示
- my_messages.transfer_message_context(request.GET, html_display)
-
- if apply_id is not None: # 如果存在对应申请
- try: # 尝试获取已经新建的apply
- application: ActivitySummary = ActivitySummary.objects.get(
- id=apply_id)
- auditor = application.activity.examine_teacher.get_user() # 审核老师
- if request.user.is_person() and auditor != request.user:
- html_display = utils.user_login_org(
- request, application.get_org())
- if html_display['warn_code'] == 1:
- return redirect(message_url(html_display))
-
- # 接下来检查是否有权限check这个条目
- # 至少应该是申请人或者被审核老师之一
- assert (application.get_org().get_user()
- == request.user) or (auditor == request.user)
- except: # 恶意跳转
- return redirect(message_url(wrong("您没有权限访问该网址!")))
-
- is_new_application = False # 前端使用量, 表示是老申请还是新的
-
- else: # 如果不存在id, 默认应该传入活动信息
- # 只有小组才有可能申请
- if not request.user.is_org():
- return redirect(message_url(wrong("您没有权限访问该网址!")))
-
- is_new_application = True # 新的申请
-
- me = get_person_or_org(request.user) # 获取自身,便于之后查询
-
- # 这种写法是为了方便随时取消某个条件
- '''
- 至此,如果是新申请那么application为None,否则为对应申请
- application = None只有在小组新建申请的时候才可能出现,对应位is_new_application为True
- 接下来POST
- '''
-
- if request.user.is_org():
- # 未总结活动
- summary_act_ids = (
- ActivitySummary.objects.all().exclude(
- status=ActivitySummary.Status.CANCELED) # 未被取消的
- .exclude(status=ActivitySummary.Status.REFUSED) # 未被拒绝的
- .values_list("activity__id", flat=True))
- # 可以新建申请的活动
- activities = (
- Activity.objects.activated() # 本学期的
- .filter(organization_id=me) # 本部门小组的
- .filter(status=Activity.Status.END) # 已结束的
- .exclude(id__in=summary_act_ids)) # 还没有报销的
- else:
- activities = None
-
- # ———————— Post操作,分为申请变更以及添加评论 ————————
-
- if request.method == "POST" and request.POST.get("post_type") is not None:
- # 首先确定申请状态
- post_type = request.POST.get("post_type")
- feasible_post = [
- "new_submit", "modify_submit", "cancel_submit",
- "accept_submit", "refuse_submit"
- ]
- if post_type not in feasible_post:
- return redirect(message_url(wrong('申请状态异常!')))
-
- # 接下来确定访问的个人/小组是不是在做分内的事情
- if (request.user.is_person() and feasible_post.index(post_type) <= 2
- ) or (request.user.is_org()
- and feasible_post.index(post_type) >= 3):
- return redirect(message_url(wrong('您无权进行此操作,如有疑惑, 请联系管理员')))
-
- if (post_type != "new_submit") and not application.is_pending():
- return redirect(message_url(wrong("不可以修改状态不为申请中的申请")))
-
- full_path = request.get_full_path()
- if post_type == "new_submit":
- # 检查活动
- try:
- act_id = int(request.POST.get('activity_id'))
- activity = Activity.objects.get(id=act_id)
- assert activity in activities # 防止篡改POST导致伪造
- except:
- return redirect(message_url(wrong('找不到该活动,请检查活动总结的合法性!')))
- # 活动总结图片合法性检查
- summary_photos = request.FILES.getlist('summaryimages')
- photo_num = len(summary_photos)
- if photo_num != 1:
- return redirect(message_url(wrong('图片内容为空或有多张图片!'), full_path))
- for image in summary_photos:
- if utils.if_image(image) != 2:
- return redirect(message_url(wrong("上传的总结图片只支持图片格式!"), full_path))
- # 新建activity summary
- application: ActivitySummary = ActivitySummary.objects.create(
- status=ActivitySummary.Status.WAITING,
- activity=activity,
- image=summary_photos[0]
- )
- context = succeed(
- f'活动“{application.activity.title}”的申请已成功发送,' +
- f'请耐心等待{application.activity.examine_teacher.name}老师审批!'
- )
-
- elif post_type == "modify_submit":
- summary_photos = request.FILES.getlist('summaryimages')
- now_participant_uids = request.POST.getlist('students')
- photo_num = len(summary_photos)
- if photo_num > 1:
- return redirect(message_url(wrong('有多张图片!'), full_path))
- for image in summary_photos:
- if utils.if_image(image) != 2:
- return redirect(message_url(wrong("上传的总结图片只支持图片格式!"), full_path))
- if len(now_participant_uids) == 0:
- return redirect(message_url(wrong('参与人员不能为空'), full_path))
- available_uids = SQ.qsvlist(available_participants(), User.username)
- if set(now_participant_uids) - set(available_uids):
- return redirect(message_url(wrong('参与人员不合法'), full_path))
-
- with transaction.atomic():
- # 修改活动总结图片
- if photo_num > 0:
- application.image = summary_photos[0]
- application.save()
- # 修改参与人员
- modify_participants(application.activity, now_participant_uids)
- context = succeed(
- f'活动“{application.activity.title}”的申请已成功修改,' +
- f'请耐心等待{application.activity.examine_teacher.name}老师审批!'
- )
-
- elif post_type == "cancel_submit":
- if not application.is_pending(): # 如果不在pending状态, 可能是重复点击
- return redirect(message_url(wrong("该申请已经完成或被取消")))
- application.status = ActivitySummary.Status.CANCELED
- application.save()
- context = succeed(f"成功取消“{application.activity.title}”的活动总结申请!")
-
- else:
- if not application.is_pending():
- return redirect(message_url(wrong("无法操作, 该申请已经完成或被取消!")))
-
- if post_type == "refuse_submit":
- # 修改申请状态
- application.status = ActivitySummary.Status.REFUSED
- application.save()
- context = succeed(
- f'已成功拒绝活动“{application.activity.title}”的活动总结申请!')
- elif post_type == "accept_submit":
- # 修改申请的状态
- application.status = ActivitySummary.Status.CONFIRMED
- old_image = application.image
- if not old_image is None:
- ActivityPhoto.objects.create(
- image=old_image,
- activity=application.activity,
- time=datetime.now(),
- type=ActivityPhoto.PhotoType.SUMMARY)
- application.save()
- context = succeed(f'活动“{application.activity.title}”的总结申请已通过!')
-
- # 为了保证稳定性,完成POST操作后同意全体回调函数,进入GET状态
- if application is None:
- return redirect(message_url(context, '/modifyEndActivity/'))
- else:
- return redirect(message_url(context, f'/modifyEndActivity/?apply_id={application.id}'))
-
- # ———————— 完成Post操作, 接下来开始准备前端呈现 ————————
- '''
- 小组:可能是新建、修改申请
- 老师:可能是审核申请
- '''
-
- render_context = dict()
- render_context.update(application=application, activities=activities,
- is_new_application=is_new_application)
- # (1) 是否允许修改表单
- # 小组写表格?
- allow_form_edit = (request.user.is_org()
- and (is_new_application or application.is_pending()))
-
- # 老师审核?
- allow_audit_submit = (request.user.is_person()
- and not is_new_application
- and application.is_pending())
-
- # 用于前端展示:如果是新申请,申请人即“me”,否则从application获取。
- render_context.update(
- allow_form_edit=allow_form_edit,
- allow_audit_submit=allow_audit_submit,
- applicant=me if is_new_application else application.get_org(),
- )
-
- # 活动总结图片
- if application is not None:
- render_context.update(summary_photo=application.image)
-
- # 所有人员和参与人员
- # 用于前端展示,把js数据都放在这里
- json_context = dict(user_infos=to_search_indices(available_participants()))
- if application is not None:
- participation = SQ.sfilter(Participation.activity, application.activity)
- json_context.update(participant_ids=SQ.qsvlist(
- participation.filter(status=Participation.AttendStatus.ATTENDED),
- Participation.person, NaturalPerson.person_id, User.username
- ))
- render_context.update(json_context=json_context)
- bar_display = utils.get_sidebar_and_navbar(request.user, '活动总结详情')
- render_context.update(bar_display=bar_display, html_display=html_display)
- return render(request, "activity/summary_application.html", render_context)
-
-
-class WeeklyActivitySummary(ProfileTemplateView):
-
- template_name = "activity/weekly_summary.html"
- page_name = "每周活动总结"
-
- def prepare_get(self):
- return self.get
-
- def prepare_post(self):
- self.context = {
- "bidding": False,
- "inner": False,
- "need_checkin": False,
- "recorded": True,
- "valid": True,
- "unlimited_capacity": True,
- "signscheme": 0,
- "maxpeople": 10000,
- "prepare_scheme": 1,
- "URL": "",
- "announce_pic_src": "/static/assets/img/announcepics/1.JPG",
- # Summary do not need an auditor, so we set it to arbitrary value
- "examine_teacher": NaturalPerson.objects.teachers().first()
- }
- summary_photos = self.request.FILES.getlist('summaryimages')
-
- # 检查总结图片合法性
- photo_num = len(summary_photos)
- if photo_num == 1:
- for image in summary_photos:
- if utils.if_image(image) != 2:
- return redirect(
- message_url(wrong("上传的总结图片只支持图片格式!")))
- else:
- return redirect(message_url(wrong('图片内容为空或有多张图片!'), self.request.path))
- self.context['summary_pic'] = summary_photos[0]
- return self.post
-
- def get(self):
- html_display = {}
- me = utils.get_person_or_org(self.request.user)
- if not self.request.user.is_org():
- return redirect(message_url(wrong('小组账号才能发起每周活动总结')))
- valid_orgs = weekly_summary_orgs()
- if not me in valid_orgs:
- return redirect(message_url(wrong('您没有权限发起每周活动总结')))
-
- # 准备前端展示量
- html_display["applicant_name"] = me.oname
- html_display["app_avatar_path"] = me.get_user_ava()
- html_display["today"] = datetime.now().strftime("%Y-%m-%d")
- bar_display = utils.get_sidebar_and_navbar(self.request.user, "活动发起")
- person_list = NaturalPerson.objects.activated()
- user_id_list = [person.person_id.id for person in person_list]
- user_queryset = User.objects.filter(id__in=user_id_list)
- js_stu_list = to_search_indices(user_queryset)
-
- self.extra_context.update({
- 'html_display': html_display,
- 'bar_display': bar_display,
- 'js_stu_list': js_stu_list,
- })
- return self.render()
-
- def post(self):
- self.weekly_summary_base_check()
- aid, created = self.create_weekly_summary()
- if not created:
- return redirect(message_url(
- succeed('存在信息相同的活动,已为您自动跳转!'),
- f'/viewActivity/{aid}'))
- return redirect(f"/editActivity/{aid}")
-
- def check_summary_time(self, start_time: datetime, end_time: datetime) -> bool:
- '''由每周活动总结新建的活动,检查开始时间早于结束时间'''
- now_time = datetime.now()
- if start_time < end_time <= now_time:
- return True
- return False
-
- def weekly_summary_base_check(self):
- '''
- 从request.POST中获取活动信息并检查合法性
- 正常情况下检查出错误会抛出不含错误信息的AssertionError,不抛出ActivityException
- '''
- for k in ['title', 'introduction', 'location']:
- v = self.request.POST.get(k)
- assert v is not None and v != ""
- self.context[k] = v
-
- # 时间
- act_start = datetime.strptime(
- self.request.POST["actstart"], "%Y-%m-%d %H:%M") # 活动报名时间
- act_end = datetime.strptime(
- self.request.POST["actend"], "%Y-%m-%d %H:%M") # 活动报名结束时间
- self.context["start"] = act_start
- self.context["end"] = act_end
- assert self.check_summary_time(act_start, act_end)
-
- prepare_scheme = int(self.context["prepare_scheme"])
- prepare_times = Activity.EndBeforeHours.prepare_times
- prepare_time = prepare_times[prepare_scheme]
- self.context["endbefore"] = prepare_scheme
- self.context["apply_end"] = act_start - timedelta(hours=prepare_time)
-
- def create_weekly_summary(self) -> tuple[int, bool]:
- '''
- 检查是否存在一致的活动及活动合法性,若通过检查则创建活动及活动总结;
- 返回(activity.id, created);
- 若查询到一致的活动或检查不合格时抛出AssertionError
- '''
-
- # 查找是否有类似活动存在
- old_ones = Activity.objects.activated().filter(
- title=self.context["title"],
- start=self.context["start"],
- introduction=self.context["introduction"],
- location=self.context["location"]
- )
- if len(old_ones):
- assert len(old_ones) == 1, "创建活动时,已存在的相似活动不唯一"
- return old_ones[0].id, False
-
- # 检查完毕,创建活动、活动总结
- org = get_person_or_org(self.request.user, UTYPE_ORG)
- participants_ids = self.request.POST.getlist("students")
- with transaction.atomic():
- # 创建活动、活动宣传图片
- activity = Activity.objects.create(
- title=self.context["title"],
- organization_id=org,
- examine_teacher=self.context["examine_teacher"],
- introduction=self.context["introduction"],
- location=self.context["location"],
- capacity=self.context["maxpeople"],
- URL=self.context["URL"],
- start=self.context["start"],
- end=self.context["end"],
- bidding=self.context["bidding"],
- apply_end=self.context["apply_end"],
- inner=self.context["inner"],
- endbefore=self.context["endbefore"],
- need_checkin=self.context["need_checkin"],
- recorded=self.context["recorded"],
- valid=self.context["valid"], # 默认已审核
- status=Activity.Status.END,
- )
- ActivityPhoto.objects.create(
- image=self.context["announce_pic_src"], type=ActivityPhoto.PhotoType.ANNOUNCE, activity=activity)
-
- # 创建参与人
- nps = SQ.mfilter(NaturalPerson.person_id, User.username, IN=participants_ids)
- status = Participation.AttendStatus.ATTENDED
- create_participate_infos(activity, nps, status=status)
- activity.current_participants = len(participants_ids)
- activity.settle_yqpoint()
- activity.save()
-
- # 创建活动总结
- application: ActivitySummary = ActivitySummary.objects.create(
- activity=activity,
- status=ActivitySummary.Status.WAITING,
- image=self.context["summary_pic"]
- )
- application.save()
-
- return activity.id, True
diff --git a/app/admin.py b/app/admin.py
index 1de136d0c..5421f4b2e 100644
--- a/app/admin.py
+++ b/app/admin.py
@@ -9,7 +9,6 @@
from utils.admin_utils import *
from app.models import *
from scheduler.cancel import remove_job
-from app.YQPoint_utils import run_lottery
from app.org_utils import accept_modifyorg_submit
# 通用内联模型
@@ -25,22 +24,6 @@ class PositionInline(admin.TabularInline):
]
show_change_link = True
-@readonly_inline
-class ParticipationInline(admin.TabularInline):
- model = Participation
- classes = ['collapse']
- ordering = ['-' + f(model.activity)]
- fields = [f(model.activity), f(model.person), f(model.status)]
- show_change_link = True
-
-@readonly_inline
-class CourseParticipantInline(admin.TabularInline):
- model = CourseParticipant
- classes = ['collapse']
- ordering = ['-id']
- fields = ['course', 'person', 'status']
- show_change_link = True
-
# 后台模型
@admin.register(NaturalPerson)
@@ -59,7 +42,7 @@ class NaturalPersonAdmin(admin.ModelAdmin):
f(_m.stu_grade), f(_m.stu_class),
]
- inlines = [PositionInline, ParticipationInline, CourseParticipantInline]
+ inlines = [PositionInline]
def _show_by_option(self, obj: NaturalPerson | None, option: str, detail: str):
if obj is None or getattr(obj, option):
@@ -309,267 +292,267 @@ def refresh(self, request, queryset):
return self.message_user(request, f'修改成功!新增职务:{new}')
-@admin.register(Activity)
-class ActivityAdmin(admin.ModelAdmin):
- list_display = ["title", 'id', "organization_id",
- "status", "participant_diaplay",
- "publish_time", "start", "end",]
- search_fields = ('id', "title", "organization_id__oname",
- "current_participants",)
+# @admin.register(Activity)
+# class ActivityAdmin(admin.ModelAdmin):
+# list_display = ["title", 'id', "organization_id",
+# "status", "participant_diaplay",
+# "publish_time", "start", "end",]
+# search_fields = ('id', "title", "organization_id__oname",
+# "current_participants",)
- class ErrorFilter(admin.SimpleListFilter):
- title = '错误状态' # 过滤标题显示为"以 错误状态"
- parameter_name = 'wrong_status' # 过滤器使用的过滤字段
+# class ErrorFilter(admin.SimpleListFilter):
+# title = '错误状态' # 过滤标题显示为"以 错误状态"
+# parameter_name = 'wrong_status' # 过滤器使用的过滤字段
- def lookups(self, request, model_admin):
- '''针对字段值设置过滤器的显示效果'''
- return (
- ('all', '全部错误状态'),
- ('not_waiting', '未进入 等待中 状态'),
- ('not_processing', '未进入 进行中 状态'),
- ('not_end', '未进入 已结束 状态'),
- ('review_end', '已结束的未审核'),
- ('normal', '正常'),
- )
+# def lookups(self, request, model_admin):
+# '''针对字段值设置过滤器的显示效果'''
+# return (
+# ('all', '全部错误状态'),
+# ('not_waiting', '未进入 等待中 状态'),
+# ('not_processing', '未进入 进行中 状态'),
+# ('not_end', '未进入 已结束 状态'),
+# ('review_end', '已结束的未审核'),
+# ('normal', '正常'),
+# )
- def queryset(self, request, queryset):
- '''定义过滤器的过滤动作'''
- now = datetime.now()
- error_id_set = set()
- activate_queryset = queryset.exclude(
- status__in=[
- Activity.Status.REVIEWING,
- Activity.Status.CANCELED,
- Activity.Status.REJECT,
- Activity.Status.ABORT,
- ])
- if self.value() in ['not_waiting', 'all', 'normal']:
- error_id_set.update(activate_queryset.exclude(
- status=Activity.Status.WAITING).filter(
- apply_end__lte=now,
- start__gt=now,
- ).values_list('id', flat=True))
- if self.value() in ['not_processing', 'all', 'normal']:
- error_id_set.update(activate_queryset.exclude(
- status=Activity.Status.PROGRESSING).filter(
- start__lte=now,
- end__gt=now,
- ).values_list('id', flat=True))
- if self.value() in ['not_end', 'all', 'normal']:
- error_id_set.update(activate_queryset.exclude(
- status=Activity.Status.END).filter(
- end__lte=now,
- ).values_list('id', flat=True))
- if self.value() in ['review_end', 'all', 'normal']:
- error_id_set.update(queryset.filter(
- status=Activity.Status.REVIEWING,
- end__lte=now,
- ).values_list('id', flat=True))
-
- if self.value() == 'normal':
- return queryset.exclude(id__in=error_id_set)
- elif self.value() is not None:
- return queryset.filter(id__in=error_id_set)
- return queryset
+# def queryset(self, request, queryset):
+# '''定义过滤器的过滤动作'''
+# now = datetime.now()
+# error_id_set = set()
+# activate_queryset = queryset.exclude(
+# status__in=[
+# Activity.Status.REVIEWING,
+# Activity.Status.CANCELED,
+# Activity.Status.REJECT,
+# Activity.Status.ABORT,
+# ])
+# if self.value() in ['not_waiting', 'all', 'normal']:
+# error_id_set.update(activate_queryset.exclude(
+# status=Activity.Status.WAITING).filter(
+# apply_end__lte=now,
+# start__gt=now,
+# ).values_list('id', flat=True))
+# if self.value() in ['not_processing', 'all', 'normal']:
+# error_id_set.update(activate_queryset.exclude(
+# status=Activity.Status.PROGRESSING).filter(
+# start__lte=now,
+# end__gt=now,
+# ).values_list('id', flat=True))
+# if self.value() in ['not_end', 'all', 'normal']:
+# error_id_set.update(activate_queryset.exclude(
+# status=Activity.Status.END).filter(
+# end__lte=now,
+# ).values_list('id', flat=True))
+# if self.value() in ['review_end', 'all', 'normal']:
+# error_id_set.update(queryset.filter(
+# status=Activity.Status.REVIEWING,
+# end__lte=now,
+# ).values_list('id', flat=True))
+
+# if self.value() == 'normal':
+# return queryset.exclude(id__in=error_id_set)
+# elif self.value() is not None:
+# return queryset.filter(id__in=error_id_set)
+# return queryset
- list_filter = (
- "status",
- 'year', 'semester', 'category',
- "organization_id__otype",
- "inner", "need_checkin", "valid",
- ErrorFilter,
- 'endbefore',
- "publish_time", 'start', 'end',
- )
- date_hierarchy = 'start'
-
- def participant_diaplay(self, obj):
- return f'{obj.current_participants}/{"无限" if obj.capacity == 10000 else obj.capacity}'
- participant_diaplay.short_description = "报名情况"
-
- inlines = [ParticipationInline]
-
- actions = []
-
- @as_action("更新 报名人数", actions, update=True)
- def refresh_count(self, request, queryset: QuerySet[Activity]):
- for activity in queryset:
- activity.current_participants = sfilter(
- Participation.activity, activity).filter(
- status__in=[
- Participation.AttendStatus.ATTENDED,
- Participation.AttendStatus.UNATTENDED,
- Participation.AttendStatus.APPLYSUCCESS,
- ]).count()
- activity.save()
- return self.message_user(request=request, message='修改成功!')
+# list_filter = (
+# "status",
+# 'year', 'semester', 'category',
+# "organization_id__otype",
+# "inner", "need_checkin", "valid",
+# ErrorFilter,
+# 'endbefore',
+# "publish_time", 'start', 'end',
+# )
+# date_hierarchy = 'start'
+
+# def participant_diaplay(self, obj):
+# return f'{obj.current_participants}/{"无限" if obj.capacity == 10000 else obj.capacity}'
+# participant_diaplay.short_description = "报名情况"
+
+# inlines = [ParticipationInline]
+
+# actions = []
+
+# @as_action("更新 报名人数", actions, update=True)
+# def refresh_count(self, request, queryset: QuerySet[Activity]):
+# for activity in queryset:
+# activity.current_participants = sfilter(
+# Participation.activity, activity).filter(
+# status__in=[
+# Participation.AttendStatus.ATTENDED,
+# Participation.AttendStatus.UNATTENDED,
+# Participation.AttendStatus.APPLYSUCCESS,
+# ]).count()
+# activity.save()
+# return self.message_user(request=request, message='修改成功!')
- @as_action('设为 普通活动', actions, update=True)
- def set_normal_category(self, request, queryset):
- queryset.update(category=Activity.ActivityCategory.NORMAL)
- return self.message_user(request=request, message='修改成功!')
-
- @as_action('设为 课程活动', actions, update=True)
- def set_course_category(self, request, queryset):
- queryset.update(category=Activity.ActivityCategory.COURSE)
- return self.message_user(request=request, message='修改成功!')
-
- def _change_status(self, activity, from_status, to_status):
- from app.activity_utils import changeActivityStatus
- changeActivityStatus(activity.id, from_status, to_status)
- if remove_job(f'activity_{activity.id}_{to_status}'):
- return '修改成功, 并移除了定时任务!'
- else:
- return '修改成功!'
-
- @as_action("进入 等待中 状态", actions, single=True)
- def to_waiting(self, request, queryset):
- _from, _to = Activity.Status.APPLYING, Activity.Status.WAITING
- msg = self._change_status(queryset[0], _from, _to)
- return self.message_user(request, msg)
+# @as_action('设为 普通活动', actions, update=True)
+# def set_normal_category(self, request, queryset):
+# queryset.update(category=Activity.ActivityCategory.NORMAL)
+# return self.message_user(request=request, message='修改成功!')
+
+# @as_action('设为 课程活动', actions, update=True)
+# def set_course_category(self, request, queryset):
+# queryset.update(category=Activity.ActivityCategory.COURSE)
+# return self.message_user(request=request, message='修改成功!')
+
+# def _change_status(self, activity, from_status, to_status):
+# from app.activity_utils import changeActivityStatus
+# changeActivityStatus(activity.id, from_status, to_status)
+# if remove_job(f'activity_{activity.id}_{to_status}'):
+# return '修改成功, 并移除了定时任务!'
+# else:
+# return '修改成功!'
+
+# @as_action("进入 等待中 状态", actions, single=True)
+# def to_waiting(self, request, queryset):
+# _from, _to = Activity.Status.APPLYING, Activity.Status.WAITING
+# msg = self._change_status(queryset[0], _from, _to)
+# return self.message_user(request, msg)
- @as_action("进入 进行中 状态", actions, single=True)
- def to_processing(self, request, queryset):
- _from, _to = Activity.Status.WAITING, Activity.Status.PROGRESSING
- msg = self._change_status(queryset[0], _from, _to)
- return self.message_user(request, msg)
+# @as_action("进入 进行中 状态", actions, single=True)
+# def to_processing(self, request, queryset):
+# _from, _to = Activity.Status.WAITING, Activity.Status.PROGRESSING
+# msg = self._change_status(queryset[0], _from, _to)
+# return self.message_user(request, msg)
- @as_action("进入 已结束 状态", actions, single=True)
- def to_end(self, request, queryset):
- _from, _to = Activity.Status.PROGRESSING, Activity.Status.END
- msg = self._change_status(queryset[0], _from, _to)
- return self.message_user(request, msg)
-
- @as_action("取消 定时任务", actions)
- def cancel_scheduler(self, request, queryset):
- success_list = []
- failed_list = []
- CANCEL_STATUSES = [
- 'remind',
- Activity.Status.END,
- Activity.Status.PROGRESSING,
- Activity.Status.WAITING,
- ]
- for activity in queryset:
- failed_statuses = []
- for status in CANCEL_STATUSES:
- if not remove_job(f'activity_{activity.id}_{status}'):
- failed_statuses.append(status)
- if failed_statuses:
- if len(failed_statuses) != len(CANCEL_STATUSES):
- failed_list.append(f'{activity.id}: {",".join(failed_statuses)}')
- else:
- failed_list.append(f'{activity.id}')
- else:
- success_list.append(f'{activity.id}')
+# @as_action("进入 已结束 状态", actions, single=True)
+# def to_end(self, request, queryset):
+# _from, _to = Activity.Status.PROGRESSING, Activity.Status.END
+# msg = self._change_status(queryset[0], _from, _to)
+# return self.message_user(request, msg)
+
+# @as_action("取消 定时任务", actions)
+# def cancel_scheduler(self, request, queryset):
+# success_list = []
+# failed_list = []
+# CANCEL_STATUSES = [
+# 'remind',
+# Activity.Status.END,
+# Activity.Status.PROGRESSING,
+# Activity.Status.WAITING,
+# ]
+# for activity in queryset:
+# failed_statuses = []
+# for status in CANCEL_STATUSES:
+# if not remove_job(f'activity_{activity.id}_{status}'):
+# failed_statuses.append(status)
+# if failed_statuses:
+# if len(failed_statuses) != len(CANCEL_STATUSES):
+# failed_list.append(f'{activity.id}: {",".join(failed_statuses)}')
+# else:
+# failed_list.append(f'{activity.id}')
+# else:
+# success_list.append(f'{activity.id}')
- msg = f'成功取消{len(success_list)}项活动的定时任务!' if success_list else '未能完全取消任何任务'
- if failed_list:
- msg += f'\n{len(failed_list)}项活动取消失败:\n{";".join(failed_list)}'
- return self.message_user(request=request, message=msg)
-
-
-@admin.register(Participation)
-class ParticipationAdmin(admin.ModelAdmin):
- _m = Participation
- _act = _m.activity
- list_display = ['id', f(_act), f(_m.person), f(_m.status)]
- search_fields = ['id', f(_act, 'id'), f(_act, Activity.title),
- f(_m.person, NaturalPerson.name)]
- list_filter = [
- f(_m.status), f(_act, Activity.category),
- f(_act, Activity.year), f(_act, Activity.semester),
- ]
-
-
-@admin.register(Notification)
-class NotificationAdmin(admin.ModelAdmin):
- list_display = ["id", "receiver", "sender", "title", "start_time"]
- search_fields = ('id', "receiver__username", "sender__username", 'title')
- list_filter = ('start_time', 'status', 'typename', "finish_time")
-
- actions = [
- 'set_delete',
- 'republish',
- 'republish_bulk_at_promote', 'republish_bulk_at_message',
- ]
-
- @as_action("设置状态为 删除", update=True)
- def set_delete(self, request, queryset):
- queryset.update(status=Notification.Status.DELETE)
- return self.message_user(request=request,
- message='修改成功!')
-
- @as_action("重发 单个通知")
- def republish(self, request, queryset):
- if len(queryset) != 1:
- return self.message_user(request=request,
- message='一次只能重发一个通知!',
- level='error')
- notification = queryset[0]
- from app.extern.wechat import publish_notification, WechatApp
- if not publish_notification(
- notification,
- app=WechatApp.NORMAL,
- ):
- return self.message_user(request=request,
- message='发送失败!请检查通知内容!',
- level='error')
- return self.message_user(request=request,
- message='已成功定时,将发送至默认窗口!')
+# msg = f'成功取消{len(success_list)}项活动的定时任务!' if success_list else '未能完全取消任何任务'
+# if failed_list:
+# msg += f'\n{len(failed_list)}项活动取消失败:\n{";".join(failed_list)}'
+# return self.message_user(request=request, message=msg)
+
+
+# @admin.register(Participation)
+# class ParticipationAdmin(admin.ModelAdmin):
+# _m = Participation
+# _act = _m.activity
+# list_display = ['id', f(_act), f(_m.person), f(_m.status)]
+# search_fields = ['id', f(_act, 'id'), f(_act, Activity.title),
+# f(_m.person, NaturalPerson.name)]
+# list_filter = [
+# f(_m.status), f(_act, Activity.category),
+# f(_act, Activity.year), f(_act, Activity.semester),
+# ]
+
+
+# @admin.register(Notification)
+# class NotificationAdmin(admin.ModelAdmin):
+# list_display = ["id", "receiver", "sender", "title", "start_time"]
+# search_fields = ('id', "receiver__username", "sender__username", 'title')
+# list_filter = ('start_time', 'status', 'typename', "finish_time")
+
+# actions = [
+# 'set_delete',
+# 'republish',
+# 'republish_bulk_at_promote', 'republish_bulk_at_message',
+# ]
+
+# @as_action("设置状态为 删除", update=True)
+# def set_delete(self, request, queryset):
+# queryset.update(status=Notification.Status.DELETE)
+# return self.message_user(request=request,
+# message='修改成功!')
+
+# @as_action("重发 单个通知")
+# def republish(self, request, queryset):
+# if len(queryset) != 1:
+# return self.message_user(request=request,
+# message='一次只能重发一个通知!',
+# level='error')
+# notification = queryset[0]
+# from app.extern.wechat import publish_notification, WechatApp
+# if not publish_notification(
+# notification,
+# app=WechatApp.NORMAL,
+# ):
+# return self.message_user(request=request,
+# message='发送失败!请检查通知内容!',
+# level='error')
+# return self.message_user(request=request,
+# message='已成功定时,将发送至默认窗口!')
- def republish_bulk(self, request, queryset, app):
- if not request.user.is_superuser:
- return self.message_user(request=request,
- message='操作失败,没有权限,请联系老师!',
- level='warning')
- if len(queryset) != 1:
- return self.message_user(request=request,
- message='一次只能选择一个通知!',
- level='error')
- bulk_identifier = queryset[0].bulk_identifier
- if not bulk_identifier:
- return self.message_user(request=request,
- message='该通知不存在批次标识!',
- level='error')
- try:
- from app.extern.wechat import publish_notifications
- except Exception as e:
- return self.message_user(request=request,
- message=f'导入失败, 原因: {e}',
- level='error')
- if not publish_notifications(
- filter_kws={'bulk_identifier': bulk_identifier},
- app=app,
- ):
- return self.message_user(request=request,
- message='发送失败!请检查通知内容!',
- level='error')
- return self.message_user(request=request,
- message=f'已成功定时!标识为{bulk_identifier}')
- republish_bulk.short_description = "错误的重发操作"
-
- @as_action("重发 所在批次 于 订阅窗口")
- def republish_bulk_at_promote(self, request, queryset):
- try:
- from app.extern.wechat import WechatApp
- app = WechatApp._PROMOTE
- except Exception as e:
- return self.message_user(request=request,
- message=f'导入失败, 原因: {e}',
- level='error')
- return self.republish_bulk(request, queryset, app)
-
- @as_action("重发 所在批次 于 消息窗口")
- def republish_bulk_at_message(self, request, queryset):
- try:
- from app.extern.wechat import WechatApp
- app = WechatApp._MESSAGE
- except Exception as e:
- return self.message_user(request=request,
- message=f'导入失败, 原因: {e}',
- level='error')
- return self.republish_bulk(request, queryset, app)
+# def republish_bulk(self, request, queryset, app):
+# if not request.user.is_superuser:
+# return self.message_user(request=request,
+# message='操作失败,没有权限,请联系老师!',
+# level='warning')
+# if len(queryset) != 1:
+# return self.message_user(request=request,
+# message='一次只能选择一个通知!',
+# level='error')
+# bulk_identifier = queryset[0].bulk_identifier
+# if not bulk_identifier:
+# return self.message_user(request=request,
+# message='该通知不存在批次标识!',
+# level='error')
+# try:
+# from app.extern.wechat import publish_notifications
+# except Exception as e:
+# return self.message_user(request=request,
+# message=f'导入失败, 原因: {e}',
+# level='error')
+# if not publish_notifications(
+# filter_kws={'bulk_identifier': bulk_identifier},
+# app=app,
+# ):
+# return self.message_user(request=request,
+# message='发送失败!请检查通知内容!',
+# level='error')
+# return self.message_user(request=request,
+# message=f'已成功定时!标识为{bulk_identifier}')
+# republish_bulk.short_description = "错误的重发操作"
+
+# @as_action("重发 所在批次 于 订阅窗口")
+# def republish_bulk_at_promote(self, request, queryset):
+# try:
+# from app.extern.wechat import WechatApp
+# app = WechatApp._PROMOTE
+# except Exception as e:
+# return self.message_user(request=request,
+# message=f'导入失败, 原因: {e}',
+# level='error')
+# return self.republish_bulk(request, queryset, app)
+
+# @as_action("重发 所在批次 于 消息窗口")
+# def republish_bulk_at_message(self, request, queryset):
+# try:
+# from app.extern.wechat import WechatApp
+# app = WechatApp._MESSAGE
+# except Exception as e:
+# return self.message_user(request=request,
+# message=f'导入失败, 原因: {e}',
+# level='error')
+# return self.republish_bulk(request, queryset, app)
@admin.register(Help)
@@ -648,268 +631,8 @@ def approve_requests(self, request, queryset: QuerySet['ModifyOrganization']):
accept_modifyorg_submit(application)
self.message_user(request, '操作成功完成!')
-@admin.register(Course)
-class CourseAdmin(admin.ModelAdmin):
- list_display = [
- "name",
- "organization",
- "type",
- "participant_diaplay",
- "status",
- ]
- search_fields = (
- "name", "organization__oname",
- 'classroom', 'teacher',
- )
- list_filter = ("year", "semester", "type", "status",)
- autocomplete_fields = ['organization']
-
- class CourseTimeInline(admin.TabularInline):
- model = CourseTime
- classes = ['collapse']
- extra = 1
-
- inlines = [CourseTimeInline, CourseParticipantInline]
-
- def participant_diaplay(self, obj):
- return f'{obj.current_participants}/{"无限" if obj.capacity == 10000 else obj.capacity}'
- participant_diaplay.short_description = "报名情况"
-
- actions = []
-
- @as_action("更新课程状态", actions)
- def refresh_status(self, request, queryset):
- from app.course_utils import register_selection
- register_selection()
- return self.message_user(request=request,
- message='已设置定时任务!')
-
- @as_action("更新课程状态 延迟2分钟", actions)
- def refresh_status_delay2(self, request, queryset):
- from app.course_utils import register_selection
- from datetime import timedelta
- register_selection(wait_for=timedelta(minutes=2))
- return self.message_user(request=request,
- message='已设置定时任务!')
-
-
-@admin.register(CourseParticipant)
-class CourseParticipantAdmin(admin.ModelAdmin):
- list_display = ["course", "person", "status",]
- search_fields = ("course__name", "person__name",)
- autocomplete_fields = ['course', 'person']
-
-
-@admin.register(CourseRecord)
-class CourseRecordAdmin(admin.ModelAdmin):
- _m = CourseRecord
- list_display = [
- _m.get_course_name, f(_m.person),
- f(_m.year), f(_m.semester),
- f(_m.attend_times), f(_m.total_hours),
- f(_m.invalid),
- ]
- search_fields = [
- f(_m.course, Course.name), f(_m.extra_name),
- f(_m.person, NaturalPerson.name),
- f(_m.person, NaturalPerson.person_id, User.username),
- ]
- class TypeFilter(admin.SimpleListFilter):
- title = '学时类别'
- parameter_name = 'type' # 过滤器使用的过滤字段
-
- def lookups(self, request, model_admin):
- '''针对字段值设置过滤器的显示效果'''
- # 自带一个None, '全部'
- return (
- ('null', '无'),
- ('any', '任意'),
- ) + tuple(Course.CourseType.choices)
-
- def queryset(self, request, queryset):
- '''定义过滤器的过滤动作'''
- if self.value() == 'null':
- return queryset.filter(course__isnull=True)
- elif self.value() == 'any':
- return queryset.exclude(course__isnull=True)
- elif self.value() in map(str, Course.CourseType.values):
- return queryset.filter(course__type=self.value())
- return queryset
- list_filter = [TypeFilter, f(_m.year), f(_m.semester), f(_m.invalid)]
-
- autocomplete_fields = [f(_m.person), f(_m.course)]
-
- actions = []
-
- @as_action('更新来源名称', actions, update=True)
- def update_extra_name(self, request, queryset: QuerySet[CourseRecord]):
- records = queryset.filter(course__isnull=False)
- for record in records.select_related(f(CourseRecord.course)):
- record.extra_name = record.course.name
- record.save()
- return self.message_user(request=request, message='已更新关联学时名称!')
-
- @as_action("设置为 无效学时", actions, update=True)
- def set_invalid(self, request, queryset):
- queryset.update(invalid=True)
- return self.message_user(request=request, message='修改成功!')
-
- @as_action("设置为 有效学时", actions, update=True)
- def set_valid(self, request, queryset):
- queryset.update(invalid=False)
- return self.message_user(request=request, message='修改成功!')
-
-
-@admin.register(AcademicTag)
-class AcademicTagAdmin(admin.ModelAdmin):
- list_display = ["atype", "tag_content"]
- search_fields = ("atype", "tag_content")
- list_filter = ["atype"]
-
-
-class AcademicEntryAdmin(admin.ModelAdmin):
- actions = []
-
- @as_action("通过审核", actions, 'change', update=True)
- def accept(self, request, queryset):
- queryset.filter(status=AcademicEntry.EntryStatus.WAIT_AUDIT
- ).update(status=AcademicEntry.EntryStatus.PUBLIC)
- return self.message_user(request, '修改成功!')
-
- @as_action("取消公开", actions, 'change', update=True)
- def reject(self, request, queryset):
- queryset.filter(status=AcademicEntry.EntryStatus.PUBLIC
- ).update(status=AcademicEntry.EntryStatus.WAIT_AUDIT)
- return self.message_user(request, '修改成功!')
-
-
-@admin.register(AcademicTagEntry)
-class AcademicTagEntryAdmin(AcademicEntryAdmin):
- list_display = ["person", "status", "tag"]
- search_fields = ("person__name", "tag__tag_content")
- list_filter = ["tag__atype", "status"]
-
-
-@admin.register(AcademicTextEntry)
-class AcademicTextEntryAdmin(AcademicEntryAdmin):
- list_display = ["person", "status", "atype", "content"]
- search_fields = ("person__name", "content")
- list_filter = ["atype", "status"]
-
-
-class PoolItemInline(admin.TabularInline):
- model = PoolItem
- classes = ['collapse']
- ordering = ['-id']
- fields = ['pool', 'prize', 'origin_num', 'consumed_num', 'exchange_limit', 'exchange_price']
- show_change_link = True
-PoolItemInline = readonly_inline(PoolItemInline, can_add=True)
-
-
-@admin.register(Prize)
-class PrizeAdmin(admin.ModelAdmin):
- autocomplete_fields = ['provider']
- inlines = [PoolItemInline]
-
-
-@admin.register(Pool)
-class PoolAdmin(admin.ModelAdmin):
- inlines = [PoolItemInline]
- actions = []
-
- def _do_draw_lots(self, request, queryset: QuerySet['Pool']):
- '''对queryset中所有未完成抽奖的抽奖奖池进行奖品分配。
-
- 这个函数假定queryset已经被select_for_update锁定,所以可以安全地查找“奖池记录”中与该奖池有关的行。
- '''
- lottery_pool_ids = list(queryset.filter(type = Pool.Type.LOTTERY).values_list('id', flat = True))
- for pool_id in lottery_pool_ids:
- pool_title = Pool.objects.get(id = pool_id).title
- if PoolRecord.objects.filter(
- pool__id = pool_id
- ).exclude(
- status = PoolRecord.Status.LOTTERING
- ).exists():
- self.message_user(request, "奖池【" + pool_title + "】在调用前已完成抽奖", 'warning')
- continue
- run_lottery(pool_id)
- self.message_user(request, "奖池【" + pool_title + "】抽奖已完成")
-
- @as_action('立即抽奖', actions, 'change', update = True)
- def draw_lots(self, request, queryset: QuerySet['Pool']):
- self._do_draw_lots(request, queryset)
-
- @as_action('立即停止并抽奖', actions, 'change', update = True)
- def stop_and_draw(self, request, queryset: QuerySet['Pool']):
- queryset.update(end = datetime.now())
- self.message_user(request, "已将选中奖池全部停止")
- self._do_draw_lots(request, queryset)
-
-
-@admin.register(PoolRecord)
-class PoolRecordAdmin(admin.ModelAdmin):
- list_display = ['user_display', 'pool', 'status', 'prize', 'time']
- search_fields = ['user__name']
- list_filter = [
- 'status', 'prize', 'time',
- ('prize__provider', admin.RelatedOnlyFieldListFilter),
- ]
- readonly_fields = ['time']
- autocomplete_fields = ['user']
- actions = []
-
- @as_display('用户')
- def user_display(self, obj: PoolRecord):
- return obj.user.name
-
- def has_manage_permission(self, request: HttpRequest, record: PoolRecord = None) -> bool:
- if not request.user.is_authenticated:
- return False
- if record is not None:
- return record.prize.provider == request.user
- return Prize.objects.filter(provider=request.user).exists()
- # return super().get_queryset(request).filter(prize__provider=request.user).exists()
-
- def has_module_permission(self, request: HttpRequest) -> bool:
- return super().has_module_permission(request) or self.has_manage_permission(request)
-
- def has_view_permission(self, request: HttpRequest, obj: PoolRecord = None) -> bool:
- return super().has_view_permission(request, obj) or self.has_manage_permission(request, obj)
-
- def get_queryset(self, request: HttpRequest):
- qs = super().get_queryset(request)
- if not self.has_change_permission(request) and self.has_manage_permission(request):
- qs = qs.filter(prize__provider=request.user)
- return qs
-
- @as_action('兑换', actions, ['change', 'manage'], update=True, single=False) # 修改single为False以允许选择多个记录
- def redeem_prize(self, request, queryset):
- # 检查权限和记录状态,统计可以兑换的记录数
- redeemable_records = []
- for record in queryset:
- if not (self.has_change_permission(request, record) or self.has_manage_permission(request, record)):
- return self.message_user(request, '无权负责一部分选定的礼品兑换!', 'warning')
- if record.status != PoolRecord.Status.UN_REDEEM:
- return self.message_user(request, f'奖品 {record.prize.name} 已被兑换或不可兑换!', 'warning')
- redeemable_records.append(record)
-
- # 如果没有可兑换的记录,提前返回
- if not redeemable_records:
- return self.message_usesr(request, '没有可兑换的奖品!', 'error')
-
- # 对可以兑换的记录进行兑换处理
- for record in redeemable_records:
- if record.prize.name.startswith('信用分'):
- User.objects.modify_credit(record.user, 1, '元气值:兑换')
- record.status = PoolRecord.Status.REDEEMED
- record.redeem_time = datetime.now()
- record.save()
-
- # 返回成功信息
- self.message_user(request, f'成功兑换 {len(redeemable_records)} 个奖品!')
admin.site.register(OrganizationTag)
admin.site.register(Comment)
admin.site.register(CommentPhoto)
-admin.site.register(PoolItem)
-admin.site.register(ActivitySummary)
+# admin.site.register(ActivitySummary)
diff --git a/app/chat_api.py b/app/chat_api.py
deleted file mode 100644
index bfb4df899..000000000
--- a/app/chat_api.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from app.views_dependency import *
-from app.models import Chat
-from app.chat_utils import (
- change_chat_status,
- select_by_keywords,
- modify_rating,
- create_QA,
- add_comment_to_QA,
-)
-
-__all__ = [
- 'StartChat', 'AddComment', 'CloseChat', 'StartUndirectedChat', 'RateAnswer'
-]
-
-
-class StartChat(ProfileJsonView):
- def prepare_post(self):
- self.receiver_id = self.request.POST['receiver_id']
- self.questioner_anonymous = self.request.POST['comment_anonymous'] == 'true'
- return self.post
-
- def post(self):
- '''创建一条新的chat'''
- respondent = User.objects.get(username=self.receiver_id)
-
- context = create_QA(self.request, respondent, directed=True,
- questioner_anonymous=self.questioner_anonymous)
- return self.message_response(context)
-
-
-class AddComment(ProfileJsonView):
- need_prepare = False
- def post(self):
- '''向聊天中添加对话'''
- return self.message_response(add_comment_to_QA(self.request))
-
-
-class CloseChat(ProfileJsonView):
- def prepare_post(self):
- self.chat_id = int(self.request.POST['chat_id'])
- return self.post
-
- def post(self):
- '''终止聊天'''
- message_context = change_chat_status(self.chat_id, Chat.Status.CLOSED)
- return self.message_response(message_context)
-
-
-class StartUndirectedChat(ProfileJsonView):
- def prepare_post(self):
- self.questioner_anonymous = self.request.POST['comment_anonymous'] == 'true'
- self.keywords = self.request.POST['keywords'].split(sep=',')
- return self.post
-
- def post(self):
- """
- 开始非定向问答
- """
- respondent, message_context = select_by_keywords(
- self.request.user, self.questioner_anonymous, self.keywords)
- if respondent is None:
- return self.message_response(message_context)
-
- context = create_QA(self.request, respondent, directed=False,
- questioner_anonymous=self.questioner_anonymous,
- keywords=self.keywords)
- return self.message_response(context)
-
-
-class RateAnswer(ProfileJsonView):
- def prepare_post(self):
- self.chat_id = int(self.request.POST['chat_id'])
- self.rating = int(self.request.POST['rating'])
- return self.post
-
- def post(self):
- '''提问方对回答质量给出评价'''
- return self.message_response(modify_rating(self.chat_id, self.rating))
diff --git a/app/chat_utils.py b/app/chat_utils.py
deleted file mode 100644
index 5396cf95c..000000000
--- a/app/chat_utils.py
+++ /dev/null
@@ -1,293 +0,0 @@
-from collections import Counter
-from random import sample
-from typing import Tuple
-
-from django.http import HttpRequest
-
-from generic.models import YQPointRecord
-from app.utils_dependency import *
-from app.models import (
- AcademicEntry,
- AcademicTagEntry,
- AcademicTextEntry,
- User,
- Chat,
- AcademicQA,
- AcademicQAAwards,
-)
-from app.comment_utils import addComment
-from achievement.api import unlock_achievement
-
-__all__ = [
- 'change_chat_status',
- 'select_by_keywords',
- 'create_QA',
- 'add_comment_to_QA',
- 'modify_rating',
-]
-
-
-def change_chat_status(chat_id: int, to_status: Chat.Status) -> MESSAGECONTEXT:
- """
- 修改chat的状态
-
- :param chat_id
- :type chat_id: int
- :param to_status: 目标状态
- :type to_status: Chat.Status
- :return: 表明成功与否的MESSAGECONTEXT
- :rtype: MESSAGECONTEXT
- """
- # 参考了notification_utils.py的notification_status_change
- context = wrong("在修改问答状态的过程中发生错误,请联系管理员!")
- with transaction.atomic():
- try:
- chat: Chat = Chat.objects.select_for_update().get(id=chat_id)
- except:
- return wrong("该问答不存在!", context)
-
- if chat.status == to_status:
- return succeed("问答状态无需改变!", context)
-
- if to_status == Chat.Status.CLOSED:
- chat.status = Chat.Status.CLOSED
- chat.save()
- succeed("您已成功关闭当前提问!", context)
- elif to_status == Chat.Status.PROGRESSING: # 这个目前没有用到
- raise NotImplementedError
- chat.status = Chat.Status.PROGRESSING
- chat.save()
- succeed("您已成功开放一个问答!", context)
- return context
-
-
-def add_chat_message(request: HttpRequest, chat: Chat) -> MESSAGECONTEXT:
- """
- 给一个chat发送新comment,并给接收者发通知(复用了comment_utils.py/addComment)
-
- :param request: addComment函数会用到,其user即为发送comment的用户,其POST参数至少应当包括:comment_submit(点击“回复”按钮),comment(回复内容)
- :type request: HttpRequest
- :param chat
- :type chat: Chat
- :return: 表明发送结果的MESSAGECONTEXT
- :rtype: MESSAGECONTEXT
- """
- # 只能发给PROGRESSING的chat
- if chat.status == Chat.Status.CLOSED:
- return wrong("当前问答已关闭,无法发送新信息!")
- if (not chat.respondent.accept_anonymous_chat
- ) and chat.questioner_anonymous:
- if request.user == chat.respondent:
- return wrong("您目前处于禁用匿名提问状态!")
- else:
- return wrong("对方目前不允许匿名提问!")
-
- if request.user == chat.questioner:
- receiver = chat.respondent # 我是这个chat的提问方,则我发送新comment时chat的接收方会收到通知
- anonymous = chat.questioner_anonymous # 如果chat是匿名提问的,则我作为提问方发送新comment时需要匿名
- else:
- receiver = chat.questioner # 我是这个chat的接收方,则我发送新comment时chat的提问方会收到通知
- anonymous = False # 接收方发送的comment一定是实名的
-
- comment_context = addComment( # 复用comment_utils.py,这个函数包含了通知发送功能
- request,
- chat,
- receiver,
- anonymous=anonymous,
- notification_title='学术地图问答信息')
- return comment_context
-
-
-def create_chat(
- request: HttpRequest,
- respondent: User,
- title: str,
- questioner_anonymous: bool = False,
- respondent_anonymous: bool = False
-) -> Tuple[int | None, MESSAGECONTEXT]:
- """
- 创建新chat并调用add_chat_message发送首条提问
-
- :param request: add_chat_message会用到
- :type request: HttpRequest
- :param respondent: 被提问的人
- :type respondent: User
- :param title: chat主题,不超过50字
- :type title: str
- :param anonymous: chat是否匿名, defaults to False
- :type anonymous: bool, optional
- :return: 新chat的id(创建失败为None)和表明创建chat/发送提问结果的MESSAGECONTEXT
- :rtype: Tuple[int | None, MESSAGECONTEXT]
- """
- if (not respondent.accept_anonymous_chat) and questioner_anonymous:
- return None, wrong("对方不允许匿名提问!")
-
- # 目前提问方回答方都需要是自然人
- if not request.user.is_person() or not respondent.is_person():
- return None, wrong("目前只允许个人用户进行问答!")
-
- if len(title) > 50: # Chat.title的max_length为50
- return None, wrong("主题过长!请勿超过50字")
- if len(request.POST["comment"]) == 0:
- return None, wrong("提问内容不能为空!")
-
- with transaction.atomic():
- chat = Chat.objects.create(
- questioner=request.user,
- respondent=respondent,
- title=title,
- questioner_anonymous=questioner_anonymous,
- respondent_anonymous=respondent_anonymous,
- )
- # 创建chat后没有发送通知,随后创建chat的第一条comment时会发送通知
- comment_context = add_chat_message(request, chat)
-
- return chat.id, comment_context
-
-
-def get_matched_users(query: str, current_user: User, anonymous: bool):
- """
- 根据提供的关键词获取搜索结果
- 比academic_utils中的类似函数更加精简
- """
- # 搜索所有含有关键词的公开的学术地图项目,忽略大小写
- match_with_tags = SQ.qsvlist(AcademicTagEntry.objects.filter(
- tag__tag_content__icontains=query,
- status=AcademicEntry.EntryStatus.PUBLIC,
- ), AcademicEntry.person, NaturalPerson.person_id)
-
- match_with_texts = SQ.qsvlist(AcademicTextEntry.objects.filter(
- content__icontains=query,
- status=AcademicEntry.EntryStatus.PUBLIC,
- ), AcademicEntry.person, NaturalPerson.person_id)
-
- matched_ids = match_with_tags + match_with_texts
- matched_users = User.objects.exclude(
- username=current_user.username).filter(id__in=matched_ids)
-
- if anonymous:
- return matched_users.filter(accept_anonymous_chat=True)
- else:
- return matched_users
-
-
-def select_by_keywords(
- user: User, anonymous: bool,
- keywords: list[str]) -> Tuple[User | None, MESSAGECONTEXT]:
- """
- 根据关键词从学生中抽取一个回答者
- """
- matched_users = []
- for k in keywords:
- matched_users.extend(list(get_matched_users(k, user, anonymous)))
-
- counted_users = Counter()
- counted_users.update(matched_users)
- greatest_occurrence = counted_users.most_common(1)[0][1]
- most_matched_users = [
- user[0] for user in counted_users.items()
- if user[1] == greatest_occurrence
- ]
-
- if not most_matched_users:
- return None, wrong("没有和标签匹配的对象!")
-
- chosen_users = sample(most_matched_users, k=1)[0]
- return chosen_users, succeed("成功找到回答者")
-
-
-def create_QA(request: HttpRequest,
- respondent: User,
- directed: bool,
- questioner_anonymous: bool,
- keywords: None | list[str] = None) -> MESSAGECONTEXT:
- """
- 创建学术地图问答,包括定向和非定向
-
- :param respondent: 回答者
- :type respondent: User
- :param directed: 是否为定向问答
- :type directed: bool
- :param questioner_anonymous: 提问者是否匿名
- :type questioner_anonymous: bool
- :param keywords: 关键词,暂时只在非定向问答中使用,用来定位回答者。
- :type keywords: None | list[str]
- """
- respondent_anonymous = not directed
- chat_id, message_context = create_chat(
- request,
- respondent=respondent,
- title=request.POST.get('comment_title'),
- questioner_anonymous=questioner_anonymous,
- respondent_anonymous=respondent_anonymous,
- )
- if chat_id is None:
- return message_context
-
- with transaction.atomic():
- AcademicQA.objects.create(
- chat_id=chat_id,
- keywords=keywords,
- directed=directed,
- )
-
- # 解锁成就-参与学术问答
- unlock_achievement(request.user, "参与学术问答")
-
- # 奖励仅限第一次发起非定向提问
- if directed:
- return succeed("提问成功")
-
- award_points = 5
- award, created = AcademicQAAwards.objects.get_or_create(user=request.user)
- if created or not award.created_undirected_qa:
- User.objects.modify_YQPoint(
- request.user, award_points, "学术地图: 首次发起非定向提问", source_type=YQPointRecord.SourceType.ACHIEVE)
- award.created_undirected_qa = True
- award.save()
- return succeed(f"首次发起非定向提问,奖励{award_points}元气值~")
-
- return succeed("提问成功")
-
-
-def modify_rating(chat_id: int, rating: int) -> MESSAGECONTEXT:
- assert rating >= 0 and rating <= 3
- award_points = [0, 5, 8, 12] # 评价对应的奖励值
- with transaction.atomic():
- qa: AcademicQA = AcademicQA.objects.get(chat_id=chat_id)
- qa.rating = rating
- User.objects.modify_YQPoint(qa.chat.respondent, award_points[rating],
- "学术问答: 回答得到好评",
- YQPointRecord.SourceType.ACHIEVE)
- qa.save()
-
- return succeed("成功修改评价")
-
-
-def add_comment_to_QA(request: HttpRequest) -> MESSAGECONTEXT:
- try:
- # TODO: 以后换成AcademicQA的id
- chat = Chat.objects.get(id=request.POST.get('chat_id'))
- except:
- return wrong('问答不存在!')
-
- message_context = add_chat_message(request, chat)
- if message_context[my_messages.CODE_FIELD] == my_messages.WRONG:
- return message_context
-
- anonymous = request.POST.get('anonymous')
- if anonymous == 'true':
- return message_context
-
- with transaction.atomic():
- if request.user == chat.respondent:
- Chat.objects.select_for_update().filter(id=chat.id).update(
- respondent_anonymous=False)
- else:
- Chat.objects.select_for_update().filter(id=chat.id).update(
- questioner_anonymous=False)
-
- # 解锁成就-参与学术问答
- unlock_achievement(request.user, "参与学术问答")
-
- return message_context
diff --git a/app/course_utils.py b/app/course_utils.py
deleted file mode 100644
index 9ac24dcfb..000000000
--- a/app/course_utils.py
+++ /dev/null
@@ -1,1491 +0,0 @@
-"""
-course_utils.py
-
-course_views.py的依赖函数
-
-registration_status_check: 检查学生选课状态变化的合法性
-registration_status_change: 改变学生选课状态
-course_to_display: 把课程信息转换为方便前端呈现的形式
-draw_lots: 预选阶段结束时执行抽签
-change_course_status: 改变课程的选课阶段
-remaining_willingness_point(暂不启用): 计算学生剩余的意愿点数
-process_time: 把datetime对象转换成人类可读的时间表示
-check_course_time_conflict: 检查当前选择的课是否与已选的课上课时间冲突
-"""
-from app.utils_dependency import *
-from app.models import (
- User,
- NaturalPerson,
- Activity,
- Notification,
- ActivityPhoto,
- Position,
- Participation,
- Course,
- CourseTime,
- CourseParticipant,
- CourseRecord,
- Semester,
-)
-from app.utils import (
- get_person_or_org,
- if_image,
-)
-from app.notification_utils import (
- bulk_notification_create,
- notification_create,
- notification_status_change,
-)
-from app.activity_utils import (
- changeActivityStatus,
- notifyActivity,
- create_participate_infos,
-)
-from app.extern.wechat import WechatApp, WechatMessageLevel
-from app.log import logger
-
-import openpyxl
-import openpyxl.worksheet.worksheet
-from random import sample
-from urllib.parse import quote
-from collections import Counter
-from datetime import datetime, timedelta
-from typing import Tuple, List
-
-from django.http import HttpRequest, HttpResponse
-from django.db import transaction
-from django.db.models import F, Q, Sum, Prefetch
-
-from scheduler.adder import ScheduleAdder, MultipleAdder
-from scheduler.cancel import remove_job
-from utils.config.cast import str_to_time
-from achievement.api import unlock_achievement
-
-__all__ = [
- 'check_ac_time_course',
- 'course_activity_base_check',
- 'create_single_course_activity',
- 'modify_course_activity',
- 'cancel_course_activity',
- 'registration_status_change',
- 'course_to_display',
- 'change_course_status',
- 'course_base_check',
- 'create_course',
- 'cal_participate_num',
- 'check_post_and_modify',
- 'finish_course',
- 'download_course_record',
- 'download_select_info',
-]
-
-APP_CONFIG = CONFIG.course
-
-
-def check_ac_time_course(start_time: datetime, end_time: datetime) -> bool:
- """
- 时间合法性的检查:开始早于结束
-
- :param start_time: 活动开始时间
- :type start_time: datetime
- :param end_time: 活动结束时间
- :type end_time: datetime
- :return: 是否合法
- :rtype: bool
- """
- if not start_time < end_time:
- return False
- return True
-
-
-def course_activity_base_check(request: HttpRequest) -> dict:
- """
- 检查课程活动,是activity_base_check的简化版,失败时抛出AssertionError
-
- :param request: 修改/发起单次课程活动的请求
- :type request: HttpRequest
- :raises AssertionError: 活动时间非法/需要报名的活动必须提前至少一小时发起
- :return: context
- :rtype: dict
- """
- context = dict()
-
- # 读取活动名称和地点,检查合法性
- context["title"] = request.POST.get("title", "")
- # context["introduction"] = request.POST["introduction"] # 暂定不需要简介
- context["location"] = request.POST.get("location", "")
- assert len(context["title"]) > 0, "标题不能为空"
- # assert len(context["introduction"]) > 0 # 暂定不需要简介
- assert len(context["location"]) > 0, "地点不能为空"
-
- # 读取活动时间,检查合法性
- try:
- act_start = datetime.strptime(
- request.POST["lesson_start"], "%Y-%m-%d %H:%M") # 活动开始时间
- act_end = datetime.strptime(
- request.POST["lesson_end"], "%Y-%m-%d %H:%M") # 活动结束时间
- act_publish_day = {
- "instant": Course.PublishDay.instant,
- "3": Course.PublishDay.threeday,
- "2": Course.PublishDay.twoday,
- "1": Course.PublishDay.oneday,
- }[request.POST.get("publish_day")] # 活动发布提前日期
-
- if act_publish_day == Course.PublishDay.instant:
- act_publish_time = datetime.now() + timedelta(seconds=10) # 活动发布时间,立即发布
- else:
- act_publish_time = datetime.strptime(
- request.POST["publish_time"], "%Y-%m-%d %H:%M") # 活动发布时间,指定的发布时间
- except:
- raise AssertionError("活动时间非法")
- context["start"] = act_start
- context["end"] = act_end
- context["publish_day"] = act_publish_day
- context["publish_time"] = act_publish_time
-
- if request.POST["need_apply"] == "True":
- assert datetime.now() < context["start"] - \
- timedelta(hours=1), "需要报名的活动必须提前至少一小时发起"
-
- assert check_ac_time_course(act_start, act_end), "活动时间非法"
-
- # 默认需要签到
- context["need_checkin"] = True
- # 默认不需要报名
- context["need_apply"] = request.POST["need_apply"] == "True"
- context["post_type"] = str(request.POST.get("post_type", ""))
- return context
-
-
-def create_single_course_activity(request: HttpRequest) -> Tuple[int, bool]:
- """
- 创建单次课程活动,是create_activity的简化版
- 错误提示通过AssertionError抛出
-
- :param request: 发起单次课程活动的请求
- :type request: HttpRequest
- :return: 课程活动id,True(成功创建)/False(相似活动已存在)
- :rtype: Tuple[int, bool]
- """
- context = course_activity_base_check(request)
-
- # 获取组织和课程
- org = get_person_or_org(request.user, UTYPE_ORG)
- course = Course.objects.activated().get(organization=org)
-
- # 查找是否有类似活动存在
- old_ones = Activity.objects.activated().filter(
- title=context["title"],
- start=context["start"],
- # introduction=context["introduction"], # 暂定不需要简介
- location=context["location"],
- category=Activity.ActivityCategory.COURSE, # 查重时要求是课程活动
- )
- if len(old_ones):
- assert len(old_ones) == 1, "创建活动时,已存在的相似活动不唯一"
- return old_ones[0].id, False
-
- # 获取默认审核老师
- examine_teacher = NaturalPerson.objects.get_teacher(
- APP_CONFIG.audit_teachers[0])
-
- # 获取活动所属课程的图片,用于viewActivity, examineActivity等页面展示
- image = str(course.photo)
- assert image, "获取课程图片失败"
-
- # 创建活动
- activity = Activity.objects.create(
- title=context["title"],
- organization_id=org,
- examine_teacher=examine_teacher,
- # introduction=context["introduction"], # 暂定不需要简介
- location=context["location"],
- start=context["start"],
- end=context["end"],
- category=Activity.ActivityCategory.COURSE,
- need_checkin=True, # 默认需要签到
-
- recorded=True,
- status=Activity.Status.UNPUBLISHED,
- publish_day=context["publish_day"], # 发布提前天数
- publish_time=context["publish_time"], # 发布时间
- need_apply=context["need_apply"] # 是否需要报名
-
- # capacity, URL, bidding,
- # inner, end_before均为default
- )
-
- if context["need_apply"]:
- activity.endbefore = Activity.EndBefore.onehour
- activity.apply_end = activity.start - timedelta(hours=1)
-
- if not activity.need_apply:
- # 选课人员自动报名活动
- # 选课结束以后,活动参与人员从小组成员获取
- person_pos = list(Position.objects.activated().filter(
- org=course.organization).values_list("person", flat=True))
- if course.status == Course.Status.STAGE2:
- # 如果处于补退选阶段,活动参与人员从课程选课情况获取
- selected_person = list(CourseParticipant.objects.filter(
- course=course,
- status=CourseParticipant.Status.SUCCESS,
- ).values_list("person", flat=True))
- person_pos += selected_person
- person_pos = list(set(person_pos))
- members = NaturalPerson.objects.filter(
- id__in=person_pos)
- status = Participation.AttendStatus.APPLYSUCCESS
- create_participate_infos(activity, members, status=status)
-
- activity.current_participants = len(person_pos)
- activity.capacity = len(person_pos)
- activity.save()
-
- # 在活动发布时通知参与成员,创建定时任务并修改活动状态
- if activity.need_apply:
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.APPLYING}",
- run_time=activity.publish_time)(activity.id, Activity.Status.UNPUBLISHED, Activity.Status.APPLYING) # OK
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.WAITING}",
- run_time=activity.start - timedelta(hours=1))(activity.id, Activity.Status.APPLYING, Activity.Status.WAITING) # OK
- else:
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.WAITING}",
- run_time=activity.publish_time)(activity.id, Activity.Status.UNPUBLISHED, Activity.Status.WAITING) # OK
-
- ScheduleAdder(notifyActivity, id=f"activity_{activity.id}_newCourseActivity",
- run_time=activity.publish_time)(activity.id, "newCourseActivity") # OK
-
- # 引入定时任务:提前15min提醒、活动状态由WAITING变PROGRESSING再变END
- ScheduleAdder(notifyActivity, id=f"activity_{activity.id}_remind",
- run_time=activity.start - timedelta(minutes=15))(activity.id, "remind") # OK
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.PROGRESSING}",
- run_time=activity.start)(activity.id, Activity.Status.WAITING, Activity.Status.PROGRESSING)
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.END}",
- run_time=activity.end)(activity.id, Activity.Status.PROGRESSING, Activity.Status.END)
- activity.save()
-
- # 设置活动照片
- ActivityPhoto.objects.create(
- image=image, type=ActivityPhoto.PhotoType.ANNOUNCE, activity=activity)
-
- # 通知审核老师
- notification_create(
- receiver=examine_teacher.person_id,
- sender=request.user,
- typename=Notification.Type.NEEDDO,
- title=Notification.Title.VERIFY_INFORM,
- content="您有一个单次课程活动待审批",
- URL=f"/examineActivity/{activity.id}",
- relate_instance=activity,
- to_wechat=dict(app=WechatApp.AUDIT),
- )
-
- return activity.id, True
-
-
-def modify_course_activity(request: HttpRequest, activity: Activity):
- """
- 修改单次课程活动信息,是modify_activity的简化版
- 错误提示通过AssertionError抛出
-
- :param request: 修改单次课程活动的请求
- :type request: HttpRequest
- :param activity: 待修改的活动
- :type activity: Activity
- """
- # 课程活动仅在待发布状态下可以修改
- assert activity.status == Activity.Status.UNPUBLISHED, \
- "课程活动只有在待发布状态才能修改。"
-
- context = course_activity_base_check(request)
-
- # 记录旧信息(以便发通知),写入新信息
- old_title = activity.title
- activity.title = context["title"]
- # activity.introduction = context["introduction"]# 暂定不需要简介
- old_location = activity.location
- activity.location = context["location"]
- old_start = activity.start
- activity.start = context["start"]
- old_end = activity.end
- activity.end = context["end"]
- old_publish_day = activity.publish_day
- activity.publish_day = context["publish_day"]
- old_publish_time = activity.publish_time
- activity.publish_time = context["publish_time"]
- old_need_apply = activity.need_apply
- activity.need_apply = context["need_apply"]
-
- if context["need_apply"]:
- activity.endbefore = Activity.EndBefore.onehour
- activity.apply_end = activity.start - timedelta(hours=1)
-
- activity.save()
-
- # 修改所有该时段的时间、地点
- if context["post_type"] == "modify_all" and activity.course_time is not None:
- course_time = CourseTime.objects.select_for_update().get(
- id=activity.course_time.id)
- course = course_time.course
- schedule_start = course_time.start
- schedule_end = course_time.end
- # 设置CourseTime初始时间为对应的 周几:hour:minute:second
- # 设置周几
- schedule_start += timedelta(
- days=(context["start"].weekday() - schedule_start.weekday()))
- schedule_end += timedelta(
- days=(context["end"].weekday() - schedule_end.weekday()))
- # 设置每周上课时间:hour:minute:second
- schedule_start = schedule_start.replace(hour=context["start"].hour,
- minute=context["start"].minute,
- second=context["start"].second)
- schedule_end = schedule_end.replace(hour=context["end"].hour,
- minute=context["end"].minute,
- second=context["end"].second)
- course_time.start = schedule_start
- course_time.end = schedule_end
- # 设置地点
- course.classroom = context["location"]
- course.need_apply = context["need_apply"]
- course.publish_day = context["publish_day"]
- course.save()
- course_time.save()
- # 目前只要编辑了活动信息,无论活动处于什么状态,都通知全体选课同学
- # if activity.status != Activity.Status.APPLYING and activity.status != Activity.Status.WAITING:
- # return
-
- # 发布前参与同学无法获取课程信息,因此不需要发送通知
- # to_participants = [f"您参与的书院课程活动{old_title}发生变化"]
- # if old_title != activity.title:
- # to_participants.append(f"活动更名为{activity.title}")
- # if old_location != activity.location:
- # to_participants.append(f"活动地点修改为{activity.location}")
- # if old_start != activity.start:
- # to_participants.append(
- # f"活动开始时间调整为{activity.start.strftime('%Y-%m-%d %H:%M')}")
-
- # 更新定时任务
- if old_need_apply:
- # 删除报名中的状态阶段
- remove_job(job_id=f"activity_{activity.id}_{Activity.Status.APPLYING}")
-
- if activity.need_apply:
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.APPLYING}",
- run_time=activity.publish_time)(activity.id, Activity.Status.UNPUBLISHED, Activity.Status.APPLYING) # OK
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.WAITING}",
- run_time=activity.start - timedelta(hours=1))(activity.id, Activity.Status.APPLYING, Activity.Status.WAITING) # OK
- else:
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.WAITING}",
- run_time=activity.publish_time)(activity.id, Activity.Status.UNPUBLISHED, Activity.Status.WAITING) # OK
-
- ScheduleAdder(notifyActivity, id=f"activity_{activity.id}_newCourseActivity",
- run_time=activity.publish_time)(activity.id, "newCourseActivity") # OK
- ScheduleAdder(notifyActivity, id=f"activity_{activity.id}_remind",
- run_time=activity.start - timedelta(minutes=15))(activity.id, "remind") # OK
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.PROGRESSING}",
- run_time=activity.start)(activity.id, Activity.Status.WAITING, Activity.Status.PROGRESSING) # OK
- ScheduleAdder(changeActivityStatus, id=f"activity_{activity.id}_{Activity.Status.END}",
- run_time=activity.end)(activity.id, Activity.Status.PROGRESSING, Activity.Status.END) # OK
-
- # 发通知
- # notifyActivity(activity.id, "modification_par", "\n".join(to_participants))
-
-
-def cancel_course_activity(request: HttpRequest, activity: Activity, cancel_all: bool = False):
- """
- 取消课程活动,是cancel_activity的简化版,在聚合页面被调用
-
- 在聚合页面中,应确保activity是课程活动,并且应检查activity.status,
- 如果不是WAITING或PROGRESSING,不应调用本函数
-
- 成功无返回值,失败返回错误消息
- (或者也可以在聚合页面判断出来能不能取消)
-
- :param request: 取消单次课程活动的请求
- :type request: HttpRequest
- :param activity: 待取消的活动
- :type activity: Activity
- :param cancel_all: 取消该时段所有活动, defaults to False
- :type cancel_all: bool, optional
- :return: 取消失败的话返回错误信息
- :rtype: string
- """
- # 只有UNPUBLISHED,WAITING和PROGRESSING允许取消
- if activity.status not in [
- Activity.Status.UNPUBLISHED,
- Activity.Status.WAITING,
- Activity.Status.PROGRESSING,
- ]:
- return f"课程活动状态为{activity.get_status_display()},不可取消。"
-
- # 课程活动已于一天前开始则不能取消,这一点也可以在聚合页面进行判断
- if activity.status == Activity.Status.PROGRESSING:
- if activity.start.day != datetime.now().day:
- return "课程活动已于一天前开始,不能取消。"
-
- # 取消活动
- activity.status = Activity.Status.CANCELED
- # 目前只要取消了活动信息,无论活动处于什么状态,都通知全体选课同学
- notifyActivity(activity.id, "modification_par",
- f"您报名的书院课程活动{activity.title}已取消(活动原定开始于{activity.start.strftime('%Y-%m-%d %H:%M')})。")
-
- # 删除老师的审核通知(如果有)
- notification = Notification.objects.get(
- relate_instance=activity,
- typename=Notification.Type.NEEDDO
- )
- notification_status_change(notification, Notification.Status.DELETE)
-
- # 取消定时任务(需要先判断一下是否已经被执行了)
- if activity.start - timedelta(minutes=15) > datetime.now():
- remove_job(f"activity_{activity.id}_remind")
- if activity.start > datetime.now():
- remove_job(f"activity_{activity.id}_{Activity.Status.PROGRESSING}")
- if activity.end > datetime.now():
- remove_job(f"activity_{activity.id}_{Activity.Status.END}")
-
- activity.save()
-
- # 取消该时段所有活动!
- if cancel_all:
- # 设置结束 若cur_week >= end_week 则每周定时任务无需执行
- activity.course_time.end_week = activity.course_time.cur_week
- activity.course_time.save()
-
-
-def remaining_willingness_point(user: NaturalPerson) -> int:
- """
- 计算用户剩余的意愿点
-
- :param user: 当前用户
- :type user: NaturalPerson
- :raises NotImplementedError: 暂不启用
- :return: 剩余的意愿点值
- :rtype: int
- """
- raise NotImplementedError("暂时不使用意愿点")
- # 当前用户已经预选的课
- # 由于participant可能为空,重新启用的时候注意修改代码
- courses = Course.objects.filter(
- participant_set__person=user,
- participant_set__status=CourseParticipant.Status.SELECT)
-
- initial_point = 99 # 初始意愿点
- cost_point = courses.aggregate(Sum('bidding')) # 已经使用的意愿点
-
- if cost_point:
- # cost_point可能为None
- return initial_point - cost_point['bidding__sum']
- else:
- return initial_point
-
-
-def registration_status_check(course_status: Course.Status,
- cur_status: CourseParticipant.Status,
- to_status: CourseParticipant.Status) -> None:
- """
- 检查选课状态的变化是否合法
-
- 1. 预选阶段允许的状态变化: SELECT <-> UNSELECT
- 2. 补退选阶段允许的状态变化: SUCCESS -> UNSELECT; FAILED -> SUCCESS; UNSELECT -> SUCCESS
-
- 异常: 抛出AssertionError,在调用处解决
-
- :param course_status: 课程所处的选课阶段
- :type course_status: Course.Status
- :param cur_status: 当前选课状态
- :type cur_status: CourseParticipant.Status
- :param to_status: 希望转变为的选课状态
- :type to_status: CourseParticipant.Status
- """
-
- if course_status == Course.Status.STAGE1:
- assert ((cur_status == CourseParticipant.Status.SELECT
- and to_status == CourseParticipant.Status.UNSELECT)
- or (cur_status == CourseParticipant.Status.UNSELECT
- and to_status == CourseParticipant.Status.SELECT))
- else:
- assert ((cur_status == CourseParticipant.Status.SUCCESS
- and to_status == CourseParticipant.Status.UNSELECT)
- or (cur_status == CourseParticipant.Status.FAILED
- and to_status == CourseParticipant.Status.SUCCESS)
- or (cur_status == CourseParticipant.Status.UNSELECT
- and to_status == CourseParticipant.Status.SUCCESS))
-
-
-def check_course_time_conflict(current_course: Course,
- user: NaturalPerson) -> Tuple[bool, str]:
- """
- 检查当前选择课程的时间和已选课程是否冲突
-
- :param current_course: 用户当前想选的课程
- :type current_course: Course
- :param user: 当前用户
- :type user: NaturalPerson
- :return: 是否冲突、发生冲突的具体原因
- :rtype: Tuple[bool, str]
- """
- selected_courses = Course.objects.selected(
- user, unfailed=True).prefetch_related("time_set")
-
- def time_hash(time: datetime):
- return time.weekday() * 1440 + time.hour * 60 + time.minute
-
- # 因为选择的课最多只能有6门,所以暂时用暴力算法
- for current_course_time in current_course.time_set.all():
-
- # 当前选择课程的上课时间
- current_start_time = current_course_time.start
- current_end_time = current_course_time.end
-
- for course in selected_courses:
- for course_time in course.time_set.all():
- start_time = course_time.start
- end_time = course_time.end
-
- # 效率不高,有待改进
- if not (time_hash(current_start_time) >= time_hash(end_time) or
- time_hash(current_end_time) <= time_hash(start_time)):
- # 发生冲突
- return True, \
- f"《{current_course.name}》和《{course.name}》的上课时间发生冲突!"
-
- # 没有冲突
- return False, ""
- '''
- # 循环较少的写法
- from django.db.models import Q
- conflict_course_names = set()
- for current_course_time in current_course.time_set.all():
- # 冲突时间
- conflict_times = CourseTime.objects.filter(
- # 已选的课程
- Q(course__in=selected_courses),
- # 开始比当前的结束时间早
- (Q(start__week_day=current_course_time.end.weekday() + 1,
- start__time__lte=current_course_time.end.time())
- | Q(start__week_day__lt=current_course_time.end.weekday() + 1))
- # 结束比当前的开始时间晚
- & (Q(end__week_day=current_course_time.start.weekday() + 1,
- end__time__gte=current_course_time.start.time())
- | Q(end__week_day__gt=current_course_time.start.weekday() + 1))
- )
- if conflict_times.exists():
- # return True, f'《{conflict_times.first().course.name}》'
- conflict_course_names.union(
- conflict_times.values_list('course__name', flat=True))
-
- conflict_count = len(conflict_course_names)
- # 有冲突
- if conflict_count:
- return conflict_count, f'《{"》《".join(conflict_course_names)}》'
- # 没有冲突
- return conflict_count, ""
- '''
-
-
-@logger.secure_func()
-def registration_status_change(course_id: int, user: NaturalPerson,
- action: str) -> MESSAGECONTEXT:
- """
- 学生点击选课或者取消选课后,更改学生的选课状态
-
- :param course_id: 当前课程的编号
- :type course_id: int
- :param user: 当前用户
- :type user: NaturalPerson
- :param action: 希望进行的操作,可能为"select"或"cancel"
- :type action: str
- :return: 操作是否成功执行
- :rtype: MESSAGECONTEXT
- """
- context = wrong("在修改选课状态的过程中发生错误,请联系管理员!")
-
- # 在外部保证课程ID是存在的
- course = Course.objects.get(id=course_id)
- course_status = course.status
-
- if (course_status != Course.Status.STAGE1
- and course_status != Course.Status.STAGE2):
- return wrong("在非选课阶段不能选课!")
-
- need_to_create = False
-
- if action == "select":
- if CourseParticipant.objects.filter(course_id=course_id,
- person=user).exists():
- participant_info = CourseParticipant.objects.get(
- course_id=course_id, person=user)
- cur_status = participant_info.status
- else:
- need_to_create = True
- cur_status = CourseParticipant.Status.UNSELECT
-
- if course_status == Course.Status.STAGE1:
- to_status = CourseParticipant.Status.SELECT
- else:
- to_status = CourseParticipant.Status.SUCCESS
-
- # 选课不能超过6门
- if Course.objects.selected(user, unfailed=True).count() >= 6:
- return wrong("每位同学同时预选或选上的课程数最多为6门!")
-
- # 检查选课时间是否冲突
- is_conflict, message = check_course_time_conflict(course, user)
-
- if is_conflict:
- return wrong(message)
- # return wrong(f'与{is_conflict}门已选课程时间冲突: {message}')
-
- # 解锁成就-首次报名书院课程
- unlock_achievement(user, '首次报名书院课程')
-
- else:
- # action为取消预选或退选
- to_status = CourseParticipant.Status.UNSELECT
-
- # 不允许状态不存在,除非发生了严重的错误
- try:
- participant_info = CourseParticipant.objects.get(
- course_id=course_id, person=user)
- cur_status = participant_info.status
- except:
- return context
-
- # 检查当前选课状态、选课阶段和操作的一致性
- try:
- registration_status_check(course_status, cur_status, to_status)
- except AssertionError:
- return wrong("非法的选课状态修改!")
-
- # 暂时不使用意愿点选课
- # if (course_status == Course.Status.STAGE1
- # and remaining_willingness_point(user) < course.bidding):
- # context["warn_message"] = "剩余意愿点不足"
-
- # 更新选课状态
- try:
- with transaction.atomic():
- if to_status == CourseParticipant.Status.UNSELECT:
- Course.objects.filter(id=course_id).select_for_update().update(
- current_participants=F("current_participants") - 1)
- CourseParticipant.objects.filter(course_id=course_id,
- person=user).delete()
- succeed("成功取消选课!", context)
- else:
- # 处理并发问题
- course = Course.objects.select_for_update().get(id=course_id)
- if (course_status == Course.Status.STAGE2
- and course.current_participants >= course.capacity):
- wrong("选课人数已满!", context)
- else:
- course.current_participants += 1
- course.save()
-
- # 由于不同用户之间的状态不共享,这个更新应该可以不加锁
- if need_to_create:
- CourseParticipant.objects.create(course_id=course_id,
- person=user,
- status=to_status)
- else:
- CourseParticipant.objects.filter(
- course_id=course_id,
- person=user).update(status=to_status)
- succeed("选课成功!", context)
- except:
- return context
- return context
-
-
-def process_time(start: datetime, end: datetime) -> str:
- """
- 把datetime对象转换成可读的时间表示
-
- :param start: 课程的开始时间
- :type start: datetime
- :param end: 课程的结束时间
- :type end: datetime
- :return: 可读的时间表示
- :rtype: str
- """
- chinese_display = ["一", "二", "三", "四", "五", "六", "日"]
- start_time = start.strftime("%H:%M")
- end_time = end.strftime("%H:%M")
- return f"周{chinese_display[start.weekday()]} {start_time}-{end_time}"
-
-
-def course_to_display(courses: QuerySet[Course],
- user: NaturalPerson,
- detail: bool = False) -> List[dict]:
- """
- 将课程信息转换为列表,方便前端呈现
-
- :param courses: 课程集合
- :type courses: QuerySet[Course]
- :param user: 当前用户
- :type user: NaturalPerson
- :param detail: 是否显示课程的详细信息, defaults to False
- :type detail: bool
- :return: 课程信息列表,用一个字典来传递课程的全部信息
- :rtype: List[dict]
- """
- display = []
-
- if detail:
- courses = courses.select_related('organization').prefetch_related(
- "time_set")
- else:
- # 预取,同时不查询不需要的字段
- courses = courses.defer(
- "classroom",
- "teacher",
- "introduction",
- "photo",
- "teaching_plan",
- "record_cal_method",
- "QRcode",
- ).select_related('organization').prefetch_related(
- Prefetch('participant_set',
- queryset=CourseParticipant.objects.filter(person=user),
- to_attr='participants'), "time_set")
-
- # 获取课程的基本信息
- for course in courses:
- course_info = {}
-
- # 选课页面和详情页面共用的信息
- course_info["name"] = course.name
- course_info["type"] = course.get_type_display() # 课程类型
- course_info["avatar_path"] = course.organization.get_user_ava()
-
- course_time = []
- for time in course.time_set.all():
- course_time.append(process_time(time.start, time.end))
- course_info["time_set"] = course_time
-
- def linebreak(str):
- from re import sub
- return sub("((\r|\\\)+n)|((\r|\\\)+\n)", "\n", str)
-
- if detail:
- # 在课程详情页才展示的信息
- course_info["times"] = course.times # 课程周数
- course_info["classroom"] = course.classroom
- course_info["teacher"] = course.teacher
- course_info["introduction"] = linebreak(course.introduction)
- course_info["teaching_plan"] = linebreak(course.teaching_plan)
- course_info["record_cal_method"] = linebreak(course.record_cal_method)
- course_info["organization_name"] = course.organization.oname
- course_info["have_QRcode"] = bool(course.QRcode)
- course_info["photo_path"] = course.get_photo_path()
- course_info["QRcode"] = course.get_QRcode_path()
- display.append(course_info)
- continue
-
- course_info["course_id"] = course.id
- course_info["capacity"] = course.capacity
- course_info["current_participants"] = course.current_participants
- course_info["status"] = course.get_status_display() # 课程所处的选课阶段
-
- # 暂时不启用意愿点机制
- # course_info["bidding"] = int(course.bidding)
-
- # 当前学生的选课状态(注:course.participants是一个list)
- if course.participants:
- course_info["student_status"] = course.participants[
- 0].get_status_display()
- else:
- course_info["student_status"] = "未选课"
-
- display.append(course_info)
-
- return display
-
-
-@logger.secure_func()
-def draw_lots():
- """
- 等额抽签选出成功选课的学生,并修改学生的选课状态
- """
- courses = Course.objects.activated().filter(status=Course.Status.DRAWING)
- for course in courses:
- with transaction.atomic():
- participants = CourseParticipant.objects.filter(
- course=course, status=CourseParticipant.Status.SELECT)
-
- participants_num = participants.count()
- if participants_num <= 0:
- continue
-
- participants_id = list(participants.values_list("id", flat=True))
- capacity = course.capacity
-
- if participants_num <= capacity:
- # 选课人数少于课程容量,不用抽签
- CourseParticipant.objects.filter(
- id__in=participants_id).select_for_update().update(
- status=CourseParticipant.Status.SUCCESS)
- Course.objects.filter(id=course.id).select_for_update().update(
- current_participants=participants_num)
- else:
- # 抽签;可能实现得有一些麻烦
- lucky_ones = sample(participants_id, capacity)
- unlucky_ones = list(
- set(participants_id).difference(set(lucky_ones)))
- # 不确定是否要加悲观锁
- CourseParticipant.objects.filter(
- id__in=lucky_ones).select_for_update().update(
- status=CourseParticipant.Status.SUCCESS)
- CourseParticipant.objects.filter(
- id__in=unlucky_ones).select_for_update().update(
- status=CourseParticipant.Status.FAILED)
- Course.objects.filter(id=course.id).select_for_update().update(
- current_participants=capacity)
-
- # 给选课成功的同学发送通知
- receivers = SQ.qsvlist(CourseParticipant.objects.filter(
- course=course,
- status=CourseParticipant.Status.SUCCESS,
- ), CourseParticipant.person, NaturalPerson.person_id)
- receivers = User.objects.filter(id__in=receivers)
- sender = course.organization.get_user()
- typename = Notification.Type.NEEDREAD
- title = Notification.Title.ACTIVITY_INFORM
- content = f"您好!您已成功选上课程《{course.name}》!"
-
- # 课程详情页面
- URL = f"/viewCourse/?courseid={course.id}"
-
- # 批量发送通知
- bulk_notification_create(
- receivers=receivers,
- sender=sender,
- typename=typename,
- title=title,
- content=content,
- URL=URL,
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT,
- level=WechatMessageLevel.IMPORTANT)
- )
-
- # 给选课失败的同学发送通知
-
- receivers = SQ.qsvlist(CourseParticipant.objects.filter(
- course=course,
- status=CourseParticipant.Status.FAILED,
- ), CourseParticipant.person, NaturalPerson.person_id)
- receivers = User.objects.filter(id__in=receivers)
- content = f"很抱歉通知您,您未选上课程《{course.name}》。"
- if len(receivers) > 0:
- bulk_notification_create(
- receivers=receivers,
- sender=sender,
- typename=typename,
- title=title,
- content=content,
- URL=URL,
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT,
- level=WechatMessageLevel.IMPORTANT),
- )
-
-
-@logger.secure_func()
-def change_course_status(cur_status: Course.Status, to_status: Course.Status) -> None:
- """
- 作为定时任务,在课程设定的时间改变课程的选课阶段
-
- example:
- scheduler.add_job(change_course_status, "date",
- id=f"course_{course_id}_{to_status}, run_date, args)
-
- :param cur_status: 课程的当前选课阶段
- :type cur_status: Course.Status
- :param to_status: 希望课程转变到的选课阶段
- :type to_status: Course.Status
- :raises AssertionError: 选课已经结束 / 两个状态间不匹配
- :raises AssertionError: 未提供当前阶段的信息
- """
- # 以下进行状态的合法性检查
- if cur_status is not None:
- if cur_status == Course.Status.WAITING:
- assert to_status == Course.Status.STAGE1, \
- f"不能从{cur_status}变更到{to_status}"
- elif cur_status == Course.Status.STAGE1:
- assert to_status == Course.Status.DRAWING, \
- f"不能从{cur_status}变更到{to_status}"
- elif cur_status == Course.Status.DRAWING:
- assert to_status == Course.Status.STAGE2, \
- f"不能从{cur_status}变更到{to_status}"
- elif cur_status == Course.Status.STAGE2:
- assert to_status == Course.Status.SELECT_END, \
- f"不能从{cur_status}变更到{to_status}"
- else:
- raise AssertionError("选课已经结束,不能再变化状态")
- else:
- raise AssertionError("未提供当前状态,不允许进行选课状态修改")
- courses = Course.objects.activated().filter(status=cur_status)
- if to_status == Course.Status.SELECT_END:
- courses = courses.select_related('organization')
- with transaction.atomic():
- for course in courses:
- if to_status == Course.Status.SELECT_END:
- # 选课结束,将选课成功的同学批量加入小组
- participants = CourseParticipant.objects.filter(
- course=course,
- status=CourseParticipant.Status.SUCCESS).select_related(
- 'person')
- organization = course.organization
- positions = []
- for participant in participants:
- # 如果已有当前的离职状态,改成在职成员
- Position.objects.current().filter(
- person=participant.person,
- org=organization,
- status=Position.Status.DEPART,
- ).update(pos=10,
- is_admin=False,
- semester=GLOBAL_CONFIG.semester,
- status=Position.Status.INSERVICE)
- # 检查是否已经加入小组
- if not Position.objects.activated().filter(
- person=participant.person,
- org=organization).exists():
- position = Position(person=participant.person,
- org=organization,
- semester=GLOBAL_CONFIG.semester)
-
- positions.append(position)
- if positions:
- with transaction.atomic():
- Position.objects.bulk_create(positions)
- # 更新目标状态
- courses.select_for_update().update(status=to_status)
-
-
-@logger.secure_func()
-def register_selection(wait_for: timedelta | None = None):
- """
- 添加定时任务,实现课程状态转变,每次发起课程时调用
- """
- # 预选和补退选的开始和结束时间
- year = GLOBAL_CONFIG.acadamic_year
- semester = GLOBAL_CONFIG.semester.value
- now = datetime.now()
- if wait_for is not None:
- now += wait_for
- stage1_start = str_to_time(APP_CONFIG.yx_election_start)
- stage1_start = max(stage1_start, now + timedelta(seconds=5))
- stage1_end = str_to_time(APP_CONFIG.yx_election_end)
- stage1_end = max(stage1_end, now + timedelta(seconds=10))
- publish_time = str_to_time(APP_CONFIG.publish_time)
- publish_time = max(publish_time, now + timedelta(seconds=15))
- stage2_start = str_to_time(APP_CONFIG.btx_election_start)
- stage2_start = max(stage2_start, now + timedelta(seconds=20))
- stage2_end = str_to_time(APP_CONFIG.btx_election_end)
- stage2_end = max(stage2_end, now + timedelta(seconds=25))
- # 定时任务:修改课程状态
- adder = MultipleAdder(change_course_status)
- adder.schedule(f'{year}_{semester}_选课_stage1_start',
- run_time=stage1_start)(Course.Status.WAITING, Course.Status.STAGE1)
- adder.schedule(f'{year}_{semester}_选课_stage1_end',
- run_time=stage1_end)(Course.Status.STAGE1, Course.Status.DRAWING)
- ScheduleAdder(draw_lots, id=f'{year}_{semester}_选课_publish',
- run_time=publish_time)()
- adder.schedule(f'{year}_{semester}_选课_stage2_start',
- run_time=stage2_start)(Course.Status.DRAWING, Course.Status.STAGE2)
- adder.schedule(f'{year}_{semester}_选课_stage2_end',
- run_time=stage2_end)(Course.Status.STAGE2, Course.Status.SELECT_END)
- # 状态随时间的变化: WAITING-STAGE1-WAITING-STAGE2-END
-
-
-def course_base_check(request, if_new=None):
- """
- 选课单变量合法性检查并准备变量
- """
- context = dict()
- # 字符串字段合法性检查
- try:
- # name, introduction, classroom 创建时不能为空
- context = my_messages.read_content(
- request.POST,
- "name",
- 'teacher',
- "introduction",
- "classroom",
- "teaching_plan",
- "record_cal_method",
- "need_apply",
- "publish_day",
- _trans_func=str,
- _default="",
- )
- assert len(context["name"]) > 0, "课程名称不能为空!"
- assert len(context["introduction"]) > 0, "课程介绍不能为空!"
- assert len(context["teaching_plan"]) > 0, "教学计划不能为空!"
- assert len(context["record_cal_method"]) > 0, "学时计算方法不能为空!"
- assert len(context["classroom"]) > 0, "上课地点不能为空!"
- assert context["need_apply"] in ["True", "False"], "是否需要报名必须为给定值!"
- assert context["publish_day"] in [
- "instant", "1", "2", "3"], "信息发布时间必须为给定值!"
- except Exception as e:
- return wrong(str(e))
- # int类型合法性检查
-
- type_num = request.POST.get("type", "") # 课程类型
- capacity = request.POST.get("capacity", -1)
- # context['times'] = int(request.POST["times"]) #课程上课周数
- try:
- cur_info = "记得选择课程类型哦!"
- type_num = int(type_num)
- cur_info = "课程类型仅包括德智体美劳五种!"
- assert 0 <= type_num < 5
- cur_info = "课程容量应当大于0!"
- assert int(capacity) > 0
- except:
- return wrong(cur_info)
- context['type'] = type_num
- context['capacity'] = capacity
-
- # 图片类型合法性检查
- try:
- announcephoto = request.FILES.get("photo")
- pic = None
- if announcephoto:
- pic = announcephoto
- assert if_image(pic) == 2, "课程预告图片文件类型错误!"
- else:
- for i in range(5):
- if request.POST.get(f'picture{i+1}'):
- pic = request.POST.get(f'picture{i+1}')
- context["photo"] = pic
- context["QRcode"] = request.FILES.get("QRcode")
- if if_new is None:
- assert context["photo"] is not None, "缺少课程预告图片!"
- assert if_image(context["QRcode"]) != 1, "微信群二维码图片文件类型错误!"
- except Exception as e:
- return wrong(str(e))
-
- # 每周课程时间合法性检查
- # TODO: 需要增加是否可以修改时间的安全性检查
- course_starts = request.POST.getlist("start")
- course_ends = request.POST.getlist("end")
- course_starts = [
- datetime.strptime(course_start, "%Y-%m-%d %H:%M")
- for course_start in course_starts
- if course_start != ''
- ]
- course_ends = [
- datetime.strptime(course_end, "%Y-%m-%d %H:%M")
- for course_end in course_ends
- if course_end != ''
- ]
- try:
- for i in range(len(course_starts)):
- assert check_ac_time_course(
- course_starts[i], course_ends[i]), f'第{i+1}次上课时间起止时间有误!'
- # 课程每周同一次课的开始和结束时间应当处于同一天
- assert course_starts[i].date(
- ) == course_ends[i].date(), f'第{i+1}次上课起止时间应当为同一天'
- except Exception as e:
- return wrong(str(e))
- context['course_starts'] = course_starts
- context['course_ends'] = course_ends
- context['publish_day'] = {
- "instant": Course.PublishDay.instant,
- "3": Course.PublishDay.threeday,
- "2": Course.PublishDay.twoday,
- "1": Course.PublishDay.oneday,
- }[context['publish_day']]
- org = get_person_or_org(request.user, UTYPE_ORG)
- context['organization'] = org
-
- succeed("合法性检查通过!", context)
- return context
-
-
-def create_course(request, course_id=None):
- '''
- 检查课程,合法时寻找该课程,不存在时创建
- 返回(course.id, created)
- '''
- context = dict()
-
- try:
- context = course_base_check(request, course_id)
- if context["warn_code"] == 1: # 合法性检查出错!
- return context
- except:
- return wrong("检查参数合法性时遇到不可预料的错误。如有需要,请联系管理员解决!")
-
- # 编辑已有课程
- if course_id is not None:
- try:
- course = Course.objects.get(id=int(course_id))
- with transaction.atomic():
- if course.status in [Course.Status.WAITING]:
- course_time = course.time_set.all()
- course_time.delete()
- course.name = context["name"]
- course.classroom = context["classroom"]
- course.teacher = context['teacher']
- course.introduction = context["introduction"]
- course.teaching_plan = context["teaching_plan"]
- course.record_cal_method = context["record_cal_method"]
- course.type = context['type']
- course.capacity = context["capacity"]
- course.need_apply = context["need_apply"]
- course.publish_day = context["publish_day"]
- course.photo = context['photo'] if context['photo'] is not None else course.photo
- if context['QRcode']:
- course.QRcode = context["QRcode"]
- course.save()
-
- if course.status in [Course.Status.WAITING]:
- for i in range(len(context['course_starts'])):
- CourseTime.objects.create(
- course=course,
- start=context['course_starts'][i],
- end=context['course_ends'][i],
- )
- except:
- return wrong("修改课程时遇到不可预料的错误。如有需要,请联系管理员解决!")
- context["cid"] = course_id
- context["warn_code"] = 2
- context["warn_message"] = "修改课程成功!"
- # 创建新课程
- else:
- try:
- with transaction.atomic():
- course = Course.objects.create(
- name=context["name"],
- organization=context['organization'],
- classroom=context["classroom"],
- teacher=context['teacher'],
- introduction=context["introduction"],
- teaching_plan=context["teaching_plan"],
- record_cal_method=context["record_cal_method"],
- type=context['type'],
- capacity=context["capacity"],
- need_apply=context["need_apply"],
- publish_day=context["publish_day"]
- )
- course.photo = context['photo']
- if context['QRcode']:
- course.QRcode = context["QRcode"]
- course.save()
-
- for i in range(len(context['course_starts'])):
- CourseTime.objects.create(
- course=course,
- start=context['course_starts'][i],
- end=context['course_ends'][i],
- )
- register_selection() # 每次发起课程,创建定时任务
- except:
- return wrong("创建课程时遇到不可预料的错误。如有需要,请联系管理员解决!")
- context["cid"] = course.id
- context["warn_code"] = 2
- context["warn_message"] = "创建课程成功!"
-
- return context
-
-
-def cal_participate_num(course: Course) -> dict:
- """
- 计算该课程对应组织所有成员的参与次数
- return {Naturalperson.id:参与次数}
- 前端使用的时候直接读取字典的值就好了
- :param course: 选择要计算的课程
- :type course: Course
- :return: 返回统计数据
- :rtype: dict
- """
- org = course.organization
- activities = Activity.objects.activated().filter(
- organization_id=org,
- status=Activity.Status.END,
- category=Activity.ActivityCategory.COURSE,
- )
- # 只有小组成员才可以有学时
- members = Position.objects.activated().filter(
- pos__gte=1,
- person__identity=NaturalPerson.Identity.STUDENT,
- org=org,
- ).values_list("person", flat=True)
- all_participants = SQ.qsvlist(
- Participation.objects.activated(no_unattend=True)
- .filter(SQ.mq(Participation.activity, IN=activities),
- SQ.mq(Participation.person, IN=members)),
- Participation.person)
- participate_num = dict(Counter(all_participants))
- # 没有参加的参与次数设置为0
- participate_num.update(
- {id: 0 for id in members if id not in participate_num})
- return participate_num
-
-
-def check_post_and_modify(records: list, post_data: dict) -> MESSAGECONTEXT:
- """
- records和post_data分别为原先和更新后的list
- 检查post表单是否可以为这个course对应的内容,
- 如果可以,修改学时
- - 返回wrong|succeed
- - 不抛出异常
- :param records: 原本的学时数据
- :type records: list
- :param post_data: 由前端上传上来的修改结果
- :type post_data: dict
- :return: 检查结果
- :rtype: MESSAGECONTEXT
- """
- try:
- # 对每一条记录而言
- for record in records:
- # 选取id作为匹配键
- key = str(record.person.id)
- assert key in post_data.keys(), "提交的人员信息不匹配,请联系管理员!"
-
- # 读取小时数
- hours = post_data.get(str(key), -1)
- assert float(hours) >= 0, "学时数据为负数,请检查输入数据!"
- record.total_hours = float(hours)
- # 更新是否有效
- record.invalid = (record.total_hours <
- APP_CONFIG.least_record_hours)
-
- CourseRecord.objects.bulk_update(records, ["total_hours", "invalid"])
- return succeed("修改学时信息成功!")
- except AssertionError as e:
- # 此时相当于出现用户应该知晓的信息
- return wrong(str(e))
- except:
- return wrong("数据格式异常,请检查输入数据!")
-
-
-def finish_course(course):
- """
- 结束课程
- 设置课程状态为END 生成学时表并通知同学该课程已结束。
- """
- # 若存在课程活动未结束则无法结束课程。
- cur_activities = Activity.objects.activated().filter(
- organization_id=course.organization,
- category=Activity.ActivityCategory.COURSE).exclude(
- status__in=[
- Activity.Status.CANCELED,
- Activity.Status.END,
- ])
- if cur_activities.exists():
- return wrong("存在尚未结束的课程活动,请在所有课程活结束以后再结束课程。")
-
- try:
- # 取消发布每周定时活动
- course_times = course.time_set
- for course_time in course_times.all():
- course_time.end_week = course_time.cur_week
- course_time.save()
- except:
- return wrong("取消课程每周定时活动时失败,请联系管理员!")
- try:
- # 生成学时表
- participate_num = cal_participate_num(course)
- participants = NaturalPerson.objects.activated().filter(
- id__in=participate_num.keys())
- course_record_list = []
- for participant in participants:
- # 如果存在相同学期的学时表,则不创建
- if not CourseRecord.objects.current().filter(
- person=participant, course=course
- ).exists():
- course_record_list.append(CourseRecord(
- person=participant,
- course=course,
- attend_times=participate_num[participant.id],
- total_hours=2 * participate_num[participant.id],
- invalid=(2 * participate_num[participant.id] < 8),
- ))
- CourseRecord.objects.bulk_create(course_record_list)
- except:
- return wrong("生成学时表失败,请联系管理员!")
- try:
- # 通知课程小组成员该课程已结束
- title = f'课程结束通知!'
- msg = f'{course.name}在本学期的课程已结束!'
- receivers = SQ.qsvlist(participants, NaturalPerson.person_id)
- receivers = User.objects.filter(id__in=receivers)
- bulk_notification_create(
- receivers=list(receivers),
- sender=course.organization.get_user(),
- typename=Notification.Type.NEEDREAD,
- title=title,
- content=msg,
- URL=f"/viewCourse/?courseid={course.id}",
- to_wechat=dict(app=WechatApp.TO_PARTICIPANT),
- )
- # 设置课程状态
- except:
- return wrong("生成通知失败,请联系管理员!")
- course.status = Course.Status.END
- course.save()
- return succeed("结束课程成功!")
-
-
-def _excel_response(workbook: openpyxl.Workbook, file_name: str) -> HttpResponse:
- '''创建Excel文件回应,file_name为未转义的文件名(不含后缀)'''
- response = HttpResponse(content_type='application/vnd.ms-excel')
- response['Content-Disposition'] = f'attachment; filename={quote(file_name)}.xlsx'
- workbook.save(response)
- return response
-
-
-def _write_detail_sheet(detail_sheet: openpyxl.worksheet.worksheet.Worksheet,
- records: QuerySet[CourseRecord], title: str = '详情') -> None:
- '''将学时信息写入Excel文件的detail_sheet中'''
- # 注意,标题中的中文符号如:无法被解读
- detail_sheet.title = title
- # 从第一行开始写,因为Excel文件的行号是从1开始,列号也是从1开始
- detail_header = ['课程', '姓名', '学号', '次数', '学时', '学年', '学期', '有效']
- detail_sheet.append(detail_header)
- _M = CourseRecord
- for record in records.values_list(
- SQ.f(_M.course, Course.name), SQ.f(_M.extra_name),
- SQ.f(_M.person, NaturalPerson.name),
- SQ.f(_M.person, NaturalPerson.person_id, User.username),
- SQ.f(_M.attend_times), SQ.f(_M.total_hours),
- SQ.f(_M.year), SQ.f(_M.semester), SQ.f(_M.invalid),
- ):
- record_info = [
- record[0] or record[1],
- *record[2:6],
- f'{record[6]}-{record[6] + 1}',
- '春' if record[7] == Semester.SPRING else '秋',
- '否' if record[8] else '是',
- ]
- # 将每一个对象的所有字段的信息写入一行内
- detail_sheet.append(record_info)
-
-
-def download_course_record(course: Course = None, year: int = None, semester: Semester = None) -> HttpResponse:
- """返回需要导出的学时信息文件
- course:
- 提供course时为单个课程服务,只导出该课程的相关人员的学时信息
- 不提供时下载所有学时信息,注意,只有相关负责老师可以访问!
- :param course: 所选择的课程, defaults to None
- :type course: Course, optional
- :param year: 所选择的学年, defaults to None
- :type year: int, optional
- :param semester: 所选择的学期, defaults to None
- :type semester: Semester, optional
- :return: 返回下载的文件数据
- :rtype: HttpResponse
- """
- wb = openpyxl.Workbook() # 生成一个工作簿(即一个Excel文件)
- wb.encoding = 'utf-8'
- # 学时筛选内容
- filter_kws = {}
- if course is not None:
- filter_kws[SQ.f(CourseRecord.course)] = course
- if year is not None:
- filter_kws[SQ.f(CourseRecord.year)] = year
- if semester is not None:
- filter_kws[SQ.f(CourseRecord.semester)] = semester
-
- if course is not None:
- # 助教下载自己课程的学时
- records = CourseRecord.objects.filter(**filter_kws)
- _write_detail_sheet(wb.active, records)
- now = datetime.now().strftime('%Y-%m-%d %H:%M')
- return _excel_response(wb, f'{course.name}-{now}')
-
- # 老师下载所有课程的学时
- # 获取第一个工作表(detail_sheet)
- detail_sheet = wb.active
- # 设置明细和汇总两个sheet的相关信息
- total_sheet: openpyxl.worksheet.worksheet.Worksheet = wb.create_sheet('汇总', 0)
- first_line = ['学号', '姓名', '总有效学时', '总无效学时']
- first_line.extend(Course.CourseType.labels)
- first_line.append('其他')
- total_sheet.append(first_line)
-
- # 下载所有学时信息,包括无效学时
- all_person = NaturalPerson.objects.activated().filter(
- identity=NaturalPerson.Identity.STUDENT)
-
- # 汇总表信息,姓名,学号,总学时
- relate = SQ.Reverse(CourseRecord.person)
- person_record = all_person.annotate(
- record_hours=Sum(SQ.f(relate, CourseRecord.total_hours),
- filter=SQ.mq(relate, invalid=False, **filter_kws)),
- invalid_hours=Sum(SQ.f(relate, CourseRecord.total_hours),
- filter=SQ.mq(relate, invalid=True, **filter_kws)),
- ).order_by(SQ.f(NaturalPerson.person_id, User.username))
-
- def _sum_hours(records: QuerySet[CourseRecord]) -> float:
- agg = records.filter(**filter_kws).aggregate(sum=Sum('total_hours'))
- return agg['sum'] or 0
-
- for person in person_record.select_related(SQ.f(NaturalPerson.person_id)):
- line = [person.person_id.username, person.name,
- person.record_hours or 0,
- person.invalid_hours or 0]
- valid_records = SQ.sfilter(CourseRecord.person, person).exclude(invalid=True)
- # 计算每个类别的学时
- for course_type in list(Course.CourseType):
- line.append(_sum_hours(valid_records.filter(course__type=course_type)))
- # 计算没有对应Course的学时
- line.append(_sum_hours(valid_records.filter(course__isnull=True)))
- total_sheet.append(line)
-
- # 详细信息
- records = SQ.mfilter(CourseRecord.person, IN=all_person).filter(**filter_kws)
- order = SQ.f(CourseRecord.person, NaturalPerson.person_id, User.username)
- _write_detail_sheet(detail_sheet, records.order_by(order))
- now = datetime.now().strftime('%Y-%m-%d %H:%M')
- return _excel_response(wb, f'学时汇总-{now}')
-
-
-def download_select_info(single_course: Course | None = None):
- """
- 下载选课信息
- single_course:
- 提供single_course时为单个课程服务,只导出该课程的相关人员的选课信息
- 不提供时下载所有选课信息,注意,此时只有相关负责老师可以访问!
- 不提供手动选课人员名单。
- :return: 返回下载的文件数据
- :rtype: HttpResponse
- """
- wb = openpyxl.Workbook() # 生成一个工作簿(即一个Excel文件)
- wb.encoding = 'utf-8'
- sheet_header = ['姓名', '学号']
- if single_course is not None:
- courses = [single_course]
- else:
- courses = Course.objects.activated()
- # 为每一门课创建一个新的sheet
- for course in courses:
- # 生成新的sheet,并设置表头
- sheet = wb.create_sheet(title=f'{course.name}')
- sheet.append(sheet_header)
- # 导出相关信息,不包括手动选课
- lucky_ones = CourseParticipant.objects.filter(
- course=course, status=CourseParticipant.Status.SUCCESS)
- for info in lucky_ones.values_list(
- SQ.f(CourseParticipant.person, NaturalPerson.name),
- SQ.f(CourseParticipant.person, NaturalPerson.person_id, User.username)
- ):
- person_info = [
- info[0],
- info[1]
- ]
- sheet.append(person_info)
- # 删除多余的第一个sheet
- wb.remove_sheet(wb.active)
- # 设置文件名
- semester = "春" if courses[0].semester == Semester.SPRING else "秋"
- year = (courses[0].year + 1) if semester == "春" else courses[0].year
- ctime = datetime.now().strftime('%Y-%m-%d %H:%M')
- if single_course is not None:
- file_name = f'{year}{semester}{course.name}选课名单-{ctime}'
- else:
- file_name = f'{year}{semester}选课名单汇总-{ctime}'
- # 保存并返回
- return _excel_response(wb, file_name)
diff --git a/app/course_views.py b/app/course_views.py
deleted file mode 100644
index b26d7825d..000000000
--- a/app/course_views.py
+++ /dev/null
@@ -1,744 +0,0 @@
-"""
-course_views.py
-
-选课页面: selectCourse
-课程详情页面: viewCourse
-"""
-from app.views_dependency import *
-from app.models import (
- NaturalPerson,
- Semester,
- Activity,
- Course,
- CourseRecord,
-)
-from app.course_utils import (
- cancel_course_activity,
- create_single_course_activity,
- modify_course_activity,
- registration_status_change,
- course_to_display,
- create_course,
- cal_participate_num,
- check_post_and_modify,
- finish_course,
- download_course_record,
- download_select_info,
-)
-from app.utils import get_person_or_org
-
-from datetime import datetime
-
-from django.db import transaction
-
-from utils.config.cast import str_to_time
-
-
-__all__ = [
- 'editCourseActivity',
- 'addSingleCourseActivity',
- 'showCourseActivity',
- 'showCourseRecord',
- 'selectCourse',
- 'viewCourse',
- 'outputRecord',
- 'outputSelectInfo',
-]
-
-APP_CONFIG = CONFIG.course
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def editCourseActivity(request: HttpRequest, aid: int):
- """
- 编辑单次书院课程活动,addActivity的简化版
-
- :param request: 修改单次课程活动的请求
- :type request: HttpRequest
- :param aid: 待修改的课程活动id
- :type aid: int
- :return: 返回"修改课程活动"页面
- :rtype: HttpResponse
- """
- try:
- aid = int(aid)
- activity = Activity.objects.get(id=aid)
- except:
- return redirect(message_url(wrong("活动不存在!")))
-
- # 检查用户身份
- html_display = {}
- if request.user.is_person():
- my_messages.transfer_message_context(
- utils.user_login_org(request, activity.organization_id),
- html_display,
- )
- if html_display['warn_code'] == 1:
- return redirect(message_url(html_display))
- else:
- # 登陆成功,重新加载
- return redirect(message_url(html_display, request.get_full_path()))
-
- me = utils.get_person_or_org(request.user)
-
- if activity.organization_id != me:
- return redirect(message_url(wrong("无法修改其他课程小组的活动!")))
-
- # 这个页面只能修改书院课程活动(category=1)
- if activity.category != Activity.ActivityCategory.COURSE:
- return redirect(message_url(wrong('当前活动不是书院课程活动!'),
- f'/viewActivity/{activity.id}'))
- # 课程活动只能在发布前进行修改
- if activity.status != Activity.Status.UNPUBLISHED:
- return redirect(message_url(wrong('当前活动状态不允许修改!'),
- f'/viewActivity/{activity.id}'))
-
- my_messages.transfer_message_context(request.GET, html_display)
-
- if request.method == "POST" and request.POST:
- # 修改活动
- try:
- # 只能修改自己的活动
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- assert activity.organization_id == me, "无法修改其他课程小组的活动!"
- modify_course_activity(request, activity)
- succeed("修改成功。", html_display)
- except AssertionError as err_info:
- return redirect(message_url(wrong(str(err_info)),
- request.get_full_path()))
- except Exception as e:
- print(e)
- return redirect(message_url(wrong("修改课程活动失败!"),
- request.get_full_path()))
-
- # 前端使用量
- html_display["applicant_name"] = me.oname
- html_display["app_avatar_path"] = me.get_user_ava()
- bar_display = utils.get_sidebar_and_navbar(request.user, "修改课程活动")
-
- # 前端使用量,均可编辑
- title = utils.escape_for_templates(activity.title)
- location = utils.escape_for_templates(activity.location)
- start = activity.start.strftime("%Y-%m-%d %H:%M")
- end = activity.end.strftime("%Y-%m-%d %H:%M")
- # introduction = escape_for_templates(activity.introduction) # 暂定不需要简介
- edit = True # 前端据此区分是编辑还是创建
- publish_day = activity.publish_day
- need_apply = activity.need_apply
- # 判断本活动是否为长期定时活动
- course_time_tag = (activity.course_time is not None)
-
- return render(request, "course/lesson_add.html", locals())
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def addSingleCourseActivity(request: HttpRequest):
- """
- 创建单次书院课程活动,addActivity的简化版
-
- :param request: 创建单次课程活动的请求
- :type request: HttpRequest
- :return: 返回"发起单次课程活动"页面
- :rtype: HttpResponse
- """
- # 检查用户身份
- html_display = {}
- me = utils.get_person_or_org(request.user) # 这里的me应该为小组账户
- if not request.user.is_org() or me.otype.otype_name != APP_CONFIG.type_name:
- return redirect(message_url(wrong('书院课程小组账号才能开设课程活动!')))
- if me.oname == CONFIG.yqpoint.org_name:
- return redirect("/showActivity/") # TODO: 可以重定向到书院课程聚合页面
-
- # 检查是否已经开课
- try:
- course = Course.objects.activated().get(organization=me)
- except:
- return redirect(message_url(wrong('本学期尚未开设书院课程,请先发起选课!'),
- '/showCourseActivity/'))
- if course.status != Course.Status.STAGE2 and course.status != Course.Status.SELECT_END:
- return redirect(message_url(wrong('只有补退选开始或选课结束以后才能增加课时!'),
- '/showCourseActivity/'))
-
- my_messages.transfer_message_context(request.GET, html_display)
-
- if request.method == "POST" and request.POST:
- # 创建活动
- try:
- with transaction.atomic():
- aid, created = create_single_course_activity(request)
- if not created:
- return redirect(message_url(
- succeed('存在信息相同的课程活动,已为您自动跳转!'),
- f'/viewActivity/{aid}'))
- return redirect(message_url(succeed('活动创建成功!'),
- f'/showCourseActivity/'))
- except AssertionError as err_info:
- return redirect(message_url(wrong(str(err_info)), request.path))
- except Exception as e:
- return redirect(message_url(wrong("课程活动创建失败!"), request.path))
-
-
-
- # 前端使用量
- html_display["applicant_name"] = me.oname
- html_display["app_avatar_path"] = me.get_user_ava()
- bar_display = utils.get_sidebar_and_navbar(request.user, "发起单次课程活动")
- edit = False # 前端据此区分是编辑还是创建
- course_time_tag = False
-
- return render(request, "course/lesson_add.html", locals())
-
-
-@login_required(redirect_field_name='origin')
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def showCourseActivity(request: HttpRequest):
- """
- 筛选本学期已结束的课程活动、未开始的课程活动,在课程活动聚合页面进行显示。
- """
-
- # Sanity check and start a html_display.
- html_display = {}
- me = get_person_or_org(request.user) # 获取自身
-
- if not request.user.is_org() or me.otype.otype_name != APP_CONFIG.type_name:
- return redirect(message_url(wrong('只有书院课程组织才能查看此页面!')))
- my_messages.transfer_message_context(request.GET, html_display)
-
- all_activity_list = (
- Activity.objects
- .activated()
- .filter(organization_id=me)
- .filter(category=Activity.ActivityCategory.COURSE)
- .order_by("-start")
- )
-
- future_activity_list = (
- all_activity_list.filter(
- status__in=[
- Activity.Status.UNPUBLISHED,
- Activity.Status.REVIEWING,
- Activity.Status.APPLYING,
- Activity.Status.WAITING,
- Activity.Status.PROGRESSING,
- ]
- )
- )
-
- finished_activity_list = (
- all_activity_list
- .filter(
- status__in=[
- Activity.Status.END,
- Activity.Status.CANCELED,
- ]
- )
- .order_by("-end")
- ) # 本学期的已结束活动(包括已取消的)
-
- bar_display = utils.get_sidebar_and_navbar(
- request.user, navbar_name="我的活动")
-
- if request.method == "GET":
- html_display["warn_code"], html_display["warn_message"] = my_messages.get_request_message(request)
-
- # 取消单次活动
- if request.method == "POST" and request.POST:
- cancel_all = False
- # 获取待取消的活动
- try:
- aid = int(request.POST.get("cancel-action"))
- post_type = str(request.POST.get("post_type"))
- if post_type == "cancel_all":
- cancel_all = True
- activity = Activity.objects.get(id=aid)
- except:
- return redirect(message_url(wrong('遇到不可预料的错误。如有需要,请联系管理员解决!'), request.path))
-
- if activity.organization_id != me:
- return redirect(message_url(wrong('您没有取消该课程活动的权限!'), request.path))
-
- if activity.status in [
- Activity.Status.REJECT,
- Activity.Status.ABORT,
- Activity.Status.END,
- Activity.Status.CANCELED,
- ]:
- return redirect(message_url(wrong('该课程活动已结束,不可取消!'), request.path))
-
- assert activity.status not in [
- Activity.Status.REVIEWING,
- # Activity.Status.APPLYING,
- ], "课程活动状态非法" # 课程活动不应出现审核状态
-
- # 取消活动
- with transaction.atomic():
- activity = Activity.objects.select_for_update().get(id=aid)
- error = cancel_course_activity(request, activity, cancel_all)
-
- # 无返回值表示取消成功,有则失败
- if error is None:
- html_display["warn_code"] = 2
- html_display["warn_message"] = "成功取消活动。"
- else:
- return redirect(message_url(wrong(error)), request.path)
-
- return render(request, "course/show_course_activity.html", locals())
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def showCourseRecord(request: UserRequest) -> HttpResponse:
- """
- 展示及修改学时数据
- 在开启修改功能前,显示本学期已完成的所有课程活动的学生的参与次数
- 开启修改功能后,自动创建学时表,并且允许修改学时
- :param request: 请求
- :type request: HttpRequest
- :return: 下载导出的学时文件或者返回前端展示的数据
- :rtype: HttpResponse
- """
- # ----身份检查----
- me = utils.get_person_or_org(request.user) # 获取自身
- if request.user.is_person():
- return redirect(message_url(wrong('学生账号不能访问此界面!')))
- if me.otype.otype_name != APP_CONFIG.type_name:
- return redirect(message_url(wrong('非书院课程组织账号不能访问此界面!')))
-
- # 提取课程,后端保证每个组织只有一个Course字段
-
- # 获取课程开设筛选信息
- year = GLOBAL_CONFIG.acadamic_year
- semester = GLOBAL_CONFIG.semester
-
- course = Course.objects.activated(noncurrent=False).filter(organization=me)
- if len(course) == 0: # 尚未开课的情况
- return redirect(message_url(wrong('没有检测到该组织本学期开设的课程。')))
- # TODO: 报错 这是代码不应该出现的bug
- assert len(course) == 1, "检测到该组织的课程超过一门,属于不可预料的错误,请及时处理!"
- course = course.first()
-
- # 是否可以编辑
- editable = course.status == Course.Status.END
- # 获取前端可能的提示
- messages = my_messages.transfer_message_context(request.GET)
-
- # -------- POST 表单处理 --------
- # 默认状态为正常
- if request.method == "POST" and request.POST:
- post_type = str(request.POST.get("post_type", ""))
- if not editable:
- # 由于未开放修改功能时前端无法通过表格和按钮修改和提交,
- # 所以如果出现POST请求,则为非法情况
- if post_type == "end":
- with transaction.atomic():
- course = Course.objects.select_for_update().get(id=course.id)
- messages = finish_course(course)
- return redirect(message_url(messages, request.path))
- elif post_type == "download":
- return redirect(message_url(
- wrong('请先结课再下载学时数据!'), request.path))
- else:
- return redirect(message_url(
- wrong('学时修改尚未开放。如有疑问,请联系管理员!'), request.path))
- # 获取记录的QuerySet
- record_search = CourseRecord.objects.current().filter(course=course)
- # 导出学时为表格
- if post_type == "download":
- if not record_search.exists():
- return redirect(message_url(
- wrong('未查询到相应课程记录,请联系管理员。'), request.path))
- return download_course_record(course, year, semester)
- # 不是其他post类型时的默认行为
- with transaction.atomic():
- # 检查信息并进行修改
- record_search = record_search.select_for_update()
- messages = check_post_and_modify(record_search, request.POST)
- # TODO: 发送微信消息?不一定需要
-
- # -------- GET 部分 --------
- # 如果进入这个页面时课程的状态(Course.Status)为未结束,那么只能查看不能修改,此时从函数读取
- # 每次进入都获取形如{id: times}的字典,这里id是naturalperson的主键id而不是userid
- participate_raw = cal_participate_num(course)
- if not editable:
- convert_dict = participate_raw # 转换为字典方便查询, 这里已经是字典了
- # 选取人选
- participant_list = NaturalPerson.objects.activated().filter(
- id__in=convert_dict.keys()
- )
-
- # 转换为前端使用的list
- records_list = [
- {
- "pk": person.id,
- "name": person.name,
- "grade": person.stu_grade,
- "avatar": person.get_user_ava(),
- "times": convert_dict[person.id], # 参与次数
- } for person in participant_list
- ]
-
- # 否则可以修改表单,从CourseRecord读取
- else:
-
- records_list = []
- with transaction.atomic():
- # 查找此课程本学期所有成员的学时表
- record_search = CourseRecord.objects.current().filter(
- course=course,
- ).select_for_update().select_related(
- "person"
- ) # Prefetch person to use its name, stu_grade and avatar. Help speed up.
-
- # 前端循环list
- for record in record_search:
- # 每次都需要更新一下参与次数,避免出现手动调整签到但是未能记录在学时表的情况
- record.attend_times = participate_raw[record.person.id]
- if int(record.total_hours) != record.total_hours:
- record.total_hours = int(record.total_hours)
- records_list.append({
- "pk": record.person.id,
- "name": record.person.name,
- "grade": record.person.stu_grade,
- "avatar": record.person.get_user_ava(),
- "times": record.attend_times,
- "hours": record.total_hours
- })
- CourseRecord.objects.bulk_update(record_search, ["attend_times"])
- # 如果点击提交学时按钮,修改数据库之后,跳转至已结束的活动界面
- if request.method == "POST":
- return(redirect("/showCourseActivity"))
-
- # 前端呈现信息,用于展示
- course_info = {
- 'course': course.name,
- 'year': year,
- 'semester': "春季" if semester == Semester.SPRING else "秋季",
- }
- bar_display = utils.get_sidebar_and_navbar(request.user, "课程学时")
-
- render_context = dict(
- course_info=course_info, records_list=records_list,
- editable=editable,
- bar_display=bar_display, messages=messages,
- )
- return render(request, "course/course_record.html", render_context)
-
-
-@login_required(redirect_field_name="origin")
-@utils.check_user_access(redirect_url="/logout/")
-@logger.secure_view()
-def selectCourse(request: HttpRequest):
- """
- 学生选课的聚合页面,包括:
- 1. 所有开放课程的选课信息
- 2. 在预选和补退选阶段,学生可以通过点击课程对应的按钮实现选课或者退选,
- 且点击后页面显示发生相应的变化
- 3. 显示选课结果
-
- 用户权限:学生和老师可以进入,组织不能进入;只有学生可以进行选课
-
- :param request: POST courseid=