Coverage for app/pilots/logbook_import.py: 41%

620 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-01 12:21 +0000

1"""Pilot logbook import — parsing, normalisation, mapping, and execution.""" 

2 

3from __future__ import annotations 

4 

5import csv 

6import hashlib 

7import io 

8import json 

9import math 

10import re 

11from dataclasses import dataclass, field 

12from datetime import date, datetime, time, timedelta 

13from typing import Any, Callable 

14 

15import openpyxl # pyright: ignore[reportMissingImports] 

16from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

17from flask_babel import pgettext # pyright: ignore[reportMissingImports] 

18 

19# ── Constants ───────────────────────────────────────────────────────────────── 

20 

21TARGET_FIELDS: list[str] = [ 

22 "date", 

23 "aircraft_type", 

24 "aircraft_registration", 

25 "departure_place", 

26 "departure_time", 

27 "arrival_place", 

28 "arrival_time", 

29 "pic_name", 

30 "night_time", 

31 "instrument_time", 

32 "cross_country", 

33 "landings_day", 

34 "landings_night", 

35 "single_pilot_se", 

36 "single_pilot_me", 

37 "multi_pilot", 

38 "function_pic", 

39 "function_copilot", 

40 "function_dual", 

41 "function_instructor", 

42 "remarks", 

43 # virtual target — used for import validation only, not stored 

44 "total_flight_time_check", 

45] 

46 

47# Normalised source column name → target field. 

48# Positional disambiguation suffixes (_2, _3 …) are applied before lookup, 

49# so "time" is the first TIME column (departure) and "time_2" is the second (arrival). 

50_ALIASES: dict[str, str] = { 

51 # EASA standard logbook column names (normalised) 

52 "date dd/mm/yy": "date", 

53 "date": "date", 

54 "aircraft type": "aircraft_type", 

55 "type": "aircraft_type", 

56 "aircraft registration number": "aircraft_registration", 

57 "registration": "aircraft_registration", 

58 "reg": "aircraft_registration", 

59 "from": "departure_place", 

60 "departure": "departure_place", 

61 "dep": "departure_place", 

62 "time": "departure_time", # first TIME → departure 

63 "time_2": "arrival_time", # second TIME → arrival 

64 "departure time": "departure_time", 

65 "arrival time": "arrival_time", 

66 "to": "arrival_place", 

67 "arrival": "arrival_place", 

68 "arr": "arrival_place", 

69 "pic name": "pic_name", 

70 "pic name (if not student pilot)": "pic_name", 

71 "captain": "pic_name", 

72 # Landings group — first DAY / NIGHT pair 

73 "day": "landings_day", 

74 "night": "landings_night", 

75 # Operational conditions group — second DAY / NIGHT pair (positional suffix) 

76 "day_2": "ignore", # cross-country day — not a logbook field 

77 "night_2": "night_time", # night flying time 

78 # Aircraft category 

79 "se": "single_pilot_se", 

80 "single engine": "single_pilot_se", 

81 "single pilot se": "single_pilot_se", 

82 "me": "single_pilot_me", 

83 "multi engine": "single_pilot_me", 

84 "single pilot me": "single_pilot_me", 

85 "multi pilot": "multi_pilot", 

86 "mp": "multi_pilot", 

87 # Pilot function 

88 "pic": "function_pic", 

89 "p1": "function_pic", 

90 "co-pic": "function_copilot", 

91 "co-pilot": "function_copilot", 

92 "copilot": "function_copilot", 

93 "p2": "function_copilot", 

94 "dual received": "function_dual", 

95 "dual": "function_dual", 

96 "student": "function_dual", 

97 "instructor": "function_instructor", 

98 "fi": "function_instructor", 

99 # Cross-country time 

100 "cross-country": "cross_country", 

101 "cross country": "cross_country", 

102 "x-country": "cross_country", 

103 "xc": "cross_country", 

104 # Total flight time — imported only to validate against computed sum 

105 "total flight time": "total_flight_time_check", 

106 # Catch-all 

107 "total": "ignore", 

108 "no. istr. appr.": "ignore", 

109 "ifr approaches": "ignore", 

110 "page": "ignore", 

111 "line": "ignore", 

112 "remarks": "remarks", 

113 "notes": "remarks", 

114 "comments": "remarks", 

115 "instrument time": "instrument_time", 

116 "ifr": "instrument_time", 

117 "instrument": "instrument_time", 

118 "night time": "night_time", 

119 # Group-prefixed column names (e.g. Belgian EASA logbook with a span header row) 

120 "departure & arrival from": "departure_place", 

121 "departure & arrival time": "departure_time", 

122 "departure & arrival time_2": "arrival_time", 

123 "departure & arrival to": "arrival_place", 

124 "aircraft category se": "single_pilot_se", 

125 "aircraft category me": "single_pilot_me", 

126 "operational conditions cross-country": "cross_country", 

127 "operational conditions day": "ignore", 

128 "operational conditions night": "night_time", 

129 "pilot function pic": "function_pic", 

130 "pilot function co-pic": "function_copilot", 

131 "pilot function dual received": "function_dual", 

132 "page subtotals total flight time": "ignore", 

133 "page subtotals total flight time_2": "ignore", 

134 "page subtotals pic": "ignore", 

135 "page subtotals dual": "ignore", 

136 "page subtotals night": "ignore", 

137 "page subtotals landings – day": "ignore", 

138 "page subtotals landings – night": "ignore", 

139 "page subtotals formated date": "ignore", 

140 "page subtotals formated type": "ignore", 

141 "page subtotals days since flight": "ignore", 

142 "page subtotals daytime duration": "ignore", 

143 "page subtotals non pic or dual": "ignore", 

144 "formated date": "ignore", 

145 "formated type": "ignore", 

146 "days since flight": "ignore", 

147 "daytime duration": "ignore", 

148 "non pic or dual": "ignore", 

149 # Landings group (group-prefixed) 

150 "landings day": "landings_day", 

151 "landings night": "landings_night", 

152 # Aircraft type (multiline cell name normalised to single space) 

153 "aircraft type name, model, variant": "aircraft_type", 

154} 

155 

156# ── Data structures ─────────────────────────────────────────────────────────── 

157 

158 

159@dataclass 

160class ParsedFile: 

161 """Result of parsing an uploaded logbook file.""" 

162 

163 norm_cols: list[str] # normalised + disambiguated column keys 

164 raw_cols: list[str] # original column labels (for display) 

165 header_row_index: int # 0-based row index of the detected header 

166 data_rows: list[list[Any]] # all rows after the header (including subtotals) 

167 fingerprint: str # SHA-256 of norm_cols 

168 

169 

170@dataclass 

171class MappingProposal: 

172 """Proposed column mapping and how it was derived.""" 

173 

174 mapping: dict[str, str] # norm_col_key → target_field or "ignore" 

175 match_type: str # "exact", "fuzzy", "alias" 

176 fuzzy_score: float = 0.0 # 0.0–1.0, only meaningful for "fuzzy" 

177 matched_mapping_id: int | None = None 

178 

179 

180@dataclass 

181class ImportResult: 

182 """Summary returned after executing an import.""" 

183 

184 imported: int = 0 

185 subtotals: int = 0 

186 skipped: list[tuple[int, str]] = field(default_factory=list) # (row_num, reason) 

