diff --git a/app.py b/app.py index f460b17..3b9d41a 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -import os, io, random, requests, openpyxl, concurrent.futures +import os, io, random, requests, openpyxl, concurrent.futures, argparse from flask import Flask, render_template, request, jsonify, send_file import logging @@ -345,6 +345,41 @@ def get_active(): return t, None, None +@app.route('/api/vworld-api-status') +def vworld_api_status(): + """VWorld API Key의 유효성을 확인합니다.""" + if not VWORLD_API_KEY: + return jsonify({'valid': False, 'key': None}) + + # 간단한 주소로 VWorld API 테스트 + try: + test_address = '서울특별시 용산구' + url = 'https://api.vworld.kr/req/address' + params = { + 'service': 'address', + 'request': 'getcoord', + 'version': '2.0', + 'crs': 'EPSG:4326', + 'address': test_address, + 'format': 'json', + 'type': 'PARCEL', + 'key': VWORLD_API_KEY, + } + resp = requests.get(url, params=params, timeout=5) + + if resp.ok: + data = resp.json() + response = data.get('response', {}) + status = response.get('status') + # OK, SUCCESS, FAIL 등의 상태가 반환되면 유효한 API Key + if status in ['OK', 'SUCCESS', 'FAIL']: + return jsonify({'valid': True, 'key': VWORLD_API_KEY}) + except Exception as e: + logger.debug(f'VWorld API 테스트 실패: {e}') + + return jsonify({'valid': False, 'key': None}) + + @app.route('/api/data') def get_data(): tid = request.args.get('tab') or store['active_tab'] @@ -381,6 +416,186 @@ def is_likely_worker_sheet(sheet_name, headers): return worker_indicators >= 2 +def download_from_google_drive(file_id, timeout=30): + """구글 드라이브에서 파일을 다운로드합니다.""" + url = f'https://drive.google.com/uc?id={file_id}&export=download' + + try: + logger.info(f'구글 드라이브에서 파일 다운로드 중: {file_id}') + resp = requests.get(url, timeout=timeout, allow_redirects=True, stream=True) + + if not resp.ok: + logger.error(f'구글 드라이브 다운로드 실패: 상태코드={resp.status_code}') + return None + + # 파일 크기 확인 + file_size = int(resp.headers.get('content-length', 0)) + if file_size == 0: + logger.warning('다운로드한 파일의 크기가 0입니다') + return None + + if file_size > 16 * 1024 * 1024: # 16MB 이상 + logger.error(f'파일이 너무 큽니다: {file_size} bytes') + return None + + # 파일을 바이너리로 읽기 + file_content = io.BytesIO(resp.content) + logger.info(f'파일 다운로드 완료: {file_size} bytes') + return file_content + except requests.exceptions.Timeout: + logger.error(f'구글 드라이브 다운로드 타임아웃: {file_id}') + return None + except Exception as e: + logger.error(f'구글 드라이브 다운로드 중 오류: {e}') + return None + + +def process_excel_file(wb, filename): + """엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다.""" + workers, workplaces, all_addrs = [], [], set() + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + headers = [str(c.value or '').strip() for c in ws[1]] + logger.info(f' 시트: {sheet_name}, 헤더: {headers}') + update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}') + + hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망']) + is_worker_sheet = hope_idx is not None + + name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명']) + addr_idx = find_header_idx(headers, ['주소', '위치', '소재지']) + + logger.info(f' → 희망사항: {hope_idx}, 이름: {name_idx}, 주소: {addr_idx}, 근무자시트: {is_worker_sheet}') + + if is_worker_sheet: + hope_idx = hope_idx if hope_idx is not None else 1 + name_idx = name_idx if name_idx is not None else 0 + dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3 + phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4 + if addr_idx is None: + addr_idx = 2 + logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용') + + row_count = 0 + for row in ws.iter_rows(min_row=2, values_only=True): + if not any(row): continue + name = str(row[name_idx] or '').strip() if name_idx is not None else '' + if not name: continue + addr = str(row[addr_idx] or '').strip() + if addr: + all_addrs.add(addr) + row_count += 1 + logger.info(f' → 근무자 행 수: {row_count}') + else: + name_idx = name_idx if name_idx is not None else 0 + if addr_idx is None: + addr_idx = 1 + logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용') + + row_count = 0 + for row in ws.iter_rows(min_row=2, values_only=True): + if not any(row): continue + name = str(row[name_idx] or '').strip() if name_idx is not None else '' + if not name: continue + addr = str(row[addr_idx] or '').strip() + if addr: + all_addrs.add(addr) + row_count += 1 + logger.info(f' → 근무처 행 수: {row_count}') + + # 지오코딩 + total_addrs = len(all_addrs) + logger.info(f'수집된 고유 주소 수: {total_addrs}') + GEO_STATS['success'] = 0 + GEO_STATS['failed'] = 0 + GEO_STATS['failed_addrs'] = [] + + if VWORLD_API_KEY: + api_name = 'VWorld API' + delay = 0.1 + elif KAKAO_API_KEY: + api_name = '카카오 API' + delay = 0.1 + else: + api_name = 'Nominatim (API 키 권장)' + logger.info(' (참고: VWorld API 키 발급받으시면 정확도와 속도가 크게 향상됩니다)') + delay = 1.0 + + logger.info(f'=== 지오코딩 시작 ({api_name}) ===') + update_progress('지오코딩', f'주소를 좌표로 변환 중 ({api_name})', total=total_addrs, current=0) + + geo_results = {} + for idx, addr in enumerate(all_addrs, 1): + msg = f'[{idx}/{total_addrs}] {addr}' + logger.info(f' {msg}') + geo_results[addr] = geocode_with_fallback(addr) + + if idx % 5 == 0 or idx == total_addrs: + update_progress('지오코딩', f'좌표 변환 중... {idx}/{total_addrs}', total=total_addrs, current=idx) + + if idx < total_addrs: + time.sleep(delay) + + logger.info(f'=== 지오코딩 완료: 성공 {GEO_STATS["success"]}, 실패 {GEO_STATS["failed"]} ===') + if GEO_STATS['failed_addrs']: + logger.warning(f'실패한 주소 목록 (최대 20개): {GEO_STATS["failed_addrs"]}') + + # 근무자/근무처 데이터 구성 + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + headers = [str(c.value or '').strip() for c in ws[1]] + + hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망']) + is_worker_sheet = hope_idx is not None + + name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명']) + addr_idx = find_header_idx(headers, ['주소', '위치', '소재지']) + + if is_worker_sheet: + hope_idx = hope_idx if hope_idx is not None else 1 + name_idx = name_idx if name_idx is not None else 0 + dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3 + phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4 + if addr_idx is None: + addr_idx = 2 + + for row in ws.iter_rows(min_row=2, values_only=True): + if not any(row): continue + name = str(row[name_idx] or '').strip() if name_idx is not None else '' + if not name: continue + addr = str(row[addr_idx] or '').strip() + lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005), + YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005))) + workers.append({ + 'id': f'w{len(workers)}', 'name': name, + 'hope': str(row[hope_idx] or '').strip(), + 'address': addr, + 'dob': str(row[dob_idx] or '').strip(), + 'phone': str(row[phone_idx] or '').strip(), + 'lat': lat, 'lng': lng, + }) + else: + name_idx = name_idx if name_idx is not None else 0 + if addr_idx is None: + addr_idx = 1 + + for row in ws.iter_rows(min_row=2, values_only=True): + if not any(row): continue + name = str(row[name_idx] or '').strip() if name_idx is not None else '' + if not name: continue + addr = str(row[addr_idx] or '').strip() + lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005), + YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005))) + workplaces.append({ + 'id': f'p{len(workplaces)}', 'name': name, + 'address': addr, 'lat': lat, 'lng': lng, + }) + + logger.info(f'=== 처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===') + return workers, workplaces + + @app.route('/api/upload', methods=['POST']) def upload(): tid = request.form.get('tab') or store['active_tab'] @@ -564,6 +779,69 @@ def upload(): return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) +@app.route('/api/google-drive-upload', methods=['POST']) +def google_drive_upload(): + """구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다.""" + body = request.get_json() or {} + file_id = body.get('fileId', '').strip() + tid = body.get('tab') or store['active_tab'] + + if not file_id: + return jsonify({'error': '파일 ID가 없습니다.'}), 400 + + t = store['tabs'].get(tid) + if not t: + return jsonify({'error': '탭 없음'}), 400 + + upload_progress.update({ + 'active': True, + 'stage': '시작', + 'total': 0, + 'current': 0, + 'message': '', + 'logs': [], + }) + + update_progress('파일 로딩', f'구글 드라이브에서 파일을 다운로드 중입니다...') + + # 구글 드라이브에서 파일 다운로드 + file_content = download_from_google_drive(file_id) + if not file_content: + upload_progress['active'] = False + return jsonify({'error': '파일을 다운로드할 수 없습니다. 파일 ID를 확인하고 파일이 공유되어 있는지 확인하세요.'}), 400 + + try: + # 파일을 엑셀로 읽기 + update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...') + wb = openpyxl.load_workbook(file_content, data_only=True) + + logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} ===') + logger.info(f'시트 목록: {wb.sheetnames}') + + # 엑셀 파일 처리 + workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx') + + # 탭에 데이터 저장 + t['workers'] = workers + t['workplaces'] = workplaces + t['assignments'] = {} + store['filename'] = f'GoogleDrive_{file_id}' + + update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개') + upload_progress['active'] = False + + logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===') + + return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) + + except Exception as e: + logger.error(f'엑셀 파일 처리 중 오류: {e}') + import traceback + traceback.print_exc() + upload_progress['active'] = False + return jsonify({'error': f'파일 처리 중 오류: {str(e)}'}), 400 + + @app.route('/api/assign', methods=['POST']) def assign(): body = request.get_json() or {} @@ -643,4 +921,7 @@ def index(): if __name__ == '__main__': - app.run(debug=True) + parser = argparse.ArgumentParser() + parser.add_argument('--port', type=int, default=5000, help='Port to run the server on') + args = parser.parse_args() + app.run(debug=True, port=args.port) diff --git a/templates/index.html b/templates/index.html index ec096e4..eab006d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,9 +16,10 @@
- +
+ @@ -72,6 +73,30 @@
+ +