Skip to content

Commit f6e00b6

Browse files
committed
fix avatar uploading and models
1 parent 2832d83 commit f6e00b6

File tree

6 files changed

+224
-133
lines changed

6 files changed

+224
-133
lines changed

app/mongo_models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,22 @@ class Logs(MongoModel):
160160
class StorageMeta(MongoModel):
161161
used_size = fields.IntegerField()
162162

163+
163164
class Questions(MongoModel):
164165
session_id = fields.CharField()
165166
text = fields.CharField()
166-
created_at = fields.DateTimeField(default=datetime.now(timezone.utc))
167+
order = fields.IntegerField(blank=True, default=0)
168+
created_at = fields.DateTimeField(default=datetime.now(timezone.utc))
169+
attributes = fields.DictField(blank=True, default={})
170+
generation_metadata = fields.DictField(blank=True, default={})
171+
172+
class InterviewAvatars(MongoModel):
173+
session_id = fields.CharField()
174+
file_id = fields.ObjectIdField()
175+
176+
created_at = fields.DateTimeField(default=datetime.now(timezone.utc))
177+
last_update = fields.DateTimeField(default=datetime.now(timezone.utc))
178+
179+
def save(self):
180+
self.last_update = datetime.now(timezone.utc)
181+
return super().save()

app/mongo_odm.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
RecognizedAudioToProcess,
2424
RecognizedPresentationsToProcess, Sessions,
2525
TaskAttempts, TaskAttemptsToPassBack, Tasks,
26-
Trainings, TrainingsToProcess, StorageMeta)
26+
Trainings, TrainingsToProcess, StorageMeta, InterviewAvatars)
2727
from app.status import (AudioStatus, PassBackStatus, PresentationStatus,
2828
TrainingStatus)
2929
from app.utils import remove_blank_and_none
@@ -119,6 +119,28 @@ def recalculate_used_storage_data(self):
119119
total_size += file_doc['length']
120120
self.set_used_storage_size(total_size)
121121
logger.info(f"Storage size recalculated: {total_size/BYTES_PER_MB:.2f} MB")
122+
123+
def delete_file(self, file_id):
124+
try:
125+
oid = ObjectId(file_id)
126+
except InvalidId as e:
127+
logger.warning('Invalid file_id = {}: {}.'.format(file_id, e))
128+
return
129+
130+
db = _get_db()
131+
file_doc = db.fs.files.find_one({'_id': oid})
132+
if not file_doc:
133+
logger.warning('No file doc for file_id = {}.'.format(file_id))
134+
return
135+
136+
length = file_doc.get('length', 0)
137+
try:
138+
self.storage.delete(oid)
139+
except (NoFile, ValidationError) as e:
140+
logger.warning('Error deleting file_id = {}: {}.'.format(file_id, e))
141+
return
142+
143+
self.update_storage_size(-length)
122144

123145

124146
class TrainingsDBManager:
@@ -943,4 +965,63 @@ def remove_question(self, question_id):
943965
return Question.objects.get({"_id": question_id}).delete()
944966

