Skip to content

Commit 1941286

Browse files
committed
refactor: finalize refactor plan (security, paging, tests, CI, docs)
1 parent 3f5a167 commit 1941286

File tree

11 files changed

+465
-23
lines changed

11 files changed

+465
-23
lines changed

.github/dependabot.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "npm"
4+
directory: "/frontend"
5+
schedule:
6+
interval: "weekly"
7+
open-pull-requests-limit: 5
8+
labels:
9+
- "deps"
10+
- "frontend"
11+
commit-message:
12+
prefix: "deps(npm)"
13+
versioning-strategy: increase
14+
15+
- package-ecosystem: "pip"
16+
directory: "/backend"
17+
schedule:
18+
interval: "weekly"
19+
open-pull-requests-limit: 5
20+
labels:
21+
- "deps"
22+
- "backend"
23+
commit-message:
24+
prefix: "deps(pip)"
25+
insecure-external-code-execution: deny
26+
27+

.github/workflows/ci.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,70 @@
11
name: CI
22

3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
8+
jobs:
9+
backend:
10+
runs-on: ubuntu-latest
11+
env:
12+
TEST_MODE: "true"
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Python
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: '3.11'
21+
22+
- name: Install WeasyPrint dependencies
23+
run: |
24+
sudo apt-get update
25+
sudo apt-get install -y libcairo2 libpango-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
26+
27+
- name: Install Python deps
28+
working-directory: backend
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install -r requirements.txt
32+
pip install pytest
33+
34+
- name: Run backend tests
35+
run: |
36+
pytest -q
37+
38+
frontend:
39+
runs-on: ubuntu-latest
40+
steps:
41+
- name: Checkout
42+
uses: actions/checkout@v4
43+
44+
- name: Use Node.js
45+
uses: actions/setup-node@v4
46+
with:
47+
node-version: '18'
48+
cache: 'npm'
49+
cache-dependency-path: frontend/package-lock.json
50+
51+
- name: Install deps
52+
working-directory: frontend
53+
run: npm ci
54+
55+
- name: Run tests
56+
working-directory: frontend
57+
run: npm test -- --watch=false
58+
59+
- name: Build
60+
working-directory: frontend
61+
env:
62+
CI: "true"
63+
REACT_APP_API_URL: "http://localhost:5000"
64+
run: npm run build
65+
66+
name: CI
67+
368
on:
469
push:
570
branches: [ main, dev ]

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@
7070

7171
### Пагинация и подгрузка данных
7272
- Для больших файлов реализована кнопка «Показать ещё», позволяющая подгружать данные порциями.
73+
- Backend хранит загруженный файл и возвращает `dataset_id` в ответе `POST /api/upload`.
74+
- Для догрузки последующих страниц используется `POST /api/upload/page` с телом:
75+
```json
76+
{
77+
"dataset_id": "<из ответа /api/upload>",
78+
"page": 2,
79+
"page_size": 1000
80+
}
81+
```
82+
- Сервер выбирает страницу для CSV без полного чтения файла (optimized skiprows + nrows).
7383

7484
### Устойчивость и UX
7585
- Интерфейс устойчив к ошибкам (undefined/null), все действия сопровождаются пояснениями.
@@ -169,6 +179,25 @@ GIGACHAT_CERT_PATH=russian_trusted_root_ca.cer
169179

170180
# Тестовый режим (без API-ключей)
171181
TEST_MODE=true
182+
183+
# Кэш анализа (секунды/лимит)
184+
ANALYSIS_CACHE_TTL_SEC=600
185+
ANALYSIS_CACHE_MAX=256
186+
187+
# Простая авторизация и rate limiting
188+
API_KEY=your_api_key
189+
RATE_LIMIT_WINDOW_SEC=60
190+
RATE_LIMIT_MAX_REQ=60
191+
```
192+
193+
### Переменные окружения (Frontend)
194+
Добавьте в `frontend/.env`:
195+
```bash
196+
# Базовый URL backend API
197+
REACT_APP_API_URL=http://localhost:5000
198+
199+
# Включить подробные логи в консоли браузера (dev)
200+
REACT_APP_DEBUG=true
172201
```
173202

174203
### Тестовый режим

backend/pdf_server.py

Lines changed: 161 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from backend.errors import register_error_handlers, ValidationError
1818
import time
1919
import hashlib
20+
from itertools import islice
21+
from typing import Dict, Tuple, Any
2022

2123
# Настраиваем логирование
2224
logging.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+
133186
def 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'])
366436
def 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+
483621
if __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

Comments
 (0)