187 # (row_num, reason) — rows that matched an entry already in the pilot's logbook 

188 duplicates: list[tuple[int, str]] = field(default_factory=list) 

189 # (row_num, source_col, target_field, repr(raw)) — non-empty cells that couldn't parse 

190 parse_warnings: list[tuple[int, str, str, str]] = field(default_factory=list) 

191 # (row_num, source_total, computed_total) — rows where total ≠ sum of components 

192 total_mismatch_warnings: list[tuple[int, float, float]] = field( 

193 default_factory=list 

194 ) 

195 has_opening_balance: bool = False 

196 

197 

198# ── Normalisation ───────────────────────────────────────────────────────────── 

199 

200 

201def _norm(text: str) -> str: 

202 """Strip, collapse whitespace, lower-case.""" 

203 return re.sub(r"\s+", " ", str(text).strip().lower()) 

204 

205 

206def _disambiguate(names: list[str]) -> list[str]: 

207 """Append _2, _3 … to duplicate names to make them unique.""" 

208 seen: dict[str, int] = {} 

209 result: list[str] = [] 

210 for n in names: 

211 if n in seen: 

212 seen[n] += 1 

213 result.append(f"{n}_{seen[n]}") 

214 else: 

215 seen[n] = 1 

216 result.append(n) 

217 return result 

218 

219 

220def _fingerprint(norm_cols: list[str]) -> str: 

221 payload = json.dumps(norm_cols, ensure_ascii=False, sort_keys=False) 

222 return hashlib.sha256(payload.encode()).hexdigest() 

223 

224 

225# ── Header detection ────────────────────────────────────────────────────────── 

226 

227 

228def _is_header_row(row: list[Any]) -> bool: 

229 """True if ≥ 50 % of non-empty cells are non-numeric strings and ≥ 4 non-empty.""" 

230 non_empty = [c for c in row if c is not None and str(c).strip()] 

231 if len(non_empty) < 4: 

232 return False 

233 string_like = [ 

234 c for c in non_empty if isinstance(c, str) and not _is_numeric_str(str(c)) 

235 ] 

236 return len(string_like) / len(non_empty) >= 0.5 

237 

238 

239def _is_numeric_str(s: str) -> bool: 

240 try: 

241 float(s) 

242 return True 

243 except ValueError: 

244 return False 

245 

246 

247def _header_alias_score(row: list[Any]) -> int: 

248 """Count how many cells in *row* match a known alias.""" 

249 return sum(1 for c in row if c is not None and _norm(str(c)) in _ALIASES) 

250 

251 

252def _find_header_row(rows: list[list[Any]], max_scan: int = 20) -> int | None: 

253 """Return 0-based index of the best header row within the first max_scan rows. 

254 

255 Many logbook templates have a group-label row (e.g. "DEPARTURE & ARRIVAL", 

256 "LANDINGS") above the actual column-header row. Both pass _is_header_row, 

257 but the actual header row has far more alias matches. We score every 

258 candidate and return the one with the highest score; ties go to the earlier 

259 row. If no alias matches are found we fall back to the first text-like row. 

260 """ 

261 best_idx: int | None = None 

262 best_score = -1 

263 first_text_row: int | None = None 

264 

265 for i, row in enumerate(rows[:max_scan]): 

266 if not _is_header_row(row): 

267 continue 

268 if first_text_row is None: 

269 first_text_row = i 

270 score = _header_alias_score(row) 

271 if score > best_score: 

272 best_score = score 

273 best_idx = i 

274 

275 return best_idx if (best_idx is not None and best_score > 0) else first_text_row 

276 

277 

278def _trim_trailing_empty_cols( 

279 all_rows: list[list[Any]], max_scan: int = 50 

280) -> list[list[Any]]: 

281 """Trim columns beyond the rightmost non-empty value in the first max_scan rows. 

282 

283 Excel templates often declare thousands of formatted-but-empty columns. 

284 Without trimming, every empty cell becomes a separate mapping entry. 

285 """ 

286 max_col = 0 

287 for row in all_rows[:max_scan]: 

288 for j in range(len(row) - 1, -1, -1): 

289 if row[j] is not None and str(row[j]).strip(): 

290 if j + 1 > max_col: 

291 max_col = j + 1 

292 break 

293 if max_col == 0: 

294 return ( 

295 all_rows # pragma: no cover — blank file rejected by header detection first 

296 ) 

297 return [row[:max_col] for row in all_rows] 

298 

299 

300# ── Group-header detection ──────────────────────────────────────────────────── 

301 

302 

303def _merge_label_map(ws: Any) -> dict[tuple[int, int], str]: 

304 """Return {(0-based row, 0-based col): group_label} for every merged region. 

305 

306 The label is the value of the top-left cell of each merged range. 

307 Every cell in the range gets the same label so downstream code can do a 

308 simple lookup without forward-fill. 

309 """ 

310 result: dict[tuple[int, int], str] = {} 

311 for mc in ws.merged_cells.ranges: 

312 val = ws.cell(mc.min_row, mc.min_col).value 

313 if val is None or not str(val).strip(): 

314 continue 

315 label = str(val).strip() 

316 for r in range(mc.min_row - 1, mc.max_row): 

317 for c in range(mc.min_col - 1, mc.max_col): 

318 result[(r, c)] = label 

319 return result 

320 

321 

322def _group_labels_from_map( 

323 merge_map: dict[tuple[int, int], str], row_idx: int, width: int 

324) -> list[str]: 

325 """Return per-column group labels for *row_idx* using exact merge metadata.""" 

326 return [merge_map.get((row_idx, c), "") for c in range(width)] 

327 

328 

329def _group_labels_heuristic(row: list[Any], width: int) -> list[str] | None: 

330 """Fallback for CSV: forward-fill a sparse row to infer group labels. 

331 

332 Requires at least two non-empty values with a gap between them (so a single 

333 spanning title cell does not fire). Returns None if the pattern is absent. 

334 """ 

335 padded: list[Any] = list(row[:width]) + [None] * max(0, width - len(row)) 

336 

337 prev_nonempty_idx: int | None = None 

338 has_span = False 

339 for i, val in enumerate(padded): 

340 if val is not None and str(val).strip(): 

341 if prev_nonempty_idx is not None and i > prev_nonempty_idx + 1: 

342 has_span = True 

343 break 

344 prev_nonempty_idx = i 

345 if not has_span: 

346 return None 

347 

348 result: list[str] = [] 

349 current = "" 

350 for val in padded: 

351 if val is not None and str(val).strip(): 

352 current = str(val).strip() 

353 result.append(current) 

354 return result 

355 

356 

357def _apply_group_labels(group_labels: list[str], raw_header: list[str]) -> list[str]: 

358 """Prepend each non-empty group label to the corresponding column header.""" 

359 result = [] 

360 for i, col in enumerate(raw_header): 

361 label = group_labels[i] if i < len(group_labels) else "" 

362 if label and col: 

363 result.append(f"{label} {col}") 

364 elif label: 

365 result.append(label) 

366 else: 

367 result.append(col) 

368 return result 

369 

370 

371# ── File parsing ────────────────────────────────────────────────────────────── 

372 

373 

374def parse_file(data: bytes, filename: str) -> ParsedFile: 