945967
def get_all(self):
946-
return Question.objects.all()
968+
return Question.objects.all()
969+
970+
class InterviewAvatarsDBManager:
971+
def __new__(cls):
972+
if not hasattr(cls, 'init_done'):
973+
cls.instance = super(InterviewAvatarsDBManager, cls).__new__(cls)
974+
connect(Config.c.mongodb.url + Config.c.mongodb.database_name)
975+
cls.init_done = True
976+
return cls.instance
977+
978+
def get_by_session_id(self, session_id: str):
979+
try:
980+
return InterviewAvatars.objects.get({'session_id': session_id})
981+
except InterviewAvatars.DoesNotExist:
982+
return None
983+
984+
def add_or_update_avatar(self, session_id: str, file_obj, filename: str | None = None):
985+
storage = DBManager()
986+
if filename is None:
987+
filename = str(uuid.uuid4())
988+
avatar_file_id = storage.add_file(file_obj, filename)
989+
avatar = self.get_by_session_id(session_id)
990+
if avatar is None:
991+
avatar = InterviewAvatars(session_id=session_id, file_id=avatar_file_id)
992+
else:
993+
try:
994+
storage.delete_file(avatar.file_id)
995+
except Exception:
996+
logger.warning('Failed to delete old avatar file for session_id = {}.'.format(session_id))
997+
avatar.file_id = avatar_file_id
998+
999+
saved = avatar.save()
1000+
logger.info('Avatar saved for session_id = {}, file_id = {}.'.format(session_id, avatar_file_id))
1001+
return saved
1002+
1003+
def get_avatar_record(self, session_id: str) -> Union[InterviewAvatars, None]:
1004+
return self.get_by_session_id(session_id)
1005+
1006+
def get_avatar_file(self, session_id: str):
1007+
avatar = self.get_by_session_id(session_id)
1008+
if avatar is None:
1009+
logger.info('No avatar for session_id = {}.'.format(session_id))
1010+
return None
1011+
1012+
storage = DBManager()
1013+
return storage.get_file(avatar.file_id)
1014+
1015+
def delete_avatar(self, session_id: str):
1016+
avatar = self.get_by_session_id(session_id)
1017+
if avatar is None:
1018+
return
1019+
1020+
storage = DBManager()
1021+
try:
1022+
storage.delete_file(avatar.file_id)
1023+
except Exception as e:
1024+
logger.warning('Error deleting avatar file for session_id = {}: {}.'.format(session_id, e))
1025+
1026+
avatar.delete()
1027+
logger.info('Avatar deleted for session_id = {}.'.format(session_id))

app/routes/interview.py

Lines changed: 72 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,75 @@
1-
import os
2-
from flask import Blueprint, render_template, request, jsonify, current_app, Response
1+
from flask import Blueprint, render_template, session, request, Response
2+
3+
from app.root_logger import get_root_logger
4+
from app.lti_session_passback.auth_checkers import check_auth
5+
from app.mongo_odm import InterviewAvatarsDBManager
36

47
routes_interview = Blueprint('routes_interview', __name__)
8+
logger = get_root_logger()
59

6-
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), '..', 'uploads')
7-
AVATAR_FILENAME = 'avatar.mp4'
8-
ALLOWED_EXTENSIONS = {'mp4', 'm4v', 'mov', 'webm'}
910

10-
os.makedirs(UPLOAD_DIR, exist_ok=True)
11+
@routes_interview.route('/interview/', methods=['GET'])
12+
def interview_page():
13+
user_session = check_auth()
14+
if not user_session:
15+
return {}, 404
1116

12-
def allowed_file(filename):
13-
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
17+
session_id = session.get('session_id')
18+
if not session_id:
19+
return {}, 404
20+
21+
avatar_record = InterviewAvatarsDBManager().get_avatar_record(session_id)
22+
has_avatar = avatar_record is not None
1423

15-
@routes_interview.route('/interview', methods=['GET'])
16-
def interview_page():
17-
avatar_path = os.path.join(UPLOAD_DIR, AVATAR_FILENAME)
18-
has_avatar = os.path.exists(avatar_path)
1924
return render_template(
2025
'interview.html',
21-
has_avatar=has_avatar
26+
has_avatar=has_avatar,
2227
), 200
2328

24-
@routes_interview.route('/upload_avatar', methods=['POST'])
25-
def upload_avatar():
26-
if 'file' not in request.files:
27-
return jsonify({'ok': False, 'error': 'No file part'}), 400
28-
file = request.files['file']
29-
if file.filename == '':
30-
return jsonify({'ok': False, 'error': 'No selected file'}), 400
31-
if not allowed_file(file.filename):
32-
return jsonify({'ok': False, 'error': 'Invalid file type'}), 400
3329

