-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
1403 lines (1177 loc) · 61 KB
/
Copy pathapp.py
File metadata and controls
1403 lines (1177 loc) · 61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from flask import Flask, render_template, request, redirect, url_for, session, flash, abort, jsonify, g, current_app
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import or_, and_
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from datetime import datetime, timedelta
from functools import wraps
import os
from flask_mail import Mail, Message
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadTimeSignature
import re
import requests
import json
import logging
from dotenv import load_dotenv
import os
from email.header import Header
from email.utils import formataddr
import traceback
load_dotenv()
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key-here')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAIL_SERVER'] = 'smtp.163.com'
app.config['MAIL_PORT'] = 465
app.config['MAIL_USE_TLS'] = False
app.config['MAIL_USE_SSL'] = True
app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME').strip()
app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD').strip()
app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER').strip()
app.config['MAIL_DEFAULT_CHARSET'] = 'utf-8'
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///site.db')
app.config['XFYUN_SPARK_X1_API_KEY'] = os.environ.get('XFYUN_SPARK_X1_API_KEY')
app.config['XFYUN_SPARK_X1_HTTP_URL'] = "https://spark-api-open.xf-yun.com/v2/chat/completions" # X1 的固定 URL
app.config['UPLOAD_FOLDER'] = 'static/profile_pictures' # For profile pictures
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10, # 连接池中保持的连接数,可以根据并发用户量调整
'max_overflow': 20, # 允许超过 pool_size 的额外连接数,处理短期高峰
'pool_recycle': 3600, # 连接在池中保持打开的最大秒数(1小时),防止数据库超时断开
'pool_pre_ping': True, # 每次从池中取出连接时,先测试其可用性
'pool_timeout': 30 # 获取连接的超时时间(秒)
}
print(f"DEBUG: DATABASE_URL being used: {app.config['SQLALCHEMY_DATABASE_URI']}")
# 调试信息打印
# logging.basicConfig(level=logging.INFO)
# logger = logging.getLogger(__name__)
# logger.info(f"DEBUG: Loaded XFYUN_SPARK_X1_API_KEY: {app.config['XFYUN_SPARK_X1_API_KEY']}")
# Initialize extensions
db = SQLAlchemy(app)
mail = Mail(app)
serializer = URLSafeTimedSerializer(app.config['SECRET_KEY'])
# 设置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Models
class SparkX1Client:
def __init__(self, api_key, http_url):
# 注意:X1 的 API key 已经是 'Bearer YOUR_KEY' 这种格式
self.API_KEY = api_key
self.HTTP_URL = http_url
# 类似 X1_http.py 中的 get_answer
def get_spark_response(self, prompt_text):
full_response = "" # 存储返回结果
is_first_content = True # 首帧标识
try:
# 初始化请求体
headers = {
'Authorization': self.API_KEY, # X1 的鉴权方式
'content-type': "application/json"
}
body = {
"model": "x1", # X1 模型名
"user": "flask_app_user", # 可以是任意用户标识
"messages": [{"role": "user", "content": prompt_text}], #
"stream": True, # 流式响应
# "tools": [ # 如果需要工具调用,可以保留,否则移除
# {
# "type": "web_search",
# "web_search": {
# "enable": True,
# "search_mode":"deep"
# }
# }
# ]
}
response = requests.post(url=self.HTTP_URL, json=body, headers=headers, stream=True, timeout=90) # 增加超时时间
response.raise_for_status() # 检查 HTTP 错误状态码
for chunks in response.iter_lines(): #
# 打印返回的每帧内容 (仅用于调试,生产环境可移除)
# print(chunks)
if chunks and b'[DONE]' not in chunks: #
try:
# 讯飞星火流式响应的格式是 'data: {json}'
data_line = chunks.decode('utf-8').strip()
if data_line.startswith('data:'): #
data_org = data_line[5:].strip() #
chunk = json.loads(data_org) #
# 检查 API 响应的错误码,X1_http.py 示例中没有,但一般建议加
if 'code' in chunk.get('header', {}) and chunk['header']['code'] != 0:
error_msg = f"Spark X1 API 调用失败,错误码: {chunk['header']['code']}, 详情: {chunk['header']['message']}"
logger.error(error_msg)
return json.dumps({"error": error_msg})
text = chunk['choices'][0]['delta'] #
# 判断思维链状态并输出 (X1_http.py 中的逻辑)
if 'reasoning_content' in text and text['reasoning_content']: #
logger.info(f"思维链内容: {text['reasoning_content']}")
# 如果你想把思维链内容也保存到结果,可以加到 full_response
# full_response += text['reasoning_content']
# 判断最终结果状态并输出 (X1_http.py 中的逻辑)
if 'content' in text and text['content']: #
content = text['content'] #
if is_first_content: #
logger.info("\n*******************以上为思维链内容,模型回复内容如下********************\n")
is_first_content = False #
full_response += content #
except json.JSONDecodeError as e:
logger.error(f"解析讯飞星火流式响应时 JSON 错误: {e}, 原始行: {chunks.decode('utf-8')}")
return json.dumps({"error": f"解析流式响应 JSON 错误: {str(e)}"})
except KeyError as e:
logger.error(f"讯飞星火响应结构不符合预期: {e}, 原始 chunk: {chunk}")
return json.dumps({"error": f"讯飞星火响应结构错误: {str(e)}"})
return full_response
except requests.exceptions.RequestException as e:
logger.error(f"调用讯飞星火 X1 HTTP API 失败: {e}")
return json.dumps({"error": f"调用讯飞星火 X1 HTTP API 失败: {str(e)}"})
except Exception as e:
logger.error(f"调用讯飞星火 X1 时发生未知异常: {e}")
return json.dumps({"error": f"调用讯飞星火 X1 时发生未知异常: {str(e)}"})
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
profile_picture = db.Column(db.String(20), nullable=True) # Changed to nullable
bio = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, nullable=True) # Changed to nullable
data = db.relationship('UserData',back_populates='user')
is_admin = db.Column(db.Boolean, default=False)
created_teams = db.relationship('Team', backref='creator', lazy=True, foreign_keys='Team.creator_id')
# 添加 set_password 方法
def __repr__(self):
return f"User('{self.username}', '{self.email}')"
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = session.get('user_id')
user = User.query.get(user_id)
if not user or not user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated_function
class UserData(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
deadline = db.Column(db.DateTime, nullable=True) # New field
ai_analysis = db.Column(db.Text, nullable=True) # New field
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
user = db.relationship('User',back_populates='data')
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # 关联团队
team = db.relationship('Team', backref='tasks')
team_editable = db.Column(db.Boolean, default=False)
# 新增字段:表示任务是否完成,默认为 False
is_completed = db.Column(db.Boolean, default=False, nullable=False)
# 新增字段:任务完成时间,默认为 None
completed_at = db.Column(db.DateTime, nullable=True)
def get_time_remaining(self):
if not self.deadline:
return None
now = datetime.utcnow()
if now > self.deadline:
return "Overdue"
time_left = self.deadline - now
days = time_left.days
hours = time_left.seconds // 3600
minutes = (time_left.seconds % 3600) // 60
return f"{days}d {hours}h {minutes}m"
def analyze_with_ai(self):
api_key = app.config['XFYUN_SPARK_X1_API_KEY']
spark_http_url = app.config['XFYUN_SPARK_X1_HTTP_URL']
if not all([api_key, spark_http_url]):
error_msg = "讯飞星火 X1 API 凭据或 URL 未配置。请检查 .env 文件和 app.py 配置。"
logger.error(error_msg)
return {"error": error_msg}
try:
logger.info(f"Starting AI analysis for task: {self.title} using Spark X1 HTTP API")
# 构建提示词
prompt = f"""
请分析以下任务并提供详细建议,请以JSON格式返回:
任务标题: {self.title}
任务描述: {self.content}
截止日期: {self.deadline.strftime('%Y-%m-%d %H:%M') if self.deadline else '未设置'}
请提供以下分析,并以JSON格式返回:
{{
"complexity": "任务复杂度(简单/中等/复杂)",
"steps": ["步骤1", "步骤2", "步骤3"],
"estimated_time": "预计所需时间",
"priority": "优先级(高/中/低)",
"challenges": ["挑战1", "挑战2"],
"solutions": ["解决方案1", "解决方案2"]
}}
注意:请直接返回JSON格式的结果,不要包含其他内容。
"""
# 创建讯飞星火 X1 HTTP 客户端实例
spark_client = SparkX1Client(api_key, spark_http_url)
# 获取星火大模型的响应
response_str = spark_client.get_spark_response(prompt)
logger.info(f"Spark X1 原始响应: {response_str}")
if not response_str:
error_msg = "讯飞星火 X1 没有返回任何输出"
logger.error(error_msg)
return {"error": error_msg}
try:
# 尝试从输出中提取 JSON 部分 (保留原有逻辑,以防模型输出包含额外文本)
json_match = re.search(r'```json\s*(\{[\s\S]*?\})\s*```', response_str)
if json_match:
json_str = json_match.group(1)
else:
json_match = re.search(r'(\{(?:[^{}]|(?:\{[^{}]*\}))*\})', response_str)
if json_match:
json_str = json_match.group(1)
else:
cleaned_output = re.sub(r'^.*?(\{|\[)', r'\1', response_str, flags=re.DOTALL)
cleaned_output = re.sub(r'(\}|\]).*$', r'\1', cleaned_output, flags=re.DOTALL)
if cleaned_output and (cleaned_output.startswith('{') or cleaned_output.startswith('[')):
json_str = cleaned_output
else:
raise json.JSONDecodeError("No valid JSON found in output", response_str, 0)
json_str = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', json_str)
analysis_json = json.loads(json_str)
self.ai_analysis = json.dumps(analysis_json, ensure_ascii=False)
db.session.commit()
logger.info("AI analysis saved to database")
return analysis_json
except json.JSONDecodeError as e:
error_msg = f"无法解析讯飞星火 X1 输出为 JSON: {str(e)}"
logger.error(error_msg)
logger.error(f"原始输出: {response_str}")
self.ai_analysis = response_str
db.session.commit()
logger.info("AI analysis saved as text to database")
return {"error": error_msg}
except Exception as e:
error_msg = f"AI 分析过程出错: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}
class Team(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
members = db.relationship('User', secondary='team_members', backref='teams')
password = db.Column(db.String(255), nullable=False) # 6位数字密码,必填
creator_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
class TeamMember(db.Model):
__tablename__ = 'team_members'
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), primary_key=True)
# Decorators
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
flash('您需要登录才能访问此页面。', 'info')
# 重定向到登录页面,并将当前尝试访问的 URL 作为 'next' 参数传递
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def validate_password(password):
"""Validate password strength"""
if len(password) < 8:
return False
if not re.search(r"[A-Z]", password):
return False
if not re.search(r"[a-z]", password):
return False
if not re.search(r"\d", password):
return False
return True
# Routes
@app.route('/')
def home():
return render_template('home.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
# Input validation
if not username or not email or not password:
flash('所有字段均为必填字段。', 'danger')
return render_template('register.html')
if not validate_password(password):
flash('密码必须至少包含8个字符,并且包含大写字母、小写字母和数字。', 'danger')
return render_template('register.html')
if password != confirm_password:
flash('密码不匹配。', 'danger')
return render_template('register.html')
if User.query.filter_by(username=username).first():
flash('用户已存在。', 'danger')
return render_template('register.html')
if User.query.filter_by(email=email).first():
flash('电子邮件已被注册。', 'danger')
return render_template('register.html')
# Create new user
hashed_password = generate_password_hash(password)
new_user = User(username=username, email=email, password=hashed_password)
try:
db.session.add(new_user)
db.session.commit()
flash('注册成功!请登录。', 'success')
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
flash('注册发生出错。', 'danger')
logging.error(f"注册时错误: {e}")
return render_template('register.html')
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember', False)
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password, password):
session['user_id'] = user.id
if remember:
session.permanent = True
app.permanent_session_lifetime = timedelta(days=7)
flash('登录成功!', 'success')
return redirect(url_for('dashboard'))
else:
flash('用户名或密码无效。', 'danger')
return render_template('login.html')
#团队概况功能
@app.route('/team_detail')
@login_required
def team_detail():
# 获取当前用户加入的所有团队
# 使用 join 加载 creator 信息,避免 N+1 查询问题
teams = Team.query.join(TeamMember).filter(
TeamMember.user_id == g.user.id
).options(
db.joinedload(Team.creator), # 预加载团队创建者
db.joinedload(Team.members), # 预加载团队成员的用户信息
db.joinedload(Team.tasks) # 预加载团队任务
).all()
return render_template('team_detail.html', teams=teams)
# 组队功能
@app.route('/create_team', methods=['GET', 'POST'])
@login_required
def create_team():
if request.method == 'POST':
team_name = request.form.get('team_name')
description = request.form.get('description')
password = request.form.get('password') # 获取密码字段 ✅
# 检查团队名称是否已存在
if Team.query.filter_by(name=team_name).first():
flash('团队名称已存在,请选择其他名称。', 'danger')
return redirect(url_for('create_team'))
if not password or not password.isdigit() or len(password) != 6:
flash('团队密码必须是6位数字。', 'danger')
return render_template('team.html')
new_team = Team(
name=team_name,
description=description,
password=generate_password_hash(password),
# **核心修改:设置 creator_id 为当前登录用户的 ID**
creator_id=g.user.id
)
try:
db.session.add(new_team)
db.session.commit()
# 将创建者自动加入到团队成员中
g.user.teams.append(new_team)
db.session.commit()
flash(f'团队 "{team_name}" 创建成功并已加入!', 'success')
return redirect(url_for('dashboard')) # 创建成功后重定向到团队概览页
except Exception as e:
db.session.rollback()
flash(f'创建团队时发生错误: {e}', 'danger')
return redirect(url_for('create_team'))
return render_template('team.html')
# 加入队伍功能
@app.route('/join_team/<int:team_id>', methods=['GET', 'POST'])
@login_required
def join_team(team_id):
user = User.query.get_or_404(session['user_id'])
team = Team.query.get_or_404(team_id)
if team in user.teams:
flash(f'你已经加入了团队 "{team.name}"。', 'info')
return redirect(url_for('profile'))
if request.method == 'POST':
input_password = request.form.get('password')
if not check_password_hash(team.password, input_password):
flash('密码错误,无法加入该团队。', 'danger')
return redirect(url_for('join_team', team_id=team_id))
user.teams.append(team)
db.session.commit()
flash(f'成功加入团队 "{team.name}"!', 'success')
return redirect(url_for('profile'))
return render_template('join_team.html', team=team)
# 离开队伍功能
@app.route('/leave_team/<int:team_id>')
@login_required
def leave_team(team_id):
user = User.query.get_or_404(session['user_id'])
team = Team.query.get_or_404(team_id)
if team in user.teams:
user.teams.remove(team)
try:
db.session.commit()
flash(f'您已离开团队:{team.name}.', 'success')
except Exception as e:
db.session.rollback()
flash('离开队伍出错。', 'danger')
else:
flash('您不是此团队的成员。', 'warning')
return redirect(url_for('profile'))
@app.route('/disband_team/<int:team_id>', methods=['POST'])
@login_required
def disband_team(team_id):
team = Team.query.get_or_404(team_id)
if g.user.id != team.creator_id:
flash('您没有权限解散此团队。', 'danger')
return redirect(url_for('team_detail'))
try:
TeamMember.query.filter_by(team_id=team.id).delete()
UserData.query.filter_by(team_id=team.id).delete() # 根据需求决定是否删除任务
db.session.delete(team)
db.session.commit()
flash(f'团队 "{team.name}" 已成功解散。', 'success')
return redirect(url_for('dashboard'))
except Exception as e:
db.session.rollback()
flash(f'解散团队时发生错误: {e}', 'danger')
logger.error(f"解散团队 {team_id} 时发生错误: {e}")
return redirect(url_for('team_detail'))
@app.route('/kick_member/<int:team_id>/<int:member_id>', methods=['POST'])
@login_required
def kick_member(team_id, member_id):
team = Team.query.get_or_404(team_id)
member_to_kick = User.query.get_or_404(member_id)
if g.user.id != team.creator_id:
flash('您没有权限踢出此成员。', 'danger')
return redirect(url_for('team_detail'))
if g.user.id == member_to_kick.id:
flash('您不能将自己踢出团队。', 'warning')
return redirect(url_for('team_detail'))
try:
team_member_record = TeamMember.query.filter_by(team_id=team.id, user_id=member_to_kick.id).first()
if team_member_record:
db.session.delete(team_member_record)
db.session.commit()
flash(f'成员 "{member_to_kick.username}" 已成功从团队 "{team.name}" 中踢出。', 'success')
else:
flash(f'成员 "{member_to_kick.username}" 不在该团队中。', 'info')
return redirect(url_for('team_detail'))
except Exception as e:
db.session.rollback()
flash(f'踢出成员时发生错误: {e}', 'danger')
logger.error(f"将用户 {member_id} 从团队 {team_id} 踢出时发生错误: {e}")
return redirect(url_for('team_detail'))
@app.route('/logout')
@login_required
def logout():
session.clear()
flash('您已登出。', 'info')
return redirect(url_for('home'))
# master page
@app.route('/master', methods=['GET'])
@login_required
@admin_required
def master(): # 函数名从 admin_master 改为 master
logger.info(f"管理员 {g.user.username} 访问管理员主页。")
users = User.query.all()
# 假设这里您想要加载成员和创建者,如您在提示中所示
teams = Team.query.options(db.joinedload(Team.members), db.joinedload(Team.creator)).all()
all_users = User.query.order_by(User.username).all() # 用于添加成员下拉菜单
return render_template('master.html', users=users, teams=teams, all_users=all_users)
@app.route('/dashboard')
@login_required
def dashboard():
user_id = session.get('user_id')
if not user_id:
flash('请先登录!', 'danger')
return redirect(url_for('login'))
user = User.query.get_or_404(user_id)
# 1. 查询用户自己的任务
# 这里的个人任务是指那些没有分配给任何团队,或者分配给了团队但 team_editable 为 False 的任务
# 这样可以避免与团队任务重复
user_own_tasks = UserData.query.filter(
and_(
UserData.user_id == user_id,
or_(
UserData.team_id == None, # 个人任务,没有团队ID
UserData.team_editable == False # 或者有团队ID,但不可由团队成员编辑(仍是个人任务性质)
)
)
).order_by(UserData.created_at.desc()).all()
logger.info(f"User {user.username} (ID: {user_id}) has {len(user_own_tasks)} personal tasks.")
# 2. 查询用户所属团队的任务(如果团队任务可编辑,并且用户是该团队成员)
user_teams = user.teams # 获取用户所属的所有团队
team_tasks = []
if user_teams:
team_ids = [team.id for team in user_teams]
# 查找属于这些团队且 team_editable 为 True 的任务
tasks_from_teams = UserData.query.filter(
and_(
UserData.team_id.in_(team_ids),
# UserData.team_editable == True
)
).order_by(UserData.created_at.desc()).all()
team_tasks.extend(tasks_from_teams)
logger.info(f"User {user.username} (ID: {user_id}) is in {len(user_teams)} teams, and found {len(team_tasks)} editable team tasks.")
# 3. 合并任务列表并去重
# 使用集合进行去重,确保每个任务只出现一次
all_tasks_dict = {}
for task in user_own_tasks:
all_tasks_dict[task.id] = task
for task in team_tasks:
all_tasks_dict[task.id] = task # 如果有重复,后面的会覆盖前面的,但通常团队任务和个人任务通过上述筛选是互斥的
# 将字典的值转换为列表并按创建时间倒序排序
user_data = sorted(all_tasks_dict.values(), key=lambda t: t.created_at, reverse=True)
return render_template('dashboard.html',
user=user,
user_data=user_data,
user_teams=user_teams,
team_tasks=team_tasks # 实际上dashboard.html只迭代 user_data
)
@app.route('/add_data', methods=['GET', 'POST'])
@login_required
def add_data():
user = User.query.get_or_404(session['user_id'])
teams = Team.query.all()
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
deadline_str = request.form.get('deadline')
team_id = request.form.get('team_id')
team_editable = 'team_editable' in request.form if team_id else False
if not title or not content:
flash('标题和内容为必填项。', 'danger')
return render_template('add_data.html' ,user=user, teams=teams)
deadline = None
if deadline_str:
try:
deadline = datetime.strptime(deadline_str, '%Y-%m-%dT%H:%M')
except ValueError:
flash('截止时间格式无效。', 'danger')
return render_template('add_data.html' ,user=user, teams=teams)
new_data = UserData(
title=title,
content=content,
deadline=deadline,
user_id=session['user_id'],
team_id=int(team_id) if team_id else None,
team_editable=team_editable
)
try:
db.session.add(new_data)
db.session.commit()
flash('任务添加成功!', 'success')
return redirect(url_for('dashboard'))
except Exception as e:
db.session.rollback()
flash('添加任务时出错。', 'danger')
return render_template('add_data.html' ,user=user, teams=teams)
return render_template('add_data.html' ,user=user, teams=teams)
@app.route('/edit_data/<int:data_id>', methods=['GET', 'POST'])
@login_required
def edit_data(data_id):
user = User.query.get_or_404(session['user_id'])
data = UserData.query.get_or_404(data_id)
teams = user.teams
team_id = request.form.get('team_id')
team_editable = bool(request.form.get('team_editable'))
is_author = data.user_id == user.id
is_team_member = data.team_id and any(team.id == data.team_id for team in user.teams)
if not is_author and not (data.team_editable and is_team_member):
abort(403)
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
deadline_str = request.form.get('deadline')
if not title or not content:
flash('标题和内容为必填项。', 'danger')
return render_template('edit_data.html', data=data,user=user, teams=teams)
# Convert deadline string to datetime if provided
deadline = None
if deadline_str:
try:
deadline = datetime.strptime(deadline_str, '%Y-%m-%dT%H:%M')
except ValueError:
flash('截止日期格式无效。', 'danger')
return render_template('edit_data.html', data=data,user=user, teams=teams)
# 设置团队归属(仅允许用户加入的队伍)
if team_id:
team = Team.query.get(int(team_id))
if team and team in user.teams:
data.team = team
else:
data.team = None
else:
data.team = None
# 设置是否允许团队编辑
data.team_editable = team_editable
data.title = title
data.content = content
data.deadline = deadline
data.updated_at = datetime.utcnow()
try:
db.session.commit()
flash('任务更新成功!', 'success')
return redirect(url_for('dashboard'))
except Exception as e:
db.session.rollback()
flash('更新任务时出错。', 'danger')
return render_template('edit_data.html', data=data,user=user, teams=teams)
return render_template('edit_data.html', data=data,user=user, teams=teams)
@app.route('/delete_data/<int:data_id>')
@login_required
def delete_data(data_id):
user = User.query.get_or_404(session['user_id'])
data = UserData.query.get_or_404(data_id)
is_author = data.user_id == user.id
is_team_member = data.team_id and any(team.id == data.team_id for team in user.teams)
if not is_author and not (data.team_editable and is_team_member):
abort(403)
try:
db.session.delete(data)
db.session.commit()
flash('任务删除成功!', 'success')
except Exception as e:
db.session.rollback()
flash('删除任务时出错。', 'danger')
return redirect(url_for('dashboard'))
@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
user = User.query.get_or_404(session['user_id'])
teams = Team.query.all()
if request.method == 'POST':
bio = request.form.get('bio', '')
selected_team_id = request.form.get('team_id')
file = request.files.get('profile_picture')
updated = False # 追踪是否有改动
# 修改 bio
if bio != user.bio:
user.bio = bio
updated = True
# 上传头像
if file and file.filename != '':
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
user.profile_picture = filename
updated = True
# 加入队伍
if selected_team_id:
try:
selected_team_id = int(selected_team_id)
team = Team.query.get(selected_team_id)
input_password = request.form.get('team_password', '')
if team and team not in user.teams:
if check_password_hash(team.password, input_password):
user.teams.append(team)
flash(f'成功加入团队:{team.name}', 'success')
updated = True
else:
flash('团队密码错误,无法加入。', 'danger')
elif team in user.teams:
flash(f'你已经是团队“{team.name}”的成员。', 'info')
except ValueError:
flash('无效的团队选择。', 'danger')
else:
if 'team_id' in request.form: # 用户点击了 Join 但没选队伍
flash('请选择一个团队并输入密码以加入。', 'warning')
# 提交变更
try:
db.session.commit()
flash('个人信息更新成功!', 'success')
except Exception as e:
db.session.rollback()
flash('个人信息更新失败。', 'danger')
return redirect(url_for('profile'))
return render_template('profile.html', user=user, teams=teams)
# Helper for file uploads
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# --- Password Reset and Change ---
@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if request.method == 'POST':
email = request.form['email'].strip()
user = User.query.filter_by(email=email).first()
if user:
try:
token = serializer.dumps(user.email, salt='reset-password')
reset_url = url_for('reset_token', token=token, _external=True)
subject = "任务管理系统 - 重置密码请求"
html_body = render_template('reset_password_email.html', user=user, reset_url=reset_url)
msg = Message(
subject=subject,
sender=('任务系统', app.config['MAIL_DEFAULT_SENDER']), # ⚠️ 这里用 tuple
recipients=[user.email],
charset='utf-8'
)
msg.body = "请使用支持 HTML 的邮箱查看这封邮件。"
msg.html = html_body
current_app.logger.debug(repr(app.config['MAIL_USERNAME']))
current_app.logger.debug(repr(app.config['MAIL_PASSWORD']))
current_app.logger.debug(repr(app.config['MAIL_DEFAULT_SENDER']))
mail.send(msg)
flash('已发送密码重置邮件,请检查收件箱(包含垃圾邮件)。', 'info')
except Exception as e:
current_app.logger.error(f"Error sending password reset email: {e}")
flash(f'发送邮件失败:{e}', 'danger')
else:
flash('若邮箱存在,已发送重置邮件,请检查收件箱(包含垃圾邮件)。', 'info')
return render_template('reset_password_request.html')
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_token(token):
email = None
try:
# max_age = 3600 (1 hour) for token validity
email = serializer.loads(token, salt='reset-password', max_age=3600)
except SignatureExpired:
flash('重置密码链接已过期,请重新申请。', 'danger')
return redirect(url_for('reset_password_request'))
except BadTimeSignature:
flash('重置密码链接无效,请检查或重新申请。', 'danger')
return redirect(url_for('reset_password_request'))
except Exception as e:
current_app.logger.error(f"Error decoding reset token: {e}")
flash('重置密码链接无效或已损坏。', 'danger')
return redirect(url_for('reset_password_request'))
user = User.query.filter_by(email=email).first()
if not user:
flash('用户不存在或链接无效。', 'danger')
return redirect(url_for('reset_password_request'))
if request.method == 'POST':
password = request.form['password'].strip()
confirm_password = request.form['confirm_password'].strip()
# New password validation
if not password or not confirm_password:
flash('新密码和确认密码不能为空。', 'danger')
return render_template('reset_password.html', token=token)
if password != confirm_password:
flash('两次输入的新密码不一致。', 'danger')
return render_template('reset_password.html', token=token)
# Password strength validation
if len(password) < 8:
flash('新密码长度必须至少为8个字符。', 'danger')
return render_template('reset_password.html', token=token)
if not re.search(r"\d", password):
flash('新密码必须包含至少一个数字。', 'danger')
return render_template('reset_password.html', token=token)
if not re.search(r"[A-Z]", password):
flash('新密码必须包含至少一个大写字母。', 'danger')
return render_template('reset_password.html', token=token)
if not re.search(r"[a-z]", password):
flash('新密码必须包含至少一个小写字母。', 'danger')
return render_template('reset_password.html', token=token)
# Optional: Prevent reusing old password (if you want to disallow this)
# if check_password_hash(user.password_hash, password):
# flash('新密码不能与旧密码相同。', 'danger')
# return render_template('reset_password.html', token=token)
user.password = generate_password_hash(password)
try:
db.session.commit()
flash('您的密码已成功重置!请使用新密码登录。', 'success')
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error resetting password for user {user.username}: {e}")
flash('重置密码失败,请稍后再试。', 'danger')
return render_template('reset_password.html', token=token)
return render_template('reset_password.html', token=token)
@app.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
user = g.user # Get the current logged-in user
if request.method == 'POST':
old_password = request.form['old_password'].strip()
new_password = request.form['new_password'].strip()
confirm_new_password = request.form['confirm_new_password'].strip()
# Validate old password
if not check_password_hash(user.password, old_password):
flash('当前密码不正确。', 'danger')
return redirect(url_for('change_password'))
# New password validation
if not new_password or not confirm_new_password:
flash('新密码和确认新密码不能为空。', 'danger')
return redirect(url_for('change_password'))
if new_password != confirm_new_password:
flash('两次输入的新密码不一致。', 'danger')
return redirect(url_for('change_password'))
# Password strength validation (same as register/reset)
if len(new_password) < 8:
flash('新密码长度必须至少为8个字符。', 'danger')
return redirect(url_for('change_password'))
if not re.search(r"\d", new_password):
flash('新密码必须包含至少一个数字。', 'danger')
return redirect(url_for('change_password'))
if not re.search(r"[A-Z]", new_password):
flash('新密码必须包含至少一个大写字母。', 'danger')
return redirect(url_for('change_password'))
if not re.search(r"[a-z]", new_password):
flash('新密码必须包含至少一个小写字母。', 'danger')
return redirect(url_for('change_password'))
# Prevent reusing old password
if check_password_hash(user.password, new_password):
flash('新密码不能与当前密码相同。', 'danger')
return redirect(url_for('change_password'))
user.password = generate_password_hash(new_password)
try:
db.session.commit()
flash('您的密码已成功修改!', 'success')
return redirect(url_for('profile')) # Redirect to profile or dashboard
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error changing password for user {user.username}: {e}")
flash('修改密码失败,请稍后再试。', 'danger')
return redirect(url_for('change_password'))
return render_template('change_password.html') # Assuming you have this template
@app.route('/analyze_task/<int:data_id>', methods=['POST'])
@login_required
def analyze_task(data_id):
data = UserData.query.get_or_404(data_id)
user = User.query.get_or_404(session['user_id'])
if data.team_id:
# 如果任务属于某个队伍,当前用户必须在队伍中
if data.team not in user.teams:
abort(403)
else:
# 非队伍任务,仅作者可分析
if data.user_id != user.id:
abort(403)