375 """Parse bytes from an uploaded file into a ParsedFile. 

376 

377 Raises ValueError with a user-friendly message on format errors. 

378 """ 

379 ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" 

380 if ext in ("xlsx", "xls"): 

381 return _parse_excel(data, filename) 

382 if ext == "csv": 

383 return _parse_csv(data, filename) 

384 raise ValueError( 

385 f"Unsupported file format: .{ext} — please upload a .csv or .xlsx file." 

386 ) 

387 

388 

389# English-only Excel tab names used to identify the logbook sheet. 

390# Locale-specific equivalents live in the translation catalogue, keyed under 

391# the "excel tab name" pgettext context (see _preferred_sheet_names below). 

392_PREFERRED_SHEET_NAMES_EN: frozenset[str] = frozenset( 

393 { 

394 "logbook", 

395 "log", 

396 "flights", 

397 "flight log", 

398 "flights log", 

399 "journal", 

400 } 

401) 

402 

403 

404def _preferred_sheet_names() -> frozenset[str]: 

405 """Return preferred sheet names merged with their active-locale translations. 

406 

407 Uses pgettext with a dedicated context rather than plain _() — these 

408 keywords are matched against user-supplied Excel tab names, not 

409 displayed anywhere in the UI, and several of them (logbook/flight log, 

410 log/journal) intentionally translate to the same word in French. A 

411 context keeps that expected collision scoped to this lookup, so it can 

412 never silently mask a real duplicate-translation issue if the same 

413 English word is ever used as an actual UI label elsewhere. 

414 

415 The context string is repeated as a literal at each call site (not a 

416 shared constant) because pybabel's extractor is a static source scanner — 

417 it can only resolve literal string arguments, not a variable reference. 

418 """ 

419 t_logbook = pgettext("excel tab name", "logbook") 

420 t_log = pgettext("excel tab name", "log") 

421 t_flights = pgettext("excel tab name", "flights") 

422 t_flight_log = pgettext("excel tab name", "flight log") 

423 t_flights_log = pgettext("excel tab name", "flights log") 

424 t_journal = pgettext("excel tab name", "journal") 

425 translated = frozenset( 

426 {t_logbook, t_log, t_flights, t_flight_log, t_flights_log, t_journal} 

427 ) 

428 return _PREFERRED_SHEET_NAMES_EN | frozenset(s.strip().lower() for s in translated) 

429 

430 

431def _pick_best_excel_sheet(wb: Any) -> tuple[str, list[list[Any]]]: 

432 """Return (sheet_name, all_rows) for the most likely logbook sheet. 

433 

434 Prefers sheets whose name matches a known logbook keyword (fast path, reads 

435 only the chosen sheet). When no name matches, reads every sheet and scores 

436 by alias matches in the detected header, returning the highest-scoring sheet 

437 together with its rows. Falls back to the first sheet when scoring is 

438 inconclusive. 

439 

440 Rows are returned here because openpyxl read-only worksheets use a one-shot 

441 generator — calling iter_rows() twice on the same worksheet yields nothing on 

442 the second call. 

443 """ 

444 preferred = _preferred_sheet_names() 

445 for ws in wb.worksheets: 

446 if ws.title.strip().lower() in preferred: 

447 return ws.title, [list(row) for row in ws.iter_rows(values_only=True)] 

448 

449 best_name = wb.worksheets[0].title if wb.worksheets else "" 

450 best_score = -1 

451 best_rows: list[list[Any]] = [] 

452 for ws in wb.worksheets: 

453 rows = [list(row) for row in ws.iter_rows(values_only=True)] 

454 trimmed = _trim_trailing_empty_cols(list(rows)) 

455 hi = _find_header_row(trimmed) 

456 score = _header_alias_score(trimmed[hi]) if hi is not None else 0 

457 if score > best_score: 

458 best_score = score 

459 best_name = ws.title 

460 best_rows = rows 

461 return best_name, best_rows 

462 

463 

464def _parse_excel(data: bytes, filename: str) -> ParsedFile: 

465 try: 

466 wb_ro = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) 

467 except Exception as exc: 

468 raise ValueError(f"Could not open Excel file: {exc}") from exc 

469 sheet_name, all_rows = _pick_best_excel_sheet(wb_ro) 

470 wb_ro.close() 

471 

472 # Second load (non-read-only) to access merged cell ranges for exact group labels. 

473 excel_merge_map: dict[tuple[int, int], str] | None = None 

474 try: 

475 wb_full = openpyxl.load_workbook(io.BytesIO(data), data_only=True) 

476 excel_merge_map = _merge_label_map(wb_full[sheet_name]) 

477 wb_full.close() 

478 except Exception: # noqa: S110 # fall back to heuristic in _build_parsed_file 

479 pass 

480 

481 return _build_parsed_file(all_rows, filename, excel_merge_map=excel_merge_map) 

482 

483 

484def _parse_csv(data: bytes, filename: str) -> ParsedFile: 

485 try: 

486 text = data.decode("utf-8-sig") 

487 except UnicodeDecodeError: 

488 text = data.decode("latin-1") 

489 

490 sample = text[:4096] 

491 try: 

492 dialect = csv.Sniffer().sniff(sample) 

493 except csv.Error: 

494 dialect = csv.excel 

495 

496 reader = csv.reader(io.StringIO(text), dialect) 

497 try: 

498 all_rows: list[list[Any]] = list(reader) 

499 except csv.Error as exc: 

500 raise ValueError(f"Could not parse CSV file: {exc}") from exc 

501 return _build_parsed_file(all_rows, filename) 

502 

503 

504def _build_parsed_file( 

505 all_rows: list[list[Any]], 

506 filename: str, 

507 excel_merge_map: dict[tuple[int, int], str] | None = None, 

508) -> ParsedFile: 

509 all_rows = _trim_trailing_empty_cols(all_rows) 

510 header_idx = _find_header_row(all_rows) 

511 if header_idx is None: 

512 raise ValueError( 

513 "Could not detect a header row in this file. " 

514 "Make sure the file contains column names." 

515 ) 

516 

517 raw_header = [str(c).strip() if c is not None else "" for c in all_rows[header_idx]] 

518 

519 # Prepend group labels from the row above the header, if one exists 

520 if header_idx > 0: 

521 width = len(raw_header) 

522 if excel_merge_map is not None: 

523 # Excel: exact boundaries from merged cell metadata 

524 _gl = _group_labels_from_map(excel_merge_map, header_idx - 1, width) 

525 group_labels: list[str] | None = _gl if any(_gl) else None 

526 else: 

527 # CSV: heuristic forward-fill (merged cell info unavailable) 

528 group_labels = _group_labels_heuristic(all_rows[header_idx - 1], width) 

529 if group_labels is not None: 

530 raw_header = _apply_group_labels(group_labels, raw_header) 

531 

532 norm_raw = [_norm(c) for c in raw_header] 

533 norm_cols = _disambiguate(norm_raw) 

534 data_rows = all_rows[header_idx + 1 :] 

535 

536 return ParsedFile( 

537 norm_cols=norm_cols, 

538 raw_cols=raw_header, 

539 header_row_index=header_idx, 

540 data_rows=data_rows, 

541 fingerprint=_fingerprint(norm_cols), 

542 ) 

543 

544 

545# ── Mapping proposal ────────────────────────────────────────────────────────── 