34-
save_path = os.path.join(UPLOAD_DIR, AVATAR_FILENAME)
30+
def _partial_response_file(grid_out):
31+
file_size = getattr(grid_out, 'length', None)
32+
if file_size is None:
33+
grid_out.seek(0, 2) # SEEK_END
34+
file_size = grid_out.tell()
35+
grid_out.seek(0)
3536

36-
file.save(save_path)
37-
return jsonify({'ok': True, 'message': 'Uploaded'}), 200
37+
content_type = getattr(grid_out, 'content_type', None) or 'video/mp4'
3838

39-
def partial_response(path):
40-
file_size = os.path.getsize(path)
4139
range_header = request.headers.get('Range', None)
4240
if not range_header:
4341
def full_stream():
44-
with open(path, 'rb') as f:
45-
while True:
46-
chunk = f.read(8192)
47-
if not chunk:
48-
break
49-
yield chunk
42+
chunk_size = 8192
43+
grid_out.seek(0)
44+
while True:
45+
chunk = grid_out.read(chunk_size)
46+
if not chunk:
47+
break
48+
yield chunk
49+
5050
headers = {
5151
'Content-Length': str(file_size),
5252
'Accept-Ranges': 'bytes',
53-
'Content-Type': 'video/mp4'
53+
'Content-Type': content_type,
5454
}
5555
return Response(full_stream(), status=200, headers=headers)
56-
byte_range = range_header.strip().split('=')[1]
57-
start_str, end_str = byte_range.split('-')
56+
57+
try:
58+
byte_range = range_header.strip().split('=')[1]
59+
start_str, end_str = byte_range.split('-')
60+
except Exception:
61+
return Response(status=416)
62+
5863
try:
5964
start = int(start_str) if start_str else 0
6065
except ValueError:
6166
start = 0
62-
end = int(end_str) if end_str else file_size - 1
67+
68+
try:
69+
end = int(end_str) if end_str else file_size - 1
70+
except ValueError:
71+
end = file_size - 1
72+
6373
if end >= file_size:
6474
end = file_size - 1
6575
if start > end:
@@ -68,28 +78,38 @@ def full_stream():
6878
length = end - start + 1
6979

7080
def stream_range():
71-
with open(path, 'rb') as f:
72-
f.seek(start)
73-
remaining = length
74-
while remaining > 0:
75-
chunk_size = 8192 if remaining >= 8192 else remaining
76-
data = f.read(chunk_size)
77-
if not data:
78-
break
79-
remaining -= len(data)
80-
yield data
81+
chunk_size = 8192
82+
grid_out.seek(start)
83+
remaining = length
84+
while remaining > 0:
85+
size = chunk_size if remaining >= chunk_size else remaining
86+
data = grid_out.read(size)
87+
if not data:
88+
break
89+
remaining -= len(data)
90+
yield data
8191

8292
headers = {
8393
'Content-Range': f'bytes {start}-{end}/{file_size}',
8494
'Accept-Ranges': 'bytes',
8595
'Content-Length': str(length),
86-
'Content-Type': 'video/mp4'
96+
'Content-Type': content_type,
8797
}
8898
return Response(stream_range(), status=206, headers=headers)
8999

100+
90101
@routes_interview.route('/avatar_video')
91102
def avatar_video():
92-
avatar_path = os.path.join(UPLOAD_DIR, AVATAR_FILENAME)
93-
if not os.path.exists(avatar_path):
94-
return ('', 404)
95-
return partial_response(avatar_path)
103+
user_session = check_auth()
104+
if not user_session:
105+
return '', 404
106+
107+
session_id = session.get('session_id')
108+
if not session_id:
109+
return '', 404
110+
111+
grid_out = InterviewAvatarsDBManager().get_avatar_file(session_id)
112+
if grid_out is None:
113+
return '', 404
114+
115+
return _partial_response_file(grid_out)

