1717from backend .errors import register_error_handlers , ValidationError
1818import time
1919import hashlib
20+ from itertools import islice
21+ from typing import Dict , Tuple , Any
2022
2123# Настраиваем логирование
2224logging .basicConfig (level = logging .DEBUG )
@@ -130,6 +132,57 @@ def _put_cached_analysis(key: str, value: str) -> None:
130132 _analysis_cache [key ] = (time .time (), value )
131133
132134
135+ # Простое хранилище загруженных датасетов (только для lifetime процесса)
136+ # dataset_id -> {'path': str, 'kind': 'csv'|'excel'|'pdf', 'columns': list[str], 'total_rows': int}
137+ _datasets : Dict [str , Dict [str , Any ]] = {}
138+
139+ def _make_dataset_id (file_path : str ) -> str :
140+ try :
141+ st = os .stat (file_path )
142+ payload = f"{ file_path } :{ st .st_size } :{ st .st_mtime_ns } " .encode ("utf-8" )
143+ except Exception :
144+ payload = f"{ file_path } :{ time .time ()} " .encode ("utf-8" )
145+ return hashlib .sha256 (payload ).hexdigest ()[:16 ]
146+
147+ def _csv_count_rows_fast (file_path : str ) -> int :
148+ # Подсчёт строк без декодирования (быстро для больших файлов). Вычитаем заголовок.
149+ try :
150+ with open (file_path , "rb" ) as f :
151+ total_lines = sum (1 for _ in f )
152+ return max (total_lines - 1 , 0 )
153+ except Exception :
154+ # Фоллбек: читаем через pandas
155+ try :
156+ return int (pd .read_csv (file_path , usecols = [0 ]).shape [0 ])
157+ except Exception :
158+ return 0
159+
160+ def _csv_get_page (file_path : str , page : int , page_size : int ) -> Tuple [pd .DataFrame , list [str ]]:
161+ """Возвращает конкретную страницу CSV, не читая весь файл."""
162+ if page < 1 :
163+ page = 1
164+ if page_size < 1 :
165+ page_size = 1000
166+ # Быстрый путь для больших страниц: используем skiprows + nrows
167+ start_row = (page - 1 ) * page_size
168+ try :
169+ # читаем только заголовки
170+ header_cols = pd .read_csv (file_path , nrows = 0 ).columns .tolist ()
171+ if start_row == 0 :
172+ df = pd .read_csv (file_path , nrows = page_size )
173+ else :
174+ # skiprows: пропускаем строки [1..start_row] (0 — заголовок)
175+ df = pd .read_csv (file_path , skiprows = range (1 , start_row + 1 ), nrows = page_size , header = 0 )
176+ df .columns = header_cols # восстанавливаем имена колонок
177+ return df , header_cols
178+ except Exception :
179+ # Фоллбек на итератор chunksize
180+ chunk_iter = pd .read_csv (file_path , chunksize = page_size , iterator = True )
181+ chunk = next (islice (chunk_iter , page - 1 , page ), None )
182+ if chunk is None :
183+ return pd .DataFrame (), []
184+ return chunk , chunk .columns .tolist ()
185+
133186def perform_basic_analysis (df : pd .DataFrame ) -> BasicAnalysis :
134187 """Выполняет базовый анализ данных DataFrame."""
135188 logger .debug (f"Starting basic analysis. DataFrame shape: { df .shape } " )
@@ -276,35 +329,45 @@ def upload_file():
276329 # Определяем тип файла и обрабатываем соответственно
277330 file_extension = file .filename .lower ()
278331
332+ dataset_id = _make_dataset_id (temp_file_name )
333+
279334 if file_extension .endswith ('.csv' ):
280- try :
281- file_size = os .path .getsize (temp_file_name )
282- except Exception :
283- file_size = 0
284- # Для CSV используем оптимизированную обработку больших файлов
285- df = process_large_csv (temp_file_name , file_size )
335+ # CSV: считаем общее число строк быстро и читаем только нужную страницу
336+ total_rows = _csv_count_rows_fast (temp_file_name )
337+ df_page , columns = _csv_get_page (temp_file_name , page , page_size )
338+ if not columns :
339+ # попытка прочитать хотя бы заголовок
340+ try :
341+ columns = pd .read_csv (temp_file_name , nrows = 0 ).columns .tolist ()
342+ except Exception :
343+ columns = []
344+ df_for_analysis = df_page # быстрый анализ по текущей странице
345+ kind = 'csv'
286346 elif file_extension .endswith (('.xlsx' , '.xls' )):
287347 df = process_excel (temp_file_name )
348+ total_rows = len (df )
349+ df_page = df .iloc [(page - 1 ) * page_size : (page - 1 ) * page_size + page_size ]
350+ columns = df .columns .tolist ()
351+ df_for_analysis = df
352+ kind = 'excel'
288353 elif file_extension .endswith ('.pdf' ):
289354 df = process_pdf (temp_file_name )
355+ total_rows = len (df )
356+ df_page = df .iloc [(page - 1 ) * page_size : (page - 1 ) * page_size + page_size ]
357+ columns = df .columns .tolist ()
358+ df_for_analysis = df
359+ kind = 'pdf'
290360 else :
291361 raise ValidationError ("Unsupported file format" )
292362
293- if df .empty :
363+ if df_page .empty and ( file_extension . endswith ( '.csv' ) is False ) and ( total_rows == 0 ) :
294364 raise ValidationError ("Не удалось извлечь данные из файла" )
295365
296- # Вычисляем общее количество строк
297- total_rows = len (df )
298366 total_pages = (total_rows + page_size - 1 ) // page_size
299367
300368 logger .debug (f"Total rows: { total_rows } , Total pages: { total_pages } " )
301369 logger .debug (f"Page: { page } , Page size: { page_size } " )
302370
303- # Применяем пагинацию
304- start_idx = (page - 1 ) * page_size
305- end_idx = start_idx + page_size
306- df_page = df .iloc [start_idx :end_idx ]
307-
308371 # Преобразуем DataFrame в список словарей
309372 records = df_page .to_dict ('records' )
310373
@@ -329,18 +392,27 @@ def upload_file():
329392 else :
330393 record [field ] = None
331394
332- # Выполняем базовый анализ для всех данных
333- basic_analysis = perform_basic_analysis (df )
395+ # Выполняем базовый анализ (для CSV — по текущей странице как укороченный вариант)
396+ basic_analysis = perform_basic_analysis (df_for_analysis if not df_for_analysis .empty else df_page )
397+
398+ # Сохраняем метаданные датасета для последующих запросов страниц
399+ _datasets [dataset_id ] = {
400+ 'path' : temp_file_name ,
401+ 'kind' : kind ,
402+ 'columns' : columns ,
403+ 'total_rows' : total_rows ,
404+ }
334405
335406 # Формируем ответ
336407 response = {
337408 'table_data' : records ,
338- 'columns' : df . columns . tolist () ,
409+ 'columns' : columns ,
339410 'total_rows' : total_rows ,
340411 'current_page' : page ,
341412 'page_size' : page_size ,
342413 'total_pages' : total_pages ,
343- 'basic_analysis' : basic_analysis
414+ 'basic_analysis' : basic_analysis ,
415+ 'dataset_id' : dataset_id
344416 }
345417
346418 logger .debug (f"Sending response with { len (records )} records" )
@@ -356,11 +428,9 @@ def upload_file():
356428 raise
357429
358430 finally :
359- if temp_file_name and os .path .exists (temp_file_name ):
360- try :
361- os .remove (temp_file_name )
362- except Exception as e :
363- logger .error (f"Error removing temp file: { str (e )} " )
431+ # Не удаляем файл сразу: он нужен для последующих страниц.
432+ # Файл будет удалён при завершении процесса или вручную.
433+ pass
364434
365435@app .route ('/api/analyze' , methods = ['POST' ])
366436def analyze ():
@@ -480,6 +550,74 @@ def fill_missing_ai():
480550 logger .exception ('Error in fill-missing-ai' )
481551 raise
482552
553+ @app .route ('/api/upload/page' , methods = ['POST' ])
554+ def get_upload_page ():
555+ """Возвращает страницу данных по dataset_id без повторной загрузки файла."""
556+ try :
557+ data = request .get_json () or {}
558+ dataset_id = data .get ('dataset_id' )
559+ page = int (data .get ('page' , 1 ))
560+ page_size = int (data .get ('page_size' , 1000 ))
561+ if not dataset_id or dataset_id not in _datasets :
562+ raise ValidationError ("Invalid or missing dataset_id" )
563+ meta = _datasets [dataset_id ]
564+ file_path = meta ['path' ]
565+ kind = meta ['kind' ]
566+ columns = meta ['columns' ]
567+ total_rows = int (meta ['total_rows' ])
568+ total_pages = (total_rows + page_size - 1 ) // page_size if total_rows > 0 else 1
569+
570+ if kind == 'csv' :
571+ df_page , _ = _csv_get_page (file_path , page , page_size )
572+ if not columns :
573+ columns = df_page .columns .tolist ()
574+ elif kind == 'excel' :
575+ df = process_excel (file_path )
576+ start_idx = (page - 1 ) * page_size
577+ df_page = df .iloc [start_idx :start_idx + page_size ]
578+ elif kind == 'pdf' :
579+ df = process_pdf (file_path )
580+ start_idx = (page - 1 ) * page_size
581+ df_page = df .iloc [start_idx :start_idx + page_size ]
582+ else :
583+ raise ValidationError ("Unsupported dataset kind" )
584+
585+ records = df_page .to_dict ('records' )
586+
587+ # Нормализуем NaN как в upload_file
588+ required_fields = ['year' , 'make' , 'model' , 'trim' , 'body' , 'transmission' , 'vin' , 'state' , 'condition' , 'odometer' , 'color' , 'interior' , 'seller' , 'mmr' , 'sellingprice' , 'saledate' ]
589+ for record in records :
590+ for field in required_fields :
591+ if field not in record or pd .isna (record [field ]):
592+ if field == 'condition' :
593+ record [field ] = 0.0
594+ elif field in ['year' , 'odometer' , 'mmr' , 'sellingprice' ]:
595+ record [field ] = 0
596+ else :
597+ record [field ] = None
598+ elif pd .isna (record [field ]):
599+ if field == 'condition' :
600+ record [field ] = 0.0
601+ elif field in ['year' , 'odometer' , 'mmr' , 'sellingprice' ]:
602+ record [field ] = 0
603+ else :
604+ record [field ] = None
605+
606+ return jsonify ({
607+ 'table_data' : records ,
608+ 'columns' : columns ,
609+ 'total_rows' : total_rows ,
610+ 'current_page' : page ,
611+ 'page_size' : page_size ,
612+ 'total_pages' : total_pages ,
613+ 'dataset_id' : dataset_id
614+ })
615+ except ValidationError as e :
616+ raise e
617+ except Exception :
618+ logger .exception ("Error in /api/upload/page" )
619+ raise
620+
483621if __name__ == '__main__' :
484622 debug_flag = os .getenv ('FLASK_DEBUG' , '0' ) in ('1' , 'true' , 'True' )
485623 app .run (host = '0.0.0.0' , port = 5000 , debug = debug_flag )
0 commit comments