546 

547 

548def propose_mapping( 

549 parsed: ParsedFile, 

550 existing_mappings: list[Any], # list[LogbookImportMapping] 

551) -> MappingProposal: 

552 """Return the best mapping proposal for *parsed*, checking saved mappings first.""" 

553 # 1. Exact fingerprint match 

554 for m in existing_mappings: 

555 if m.source_fingerprint == parsed.fingerprint: 

556 return MappingProposal( 

557 mapping=json.loads(m.column_mapping), 

558 match_type="exact", 

559 matched_mapping_id=m.id, 

560 ) 

561 

562 # 2. Fuzzy match — best overlap among saved mappings 

563 best_score = 0.0 

564 best_m = None 

565 new_set = set(parsed.norm_cols) 

566 for m in existing_mappings: 

567 saved_cols: list[str] = json.loads(m.source_columns) 

568 saved_set = set(saved_cols) 

569 if not saved_set: 

570 continue 

571 overlap = len(new_set & saved_set) 

572 score = overlap / max(len(new_set), len(saved_set)) 

573 if score > best_score: 

574 best_score = score 

575 best_m = m 

576 

577 if best_m is not None and best_score >= 0.6: 

578 saved_map: dict[str, str] = json.loads(best_m.column_mapping) 

579 # Build a mapping for the new columns, falling back to alias for unmatched ones 

580 merged = _alias_mapping(parsed.norm_cols) 

581 for col in parsed.norm_cols: 

582 if col in saved_map: 

583 merged[col] = saved_map[col] 

584 return MappingProposal( 

585 mapping=merged, 

586 match_type="fuzzy", 

587 fuzzy_score=best_score, 

588 matched_mapping_id=best_m.id, 

589 ) 

590 

591 # 3. Alias-only auto-mapping 

592 return MappingProposal( 

593 mapping=_alias_mapping(parsed.norm_cols), 

594 match_type="alias", 

595 ) 

596 

597 

598def _alias_mapping(norm_cols: list[str]) -> dict[str, str]: 

599 """Apply the built-in alias table; unknown columns default to 'ignore'.""" 

600 result: dict[str, str] = {} 

601 for col in norm_cols: 

602 result[col] = _ALIASES.get(col, "ignore") 

603 return result 

604 

605 

606# ── Subtotal detection ──────────────────────────────────────────────────────── 

607 

608 

609def _is_subtotal_row(row: list[Any], date_col_idx: int | None) -> bool: 

610 if date_col_idx is None: 

611 return False 

612 if date_col_idx >= len(row): 

613 return True 

614 val = row[date_col_idx] 

615 if isinstance(val, timedelta): 

616 return True 

617 if val is None or (isinstance(val, str) and not val.strip()): 

618 return True 

619 if isinstance(val, str) and "total" in val.lower(): 

620 return True 

621 return False 

622 

623 

624# ── Value parsing ───────────────────────────────────────────────────────────── 

625 

626 

627def parse_date_value(val: Any) -> date | None: 

628 if isinstance(val, datetime): 

629 return val.date() 

630 if isinstance(val, date): 

631 return val 

632 if isinstance(val, (int, float)): 

633 # Excel serial date number — openpyxl returns datetime; guard anyway 

634 return None 

635 if not isinstance(val, str): 

636 return None 

637 s = val.strip() 

638 if not s: 

639 return None 

640 for fmt in ("%d/%m/%y", "%d/%m/%Y", "%Y-%m-%d", "%m/%d/%Y", "%d-%m-%Y"): 

641 try: 

642 return datetime.strptime(s, fmt).date() 

643 except ValueError: 

644 pass 

645 return None 

646 

647 

648def parse_time_value(val: Any) -> time | None: 

649 """Parse a time-of-day value (HH:MM or Python time).""" 

650 if isinstance(val, time): 

651 return val 

652 if isinstance(val, datetime): 

653 return val.time() 

654 if not isinstance(val, str): 

655 return None 

656 s = val.strip() 

657 if not s: 

658 return None 

659 m = re.match(r"^(\d{1,2}):(\d{2})$", s) 

660 if m: 

661 try: 

662 return time(int(m.group(1)), int(m.group(2))) 

663 except ValueError: 

664 return None 

665 return None 

666 

667 

668def parse_duration_value(val: Any) -> float | None: 

669 """Parse a duration into decimal hours (e.g. time(0,42) → 0.7, '1:24' → 1.4).""" 

670 if isinstance(val, timedelta): 

671 hours = val.total_seconds() / 3600 

672 return round(hours, 1) if hours >= 0 else None 

673 if isinstance(val, time): 

674 return round(val.hour + val.minute / 60, 1) 

675 if isinstance(val, datetime): 

676 # Excel sometimes returns a dummy date + the time-of-day 

677 t = val.time() 

678 return round(t.hour + t.minute / 60, 1) 

679 if isinstance(val, (int, float)): 

680 if not math.isfinite(val) or val < 0: 

681 return None 

682 return round(float(val), 1) 

683 if isinstance(val, str): 

684 s = val.strip() 

685 if not s: 

686 return None 

687 m = re.match(r"^(\d+):(\d{2})$", s) 

688 if m: 

689 return round(int(m.group(1)) + int(m.group(2)) / 60, 1) 

690 try: 

691 v = float(s) 

692 return round(v, 1) if math.isfinite(v) and v >= 0 else None 

693 except ValueError: 

694 return None 

695 return None 

696 

697 

698def parse_int_value(val: Any) -> int | None: 

699 if isinstance(val, int): 

700 return val if val >= 0 else None 

701 if isinstance(val, float): 

702 try: 

703 return int(val) if val >= 0 else None 

704 except (ValueError, OverflowError): 

705 return None 

706 if isinstance(val, str): 

707 s = val.strip() 

708 if not s: 

709 return None 

710 try: 

711 n = int(float(s)) 

712 except (ValueError, OverflowError): 

713 return None 

714 return n if n >= 0 else None 

715 return None 

716 

717 

718def _is_nonempty(val: Any) -> bool: 

719 """Return True when val carries a real value (not None, not blank string).""" 

720 if val is None: 

721 return False 

722 if isinstance(val, str): 

723 return bool(val.strip()) 

724 return True 

725 

726 

727# Map target field → (human-readable type name, parser function) 

728_FIELD_TYPE: dict[str, tuple[str, Callable[[Any], Any]]] = { 

729 "date": ("date", parse_date_value), 

730 "departure_time": ("time", parse_time_value), 

731 "arrival_time": ("time", parse_time_value), 

732 "night_time": ("duration", parse_duration_value), 

733 "instrument_time": ("duration", parse_duration_value), 

734 "cross_country": ("duration", parse_duration_value), 

735 "total_flight_time_check": ("duration", parse_duration_value), 

736 "single_pilot_se": ("duration", parse_duration_value), 

737 "single_pilot_me": ("duration", parse_duration_value), 

738 "multi_pilot": ("duration", parse_duration_value), 

739 "function_pic": ("duration", parse_duration_value), 

740 "function_copilot": ("duration", parse_duration_value), 

741 "function_dual": ("duration", parse_duration_value), 

742 "function_instructor": ("duration", parse_duration_value), 

743 "landings_day": ("integer", parse_int_value), 

744 "landings_night": ("integer", parse_int_value), 

745} 