app/routes/lti.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,7 @@
1313
logger = get_root_logger()
1414

1515

16-
17-
@routes_lti.route('/lti', methods=['POST'])
18-
def lti():
19-
"""
20-
Route that is an entry point for LTI.
21-
22-
:return: Redirects to training_greeting page, or
23-
an empty dictionary with 404 HTTP return code if access was denied.
24-
"""
16+
def _handle_lti_common():
2517
params = request.form
2618

2719
consumer_key = params.get('oauth_consumer_key', '')
@@ -30,25 +22,29 @@ def lti():
3022
headers=dict(request.headers),
3123
data=params,
3224
url=request.url,
33-
secret=consumer_secret
25+
secret=consumer_secret,
3426
)
3527
if not check_request(request_info):
36-
return {}, 404
28+
return None
29+
3730
full_name = utils.get_person_name(params)
3831
username = utils.get_username(params)
3932
custom_params = utils.get_custom_params(params)
4033
task_id = custom_params.get('task_id', '')
4134
task_description = custom_params.get('task_description', '')
4235
attempt_count = int(custom_params.get('attempt_count', 1))
4336
required_points = float(custom_params.get('required_points', 0))
44-
criteria_pack_id = CriteriaPackFactory().get_criteria_pack(custom_params.get('criteria_pack_id', '')).name
37+
criteria_pack_id = CriteriaPackFactory().get_criteria_pack(
38+
custom_params.get('criteria_pack_id', '')
39+
).name
4540
presentation_id = custom_params.get('presentation_id')
4641
feedback_evaluator_id = int(custom_params.get('feedback_evaluator_id', 1))
4742
role = utils.get_role(params)
4843
params_for_passback = utils.extract_passback_params(params)
4944
pres_formats = list(set(custom_params.get('formats', '').split(',')) & ALLOWED_EXTENSIONS) or [DEFAULT_EXTENSION]
5045

5146
SessionsDBManager().add_session(username, consumer_key, task_id, params_for_passback, role, pres_formats)
47+
5248
session['session_id'] = username
5349
session['task_id'] = task_id
5450
session['consumer_key'] = consumer_key
@@ -62,6 +58,50 @@ def lti():
6258
if not PresentationFilesDBManager().get_presentation_file(presentation_id):
6359
presentation_id = None
6460

65-
TasksDBManager().add_task_if_absent(task_id, task_description, attempt_count, required_points, criteria_pack_id, presentation_id)
61+
TasksDBManager().add_task_if_absent(
62+
task_id,
63+
task_description,
64+
attempt_count,
65+
required_points,
66+
criteria_pack_id,
67+
presentation_id,
68+
)
69+
70+
return {
71+
"username": username,
72+
"task_id": task_id,
73+
"full_name": full_name,
74+
"criteria_pack_id": criteria_pack_id,
75+
"feedback_evaluator_id": feedback_evaluator_id,
76+
"presentation_id": presentation_id,
77+
"attempt_count": attempt_count,
78+
"required_points": required_points,
79+
}
80+
81+
82+
@routes_lti.route('/lti', methods=['POST'])
83+
def lti():
84+
"""
85+
Route that is an entry point for LTI (тренировки).
86+
87+
:return: Redirects to training_greeting page, or
88+
an empty dictionary with 404 HTTP return code if access was denied.
89+
"""
90+
ctx = _handle_lti_common()
91+
if ctx is None:
92+
return {}, 404
6693

6794
return redirect(url_for('routes_trainings.view_training_greeting'))
95+
96+
97+
@routes_lti.route('/lti_interview', methods=['POST'])
98+
def lti_interview():
99+
"""
100+
LTI entry point для интервью.
101+
"""
102+
ctx = _handle_lti_common()
103+
if ctx is None:
104+
return {}, 404
105+
106+
username = ctx["username"]
107+
return redirect(url_for('routes_interview.interview_page', session_id=username))

0 commit comments

Comments
 (0)