746 

747_HINT_SAMPLE_ROWS = 5 

748 

749 

750def type_hints(parsed: ParsedFile, mapping: dict[str, str]) -> dict[str, str]: 

751 """Return {col: hint_text} for columns where sample data doesn't match the proposed type. 

752 

753 Samples up to _HINT_SAMPLE_ROWS rows. Returns a hint when non-empty values are 

754 present but any of them fail to parse — indicating a likely mapping mismatch. 

755 """ 

756 col_index = {col: i for i, col in enumerate(parsed.norm_cols)} 

757 hints: dict[str, str] = {} 

758 for col, target in mapping.items(): 

759 if target not in _FIELD_TYPE: 

760 continue 

761 type_name, parser = _FIELD_TYPE[target] 

762 idx = col_index.get(col) 

763 if idx is None: 

764 continue 

765 sample = [ 

766 row[idx] 

767 for row in parsed.data_rows[:_HINT_SAMPLE_ROWS] 

768 if idx < len(row) and _is_nonempty(row[idx]) 

769 ] 

770 if not sample: 

771 continue 

772 failed = [v for v in sample if parser(v) is None] 

773 if failed: 

774 example = str(failed[0])[:30] 

775 hints[col] = ( 

776 f"Sample data doesn't look like a {type_name} (e.g. {example!r})" 

777 ) 

778 return hints 

779 

780 

781# ── Preview rows ────────────────────────────────────────────────────────────── 

782 

783 

784def preview_rows( 

785 parsed: ParsedFile, 

786 mapping: dict[str, str], 

787 n: int = 5, 

788) -> list[dict[str, Any]]: 

789 """Return up to *n* non-subtotal data rows mapped to target field names.""" 

790 date_idx = _date_col_index(parsed.norm_cols, mapping) 

791 result: list[dict[str, Any]] = [] 

792 for row in parsed.data_rows: 

793 if _is_subtotal_row(row, date_idx): 

794 continue 

795 mapped: dict[str, Any] = {} 

796 for i, col in enumerate(parsed.norm_cols): 

797 target = mapping.get(col, "ignore") 

798 if target == "ignore": 

799 continue 

800 mapped[target] = row[i] if i < len(row) else None 

801 result.append(mapped) 

802 if len(result) >= n: 

803 break 

804 return result 

805 

806 

807def _date_col_index(norm_cols: list[str], mapping: dict[str, str]) -> int | None: 

808 for i, col in enumerate(norm_cols): 

809 if mapping.get(col) == "date": 

810 return i 

811 return None 

812 

813 

814# ── Import execution ────────────────────────────────────────────────────────── 

815 

816 

817def _row_get(row: list[Any], col_index: dict[str, int], col: str) -> Any: 

818 i = col_index.get(col) 

819 return row[i] if i is not None and i < len(row) else None 

820 

821 

822def _parse_row_date( 

823 row: list[Any], mapping: dict[str, str], col_index: dict[str, int] 

824) -> date | None: 

825 for col, target in mapping.items(): 

826 if target == "date": 

827 return parse_date_value(_row_get(row, col_index, col)) 

828 return None 

829 

830 

831def _build_entry_kwargs( 

832 row: list[Any], 

833 mapping: dict[str, str], 

834 col_index: dict[str, int], 

835 date_val: date, 

836) -> tuple[dict[str, Any], float | None, list[tuple[str, str, str]]]: 

837 """Build Flight (standalone, aircraft_id NULL) field kwargs for one data 

838 row (identity/batch fields like pic_user_id are the caller's 

839 responsibility to add). 

840 

841 Returns (kwargs, source_total_flight_time_check, parse_warnings), where 

842 parse_warnings is a list of (col, target, raw_repr) for non-empty cells 

843 that couldn't be parsed as their target field's type. Shared by the 

844 normal import pass and the near-match conflict finder below, so the two 

845 can never compute a row's fields differently. 

846 """ 

847 kwargs: dict[str, Any] = {"date": date_val} 

848 source_total: float | None = None 

849 parse_warnings: list[tuple[str, str, str]] = [] 

850 

851 for col, target in mapping.items(): 

852 if target in ("ignore", "date"): 

853 continue 

854 raw = _row_get(row, col_index, col) 

855 if target == "total_flight_time_check": 

856 if _is_nonempty(raw): 

857 source_total = parse_duration_value(raw) 

858 continue 

859 if target in _FIELD_TYPE: 

860 # NB: unpack into _type_name, not _ — callers of this module also 

861 # call gettext (imported as `_`) in the same function scope; 

862 # reassigning `_` would shadow it for the rest of that function 

863 # (Python's function-scoping, not block-scoping). 

864 _type_name, parser = _FIELD_TYPE[target] 

865 parsed_val = parser(raw) 

866 if parsed_val is None and _is_nonempty(raw): 

867 parse_warnings.append((col, target, repr(str(raw)[:40]))) 

868 kwargs[target] = parsed_val 

869 elif target == "aircraft_type": 

870 val = str(raw).strip() if raw is not None else None 

871 kwargs["other_aircraft_type"] = val 

872 if val: 

873 from utils import resolve_aircraft_type_icao # pyright: ignore[reportMissingImports] 

874 

875 kwargs["other_aircraft_type_icao"] = resolve_aircraft_type_icao(val) 

876 elif target == "aircraft_registration": 

877 kwargs["other_aircraft_registration"] = ( 

878 str(raw).strip() if raw is not None else None 

879 ) 

880 elif target == "departure_place": 

881 kwargs["departure_icao"] = str(raw).strip() if raw is not None else None 

882 elif target == "arrival_place": 

883 kwargs["arrival_icao"] = str(raw).strip() if raw is not None else None 

884 elif target == "pic_name": 

885 kwargs["pic_name"] = str(raw).strip() if raw is not None else None 

886 elif target == "remarks": 

887 kwargs["notes"] = str(raw).strip() if raw is not None else None 

888 

889 return kwargs, source_total, parse_warnings 

890 

891 

892def _num(v: Any) -> float | None: 

893 # Numeric columns come back as decimal.Decimal; the freshly parsed side 

894 # is always plain float (parse_duration_value). Decimal('0.7') != 0.7 

895 # (float) directly — comparing Decimals converted from float literals 

896 # hits binary-rounding mismatches — so normalise both sides to float 

897 # before building/comparing keys. 

898 return None if v is None else float(v) 

899 

900 

901def _dup_key(kwargs: dict[str, Any]) -> tuple[Any, ...]: 

902 """Exact-duplicate key: date + aircraft + duration + landings. Shared by 

903 execute_import (silently skips exact matches) and find_conflicting_rows 

904 (must not also flag them as a near-match conflict needing review).""" 

905 return ( 

906 kwargs["date"], 

907 kwargs.get("other_aircraft_registration"), 

908 kwargs.get("single_pilot_se"), 

909 kwargs.get("single_pilot_me"), 

910 kwargs.get("multi_pilot"), 

911 kwargs.get("landings_day"), 

912 kwargs.get("landings_night"), 

913 ) 

914 

915 

916def _fetch_existing_dedup_keys(pilot_user_id: int) -> set[tuple[Any, ...]]: 

917 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

918 

919 from models import Aircraft, Flight, db # pyright: ignore[reportMissingImports] 

920 

921 # Registration is Aircraft.registration for a managed-aircraft row, or 

922 # the free-text other_aircraft_registration for a standalone one. 

923 rows = ( 

924 db.session.query( 

925 Flight.date, 

926 Flight.other_aircraft_registration, 

927 Aircraft.registration, 

928 Flight.single_pilot_se, 

929 Flight.single_pilot_me, 

930 Flight.multi_pilot, 

931 Flight.landings_day, 

932 Flight.landings_night, 

933 ) 

934 .outerjoin(Aircraft, Aircraft.id == Flight.aircraft_id) 

935 .filter( 

936 or_( 

937 Flight.pic_user_id == pilot_user_id, 

938 Flight.second_crew_user_id == pilot_user_id, 

939 ) 

940 ) 

941 ) 

942 return { 

943 ( 

944 row[0], 

945 row[2] or row[1], 

946 _num(row[3]), 

947 _num(row[4]), 

948 _num(row[5]), 

949 row[6], 

950 row[7], 

951 ) 

952 for row in rows 

953 } 

954 

955 

956def _assign_pilot_identity(kwargs: dict[str, Any], pilot_user_id: int) -> None: 

957 """Put *pilot_user_id* into the correct crew slot for one row (mutates 

958 *kwargs* in place), based on its function_* breakdown. 

959 

960 A personal-logbook row's "PIC name" column (already in kwargs["pic_name"] 

961 by the time this runs) records the actual PIC's name as free text, 

962 independent of which slot the importing pilot occupies on that row. When 

963 the row shows dual-received or copilot time and no PIC time, the 

964 importing pilot was not the PIC — they belong in the second_crew_* slot 

965 (as student or copilot) instead, leaving pic_user_id unset so pic_name's 

966 free-text name remains the row's only PIC identity. Any other case (PIC 

967 time logged, instructor time logged, or no function breakdown at all — 

968 e.g. a source file with no "Pilot function" column) keeps pic_user_id as 

969 the importing pilot, matching this app's original single-slot behaviour. 

970 """ 

971 from models import CrewRole # pyright: ignore[reportMissingImports] 

972 

973 function_pic = kwargs.get("function_pic") 

974 function_dual = kwargs.get("function_dual") 

975 function_copilot = kwargs.get("function_copilot") 

976 

977 if not function_pic and function_dual: 

978 kwargs["second_crew_user_id"] = pilot_user_id 

979 kwargs["second_crew_role"] = CrewRole.STUDENT 

980 elif not function_pic and function_copilot: 

981 kwargs["second_crew_user_id"] = pilot_user_id 

982 kwargs["second_crew_role"] = CrewRole.COPILOT 

983 else: 

984 kwargs["pic_user_id"] = pilot_user_id 

985 

986 

987def execute_import( 

988 parsed: ParsedFile, 

989 mapping: dict[str, str], 

990 pilot_user_id: int, 

991 batch_id: int, 

992 opening_balance: dict[str, Any] | None = None, 

993 skip_row_nums: set[int] | None = None, 

994) -> ImportResult: 

995 """Create standalone Flight rows (aircraft_id NULL) from *parsed* using 

996 *mapping*. 

997 

998 Returns an ImportResult describing what happened. Entries are added to 

999 db.session but NOT committed — the caller commits after also saving the 

1000 batch/mapping records. *skip_row_nums* (1-based, matching the row 

1001 numbering used throughout this module) lets the caller carve out rows 

1002 it's handling separately — e.g. near-match conflicts routed to the 

1003 interactive review step in app/pilots/routes.py — so they're excluded 

1004 entirely from this pass (not counted as imported, duplicate, or skipped). 

1005 """ 

1006 from models import Flight, db # pyright: ignore[reportMissingImports] 

1007 

1008 result = ImportResult() 

1009 date_idx = _date_col_index(parsed.norm_cols, mapping) 

1010 col_index = {col: i for i, col in enumerate(parsed.norm_cols)} 

1011 skip_row_nums = skip_row_nums or set() 

1012 

1013 entries_to_add: list[Flight] = [] 

1014 

1015 # Duplicate detection: re-importing the same file — either by mistake, or 

1016 # deliberately after appending new rows to the source spreadsheet, which 

1017 # is the expected workflow for keeping an import-based logbook current — 

1018 # must only add genuinely new rows, not double up everything already 

1019 # imported. Match on date + aircraft + the duration/landings figures 

1020 # rather than departure/arrival place & time: those are the fields that 

1021 # actually drive currency and compliance totals, and they stay populated 

1022 # even for entries whose route/time genuinely isn't captured (e.g. a 

1023 # hand-entered or backfilled logbook row) — a key that depended on 

1024 # route/time would silently fail to match those. 

1025 existing_keys = _fetch_existing_dedup_keys(pilot_user_id) 

1026 

1027 for row_num, row in enumerate(parsed.data_rows, start=1): 

1028 if row_num in skip_row_nums: 

1029 continue 

1030 if _is_subtotal_row(row, date_idx): 

1031 result.subtotals += 1 

1032 continue 

1033 

1034 date_val = _parse_row_date(row, mapping, col_index) 

1035 if date_val is None: 

1036 raw_date = ( 

1037 row[date_idx] if date_idx is not None and date_idx < len(row) else None 

1038 ) 

1039 result.skipped.append((row_num, f"unparseable date: {raw_date!r}")) 

1040 continue 

1041 

1042 kwargs, source_total, parse_warnings = _build_entry_kwargs( 

1043 row, mapping, col_index, date_val 

1044 ) 

1045 _assign_pilot_identity(kwargs, pilot_user_id) 

1046 kwargs["import_batch_id"] = batch_id 

1047 kwargs["source"] = "import" 

1048 for col, target, raw_repr in parse_warnings: 

1049 result.parse_warnings.append((row_num, col, target, raw_repr)) 

1050 

1051 dup_key = _dup_key(kwargs) 

1052 if dup_key in existing_keys: 

1053 result.duplicates.append( 

1054 ( 

1055 row_num, 

1056 _( 

1057 "matches an entry already in your logbook " 

1058 "(same date, aircraft, duration and landings)" 

1059 ), 

1060 ) 

1061 ) 

1062 continue 

1063 existing_keys.add(dup_key) 

1064 

1065 if source_total is not None: 

1066 computed = round( 

1067 sum( 

1068 float(kwargs.get(f) or 0) 

1069 for f in ("single_pilot_se", "single_pilot_me", "multi_pilot") 

1070 ), 

1071 1, 

1072 ) 

1073 if abs(source_total - computed) >= 0.15: 

1074 result.total_mismatch_warnings.append((row_num, source_total, computed)) 

1075 

1076 entries_to_add.append(Flight(**kwargs)) 

1077 

1078 result.imported = len(entries_to_add) 

1079 for e in entries_to_add: 

1080 db.session.add(e) 

1081 

1082 # Opening balance — synthetic entry dated one day before earliest imported. 

1083 # Only one opening-balance entry ever makes sense per pilot — re-importing 

1084 # with opening-balance values filled in again must not create a second one. 

1085 if opening_balance and any(v for v in opening_balance.values() if v): 

1086 ob_exists = ( 

1087 db.session.query(Flight.id) 

1088 .filter_by(pic_user_id=pilot_user_id, notes="Opening balance (imported)") 

1089 .first() 

1090 is not None 

1091 ) 

1092 if ob_exists: 

1093 result.duplicates.append( 

1094 ( 

1095 0, 

1096 _( 

1097 "opening balance: an opening balance entry already " 

1098 "exists for this pilot — not created again" 

1099 ), 

1100 ) 

1101 ) 

1102 else: 

1103 earliest = ( 

1104 min(e.date for e in entries_to_add) if entries_to_add else date.today() 

1105 ) 

1106 from datetime import timedelta as _td 

1107 

1108 balance_date = earliest - _td(days=1) 

1109 balance_entry = Flight( 

1110 pic_user_id=pilot_user_id, 

1111 import_batch_id=batch_id, 

1112 source="import", 

1113 date=balance_date, 

1114 notes="Opening balance (imported)", 

1115 night_time=opening_balance.get("night_time"), 

1116 instrument_time=opening_balance.get("instrument_time"), 

1117 single_pilot_se=opening_balance.get("single_pilot_se"), 

1118 single_pilot_me=opening_balance.get("single_pilot_me"), 

1119 multi_pilot=opening_balance.get("multi_pilot"), 

1120 function_pic=opening_balance.get("function_pic"), 

1121 function_copilot=opening_balance.get("function_copilot"), 

1122 function_dual=opening_balance.get("function_dual"), 

1123 function_instructor=opening_balance.get("function_instructor"), 

1124 ) 

1125 db.session.add(balance_entry) 

1126 result.has_opening_balance = True 

1127 

1128 return result 

1129 

1130 

1131# ── Near-match conflict detection (possible corrections) ─────────────────────── 

1132 

1133_CANDIDATE_MIN_SCORE = 3 

1134_CANDIDATE_TIME_TOLERANCE_MINUTES = 120 

1135_CANDIDATE_DURATION_TOLERANCE = 1.0 

1136 

1137 

1138@dataclass 

1139class ConflictRow: 

1140 """A parsed row that isn't an exact duplicate but scores highly enough 

1141 against one or more existing entries to plausibly be an edited version 

1142 of one of them — needs a human decision, not a guess.""" 

1143 

1144 row_num: int 

1145 kwargs: dict[str, Any] 

1146 candidates: list[tuple[int, int]] # (score, existing_entry_id), best first 

1147 

1148 

1149def _kwargs_duration(kwargs: dict[str, Any]) -> float | None: 

1150 parts = [ 

1151 kwargs.get(f) for f in ("single_pilot_se", "single_pilot_me", "multi_pilot") 

1152 ] 

1153 vals = [float(p) for p in parts if p is not None] 

1154 return round(sum(vals), 1) if vals else None 

1155 

1156 

1157def _time_close(t1: time | None, t2: time | None, tolerance_minutes: int) -> bool: 

1158 if t1 is None or t2 is None: 

1159 return False 

1160 m1 = t1.hour * 60 + t1.minute 

1161 m2 = t2.hour * 60 + t2.minute 

1162 return abs(m1 - m2) <= tolerance_minutes 

1163 

1164 

1165def _score_candidate(kwargs: dict[str, Any], existing: Any) -> int: 

1166 """Score how likely *existing* (a Flight row) is the same real-world 

1167 flight as *kwargs* (a freshly parsed row), across 7 points: registration, 

1168 departure, arrival, departure time, arrival time, total duration, 

1169 landings. A point only counts toward the score if both sides have data 

1170 for it — missing data on either side is neutral, never a mismatch, so a 

1171 row whose route/time isn't captured on one side can still be recognised 

1172 via duration + landings alone. 

1173 """ 

1174 score = 0 

1175 

1176 reg_new = (kwargs.get("other_aircraft_registration") or "").strip().upper() 

1177 reg_old = (existing.display_registration or "").strip().upper() 

1178 if reg_new and reg_old and reg_new == reg_old: 

1179 score += 1 

1180 

1181 dep_new = (kwargs.get("departure_icao") or "").strip().upper() 

1182 dep_old = (existing.departure_icao or "").strip().upper() 

1183 if dep_new and dep_old and dep_new == dep_old: 

1184 score += 1 

1185 

1186 arr_new = (kwargs.get("arrival_icao") or "").strip().upper() 

1187 arr_old = (existing.arrival_icao or "").strip().upper() 

1188 if arr_new and arr_old and arr_new == arr_old: 

1189 score += 1 

1190 

1191 if _time_close( 

1192 kwargs.get("departure_time"), 

1193 existing.departure_time, 

1194 _CANDIDATE_TIME_TOLERANCE_MINUTES, 

1195 ): 

1196 score += 1 

1197 

1198 if _time_close( 

1199 kwargs.get("arrival_time"), 

1200 existing.arrival_time, 

1201 _CANDIDATE_TIME_TOLERANCE_MINUTES, 

1202 ): 

1203 score += 1 

1204 

1205 dur_new = _kwargs_duration(kwargs) 

1206 dur_old = existing.total_flight_time 

1207 if ( 

1208 dur_new is not None 

1209 and dur_old is not None 

1210 and abs(dur_new - dur_old) <= _CANDIDATE_DURATION_TOLERANCE 

1211 ): 

1212 score += 1 

1213 

1214 land_day = kwargs.get("landings_day") 

1215 land_night = kwargs.get("landings_night") 

1216 if ( 

1217 land_day is not None 

1218 and land_night is not None 

1219 and existing.landings_day is not None 

1220 and existing.landings_night is not None 

1221 and land_day == existing.landings_day 

1222 and land_night == existing.landings_night 

1223 ): 

1224 score += 1 

1225 

1226 return score 

1227 

1228 

1229def find_conflicting_rows( 

1230 parsed: ParsedFile, 

1231 mapping: dict[str, str], 

1232 pilot_user_id: int, 

1233 exclude_row_nums: set[int] | None = None, 

1234) -> list[ConflictRow]: 

1235 """Find rows that aren't an exact duplicate but plausibly match an 

1236 existing entry closely enough (score >= _CANDIDATE_MIN_SCORE) to need a 

1237 human decision: keep the existing entry, overwrite it with the new data, 

1238 or import as a genuinely separate new entry. 

1239 

1240 Skips subtotal rows, rows with an unparseable date, and exact duplicates 

1241 (same as execute_import's own dedup — an unmodified re-upload scores the 

1242 maximum on every point, so without this check it would incorrectly be 

1243 routed to review instead of being silently skipped), plus any row_num in 

1244 *exclude_row_nums* — typically rows already resolved in an earlier pass 

1245 of this same review. 

1246 

1247 Candidates also include *unclaimed* rows (pic_user_id AND 

1248 second_crew_user_id both NULL) on a managed aircraft belonging to one of 

1249 the pilot's own tenants — typically a hand-entered airframe-log row from 

1250 before this pilot ever ran a logbook import. Without this, those rows 

1251 are invisible to both this check and execute_import's own dedup (both 

1252 only look at rows already linked to *pilot_user_id*), so re-importing a 

1253 personal logbook can never discover that the real-world flight is 

1254 already logged from the airframe side — it silently creates a second, 

1255 duplicate Flight row instead of surfacing a conflict to resolve. 

1256 """ 

1257 from sqlalchemy import and_, or_ # pyright: ignore[reportMissingImports] 

1258 

1259 from models import Aircraft, Flight, TenantUser # pyright: ignore[reportMissingImports] 

1260 

1261 exclude_row_nums = exclude_row_nums or set() 

1262 date_idx = _date_col_index(parsed.norm_cols, mapping) 

1263 col_index = {col: i for i, col in enumerate(parsed.norm_cols)} 

1264 existing_keys = _fetch_existing_dedup_keys(pilot_user_id) 

1265 conflicts: list[ConflictRow] = [] 

1266 

1267 tenant_ids = [ 

1268 row.tenant_id for row in TenantUser.query.filter_by(user_id=pilot_user_id).all() 

1269 ] 

1270 

1271 for row_num, row in enumerate(parsed.data_rows, start=1): 

1272 if row_num in exclude_row_nums: 

1273 continue 

1274 if _is_subtotal_row(row, date_idx): 

1275 continue 

1276 date_val = _parse_row_date(row, mapping, col_index) 

1277 if date_val is None: 

1278 continue 

1279 

1280 kwargs, _source_total, _parse_warnings = _build_entry_kwargs( 

1281 row, mapping, col_index, date_val 

1282 ) 

1283 if _dup_key(kwargs) in existing_keys: 

1284 continue # exact duplicate — execute_import's own dedup handles this 

1285 

1286 same_day_filters = [ 

1287 Flight.pic_user_id == pilot_user_id, 

1288 Flight.second_crew_user_id == pilot_user_id, 

1289 ] 

1290 if tenant_ids: 

1291 same_day_filters.append( 

1292 and_( 

1293 Flight.pic_user_id.is_(None), 

1294 Flight.second_crew_user_id.is_(None), 

1295 Flight.aircraft_id.in_( 

1296 Aircraft.query.with_entities(Aircraft.id).filter( 

1297 Aircraft.tenant_id.in_(tenant_ids) 

1298 ) 

1299 ), 

1300 ) 

1301 ) 

1302 same_day = Flight.query.filter( 

1303 or_(*same_day_filters), 

1304 Flight.date == date_val, 

1305 ).all() 

1306 scored = [ 

1307 (score, existing.id) 

1308 for existing in same_day 

1309 if (score := _score_candidate(kwargs, existing)) >= _CANDIDATE_MIN_SCORE 

1310 ] 

1311 if scored: 

1312 scored.sort(key=lambda t: -t[0]) 

1313 conflicts.append( 

1314 ConflictRow(row_num=row_num, kwargs=kwargs, candidates=scored) 

1315 ) 

1316 

1317 return conflicts 

1318 

1319 

1320def link_entries_to_aircraft(entries: list[Any]) -> int: 

1321 """Promote each standalone Flight row (aircraft_id NULL, 

1322 other_aircraft_registration set) in *entries* to a managed aircraft, for 

1323 any whose registration matches an Aircraft belonging to one of the row's 

1324 pic/second-crew pilot's own tenants. Returns the count promoted. Caller 

1325 must commit. 

1326 

1327 Unified-model note: this used to create a brand-new FlightEntry + 

1328 FlightCrew and link the pilot's existing PilotLogbookEntry to it via 

1329 flight_id. There's only one row now, so promotion is just updating 

1330 aircraft_id (+ clearing the free-text other_aircraft_* fields) on the 

1331 row that already exists — no second row, no separate crew row (the 

1332 row's pic_user_id/pic_name are already whatever the CSV import set). 

1333 """ 

1334 from models import Aircraft, TenantUser, User, db # pyright: ignore[reportMissingImports] 

1335 

1336 def _norm_reg(reg: str) -> str: 

1337 return reg.upper().replace("-", "").replace(" ", "") 

1338 

1339 ac_by_tenant: dict[int, dict[str, Any]] = {} 

1340 for ac in Aircraft.query.all(): 

1341 ac_by_tenant.setdefault(ac.tenant_id, {})[_norm_reg(ac.registration)] = ac 

1342 

1343 pilot_tenant_ids: dict[int, set[int]] = {} 

1344 

1345 def _tenants_for_pilot(pilot_user_id: int) -> set[int]: 

1346 if pilot_user_id not in pilot_tenant_ids: 

1347 pilot_tenant_ids[pilot_user_id] = { 

1348 row.tenant_id 

1349 for row in TenantUser.query.filter_by(user_id=pilot_user_id).all() 

1350 } 

1351 return pilot_tenant_ids[pilot_user_id] 

1352 

1353 def _place_icao(place: str | None) -> str: 

1354 if not place: 

1355 return "ZZZZ" 

1356 clean = re.sub(r"[^A-Z0-9]", "", place.upper())[:4] 

1357 return clean if len(clean) == 4 else "ZZZZ" 

1358 

1359 promoted = 0 

1360 for entry in entries: 

1361 if entry.aircraft_id is not None or not entry.other_aircraft_registration: 

1362 continue 

1363 pilot_user_id = entry.pic_user_id or entry.second_crew_user_id 

1364 if pilot_user_id is None: 

1365 continue 

1366 norm_reg = _norm_reg(entry.other_aircraft_registration) 

1367 ac = None 

1368 for tid in _tenants_for_pilot(pilot_user_id): 

1369 ac = ac_by_tenant.get(tid, {}).get(norm_reg) 

1370 if ac is not None: 

1371 break 

1372 if ac is None: 

1373 continue 

1374 

1375 if entry.departure_time is not None: 

1376 dummy = datetime.combine(date.min, entry.departure_time) - timedelta( 

1377 hours=float(ac.flight_counter_offset) 

1378 ) 

1379 entry.departure_time = dummy.time() 

1380 

1381 entry.aircraft_id = ac.id 

1382 entry.departure_icao = _place_icao(entry.departure_icao) 

1383 entry.arrival_icao = _place_icao(entry.arrival_icao) 

1384 entry.other_aircraft_type = None 

1385 entry.other_aircraft_type_icao = None 

1386 entry.other_aircraft_registration = None 

1387 entry.source = "logbook_import" 

1388 

1389 # Step 2 (docs/backlog.md "reconcile imports from either side"): the 

1390 # true airframe-side fields (flight_time_counter_*/flight_time, 

1391 # engine_time_counter_*/engine_time — the maintenance-relevant hour 

1392 # figures) aren't known from a personal logbook and stay NULL, same 

1393 # as everywhere else in this app "if a field is not filled, it's not 

1394 # filled" — no supposition, interpolation, or guessed value gets 

1395 # invented here. That's also what keeps the flight list's 

1396 # needs-attention icon (source == "logbook_import" and flight_time 

1397 # is None) lit until a human fills these in from a real airframe 

1398 # source. (A previous version of this function seeded a rough 

1399 # engine_time_counter estimate here, on the assumption that nothing 

1400 # read it for maintenance tracking — services/component_limits.py 

1401 # now sums engine_time_counter for engine/propeller TBO, so that 

1402 # assumption no longer holds and the guess was removed.) 

1403 

1404 if not entry.pic_name: 

1405 pilot_user = db.session.get(User, pilot_user_id) 

1406 if pilot_user: 

1407 entry.pic_name = pilot_user.name or pilot_user.email 

1408 

1409 promoted += 1 

1410 

1411 return promoted