Compare commits
4 Commits
master
...
fix/crawl-
| Author | SHA1 | Date |
|---|---|---|
|
|
7afc97b8f8 | |
|
|
504dd61b6f | |
|
|
4fd45a3364 | |
|
|
272a1e5f53 |
|
|
@ -0,0 +1,78 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Decision log initialized.
|
||||
|
||||
## 2026-02-19 Expose crawling.recheck_hours in Settings UI
|
||||
|
||||
### Decision
|
||||
Added UI control and persistence for `recheck_hours` in SettingsDialog to expose previously invisible setting.
|
||||
|
||||
### Changes Made
|
||||
1. **settings_dialog.py:_init_crawl_tab()** (lines 142-147)
|
||||
- Added ComboBox for "상세 재수집 주기 (시간)" with options [1,2,3,6,12,24]
|
||||
- Loads from `settings['crawling']['recheck_hours']` with default 3
|
||||
- Positioned after unchecked_interval control for logical UI flow
|
||||
|
||||
2. **settings_dialog.py:save_all()** (line 417)
|
||||
- Added persistence: `settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())`
|
||||
- Placed with other crawling settings (after interval_minutes)
|
||||
|
||||
3. **test_settings_dialog_update_policy.py**
|
||||
- Added `recheck_hours: 3` to initial controller.settings
|
||||
- Added `combo_recheck_hours = _DummyField("6")` test field
|
||||
- Added assertion: `self.assertEqual(crawling_settings["recheck_hours"], 6)`
|
||||
|
||||
### Why This Approach
|
||||
- **Default fallback (3 hours)** ensures backward compatibility
|
||||
- **ComboBox values** match expected scheduler time scales
|
||||
- **Test verifies persistence** through save_all() flow
|
||||
- **No changes to controller/scheduler** - they already use the key
|
||||
- **Consistent with existing pattern** (interval_minutes control)
|
||||
|
||||
### Verified
|
||||
- Unit test passes: `test_save_all_persists_update_policy_fields`
|
||||
- Value correctly loaded from settings
|
||||
- Value correctly saved on save_all()
|
||||
|
||||
|
||||
## 2026-02-19 Add 5-hour option to recheck-hours ComboBox
|
||||
|
||||
### Decision
|
||||
Added "5" to the recheck_hours ComboBox values to enable explicit user selection of 5-hour interval.
|
||||
|
||||
### Change
|
||||
- **settings_dialog.py:line 143**
|
||||
- Updated values: ["1", "2", "3", "5", "6", "12", "24"] (inserted "5" between "3" and "6")
|
||||
- Maintains numeric ordering
|
||||
- No logic changes to save() or load flow
|
||||
|
||||
## 2026-02-19 Prevent duplicate unchecked notification dialogs
|
||||
|
||||
### Decision
|
||||
Implement singleton behavior for unchecked notification dialogs (title prefix `⚠️ 미확인 VOC`).
|
||||
Allow regular new notifications to open multiple dialogs as before.
|
||||
|
||||
### Implementation
|
||||
- **notification_dialog.py (lines 4-6, 8-15, 20, 22-24, 59-62, 86, 88-101)**:
|
||||
1. Added class variable `_unchecked_dialog = None` to track open unchecked dialog
|
||||
2. In `__init__`: Check if incoming title is unchecked notification (startswith `⚠️ 미확인 VOC`)
|
||||
3. If unchecked AND one already open → lift() and focus_force() existing dialog, return early (no new instance)
|
||||
4. If unchecked AND none open → store reference in `_unchecked_dialog`
|
||||
5. Modified close button (line 61) to call `self._on_close()` instead of `self.destroy()`
|
||||
6. Added `_on_close()` method (lines 97-101): Cleans up `_unchecked_dialog` reference before destroying
|
||||
7. Updated `_on_view()` and `_on_open_list()` to call `_on_close()` instead of direct `destroy()`
|
||||
|
||||
### Behavior
|
||||
- **Unchecked dialogs (⚠️ 미확인 VOC)**: Maximum one open at a time; new attempts bring existing to front
|
||||
- **Regular notifications (신규 알림)**: Unchanged; multiple can open (checked in title prefix)
|
||||
- **Cleanup**: Reference cleared when dialog closes via any path (button, protocol handler, or methods)
|
||||
|
||||
### Why This Approach
|
||||
- **Minimal scope**: Changes only notification_dialog.py, no controller/scheduler modifications
|
||||
- **Non-intrusive**: Regular new notifications unaffected; only unchecked notifications deduplicated
|
||||
- **Safe cleanup**: `_on_close()` ensures `_unchecked_dialog` ref cleared regardless of close method
|
||||
- **Semantic check**: title.startswith("⚠️ 미확인 VOC") is the exact prefix used by notifier
|
||||
|
||||
### Verified
|
||||
- Syntax: python -m py_compile passed
|
||||
- No regressions: All button callbacks still work (updated to use _on_close)
|
||||
- LSP: Only pre-existing MAIN_COLOR hasattr error (unrelated)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Issue log initialized.
|
||||
|
||||
## 2026-02-19 Implement Meaningful recheck_hours Behavior
|
||||
|
||||
### Issue
|
||||
`get_posts_needing_detail(recheck_hours)` received parameter but ignored it—only checking for empty content.
|
||||
Scheduler unconditionally skipped `is_public=0` posts, preventing detail collection for relevant non-public items.
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### 1. database.py: get_posts_needing_detail()
|
||||
**Lines 258-288**
|
||||
- **Before**: Only selected posts with empty content, ordered by is_public DESC (hardcoded)
|
||||
- **After**:
|
||||
- SELECT posts where: (content IS NULL/empty) OR (updated_at < threshold_time)
|
||||
- threshold_time = now - recheck_hours interval
|
||||
- ORDER BY is_related DESC, id DESC (prioritizes related + latest, not public status)
|
||||
- Respects recheck_hours setting from crawling.recheck_hours (default 3 hours)
|
||||
|
||||
**SQL Logic**:
|
||||
```sql
|
||||
WHERE (content IS NULL OR content = '')
|
||||
OR (updated_at IS NOT NULL AND updated_at < ?)
|
||||
ORDER BY is_related DESC, id DESC LIMIT 10
|
||||
```
|
||||
|
||||
#### 2. scheduler_manager.py: _collect_detail_content()
|
||||
**Lines 305-368**
|
||||
- **Before**: Hard-skip all `is_public=0` with unconditional continue
|
||||
- **After**: Decision tree:
|
||||
- **Related posts** (is_related=1): Attempt detail regardless of is_public (차량 context matters)
|
||||
- **Public posts** (is_public=1): Attempt detail
|
||||
- **Non-public + non-related**: Skip (prevents pointless permission errors)
|
||||
- Failures from fetch_detail_content (timeout, permission denied, etc.) logged at DEBUG level (not treated as errors)
|
||||
- Maintains 1.2s delay between requests for server load protection
|
||||
|
||||
**Decision Logic**:
|
||||
```python
|
||||
if is_related:
|
||||
should_attempt_detail = True # Always try
|
||||
elif is_public == 1:
|
||||
should_attempt_detail = True # Try public
|
||||
else:
|
||||
skip_reason = "비공개 비관심글" # Skip non-public non-related
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
✓ Method signature unchanged: `get_posts_needing_detail(recheck_hours=3)`
|
||||
✓ Default recheck_hours=3 hours maintained
|
||||
✓ Safe defaults: Respects crawling.recheck_hours setting
|
||||
✓ Graceful failure handling (None returns from fetch logged, not crashed)
|
||||
✓ No new dependencies or breaking changes
|
||||
|
||||
### Verification Checklist
|
||||
- [x] Python syntax valid (py_compile)
|
||||
- [x] recheck_hours parameter actually used in SQL query
|
||||
- [x] Threshold calculation correct (now - N hours)
|
||||
- [x] Scheduler respects is_related context for non-public posts
|
||||
- [x] Failure handling graceful (debug logs, not exceptions)
|
||||
- [x] Korean logging messages consistent with codebase
|
||||
|
||||
## 2026-02-19 Fix: HistoryDialog Title Click Checked Timestamp Refresh
|
||||
|
||||
### Issue
|
||||
When clicking a title in HistoryDialog to open detail view, `controller.mark_as_read(voc_id)` is called which refreshes `controller.list_window`. However, UIManager independently manages `self.list_window`, causing a mismatch—the refresh affects the wrong instance.
|
||||
|
||||
### Root Cause
|
||||
Two separate list_window references:
|
||||
- `controller.list_window` (checked in mark_as_read)
|
||||
- `UIManager.list_window` (actual active window displayed)
|
||||
|
||||
These never synchronized, so refresh refreshed a stale reference.
|
||||
|
||||
### Solution
|
||||
**File**: `app/controllers/ui_manager.py` (line 97)
|
||||
**Change**: After creating HistoryDialog instance in UIManager, synchronize it to controller:
|
||||
```python
|
||||
self.list_window = HistoryDialog(self.controller, formatted)
|
||||
# UIManager의 list_window를 controller가 접근하도록 동기화
|
||||
self.controller.list_window = self.list_window
|
||||
```
|
||||
|
||||
### Impact
|
||||
- Title click → detail open → `mark_as_read()` → `controller.list_window.refresh_data_only()` now refreshes the visible list
|
||||
- Checked timestamp visibility updates immediately (same behavior as pressing refresh button)
|
||||
- No code changes needed in controller.py; list refresh flow remains unchanged
|
||||
- Minimal 1-line addition with clear Korean comment explaining synchronization
|
||||
|
||||
### Verification
|
||||
✓ Syntax valid (py_compile)
|
||||
✓ No LSP diagnostics
|
||||
✓ Controller mark_as_read() logic preserved
|
||||
✓ Existing list open behavior unaffected
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Notepad initialized for crawling fixes orchestration.
|
||||
|
||||
## 2026-02-19 Scraper Service Fixes
|
||||
|
||||
### Fix 1: Unconditional List Refresh (Lines 168-172)
|
||||
**Before**: Conditional check skipped navigation if URL contained "act=searchList" and data existed
|
||||
**After**: Always navigate to refresh list via _navigate_to_list_via_physical_click()
|
||||
**Reason**: Cached pages cause new posts to be missed. Unconditional refresh ensures latest state.
|
||||
**Code**: Removed nested if checking "act=searchList" and _check_data_exists()
|
||||
|
||||
### Fix 2: Preserve Non-Public Rows (Lines 231-236)
|
||||
**Before**: Skip (continue) non-public rows entirely with `if is_public == 0: continue`
|
||||
**After**: Keep all rows in metadata with is_public=0 flag, log discovery
|
||||
**Reason**: Metadata completeness required; detail access already has timeout handling
|
||||
**Code**: Changed `continue` to debug log "비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)"
|
||||
|
||||
### Fix 3: Detail Attempt Policy (Lines 256-269)
|
||||
**Before**: Verbose comments, explicit "관심 대상인 경우 상세까지 긁어서 정확도 높임"
|
||||
**After**: Clear policy: "관심 게시글: 공개/비공개 구분 없이 상세 조회 시도"
|
||||
**Reason**: Support related 차량 cases; non-fatal errors in fetch_detail_content (timeout, permissions)
|
||||
**Code**: Updated comments to explain that fetch_detail_content handles errors for both public/private
|
||||
|
||||
### Non-Functional Changes
|
||||
- All modifications are isolated to fetch_list_pages() method
|
||||
- No dependency additions
|
||||
- Failures remain non-fatal (try-except in fetch_detail_content catches all)
|
||||
- Architecture preserved (same control flow, same data structure)
|
||||
|
||||
### Testing Notes
|
||||
- List refresh now runs on every fetch_list_pages() call
|
||||
- Metadata includes is_public=0 rows (complete state capture)
|
||||
- Detail attempts continue for is_target rows regardless of is_public value
|
||||
- Timeout/permission errors in fetch_detail_content are silently caught (returns None)
|
||||
|
|
@ -224,6 +224,11 @@ class AppController:
|
|||
self.settings['update'].setdefault('version_table', 'program_versions')
|
||||
self.settings['update'].setdefault('ssl_verify', True)
|
||||
self.settings['update'].setdefault('ca_bundle_path', '')
|
||||
self.settings['update'].setdefault('cleanup_backup_on_success', True)
|
||||
self.settings['update'].setdefault('clean_install_before_copy', True)
|
||||
self.settings['update'].setdefault('preserve_globs', ['data/*.sqlite', 'data/*.json', 'data/*.db'])
|
||||
self.settings['update'].setdefault('allowed_update_levels', ['patch', 'minor', 'major'])
|
||||
self.settings['update'].setdefault('clean_install_levels', ['minor', 'major'])
|
||||
self.logger.info("설정 파일 로드 완료")
|
||||
except FileNotFoundError:
|
||||
# 기본 설정 생성
|
||||
|
|
@ -251,7 +256,12 @@ class AppController:
|
|||
"program_id": "voc_monitor",
|
||||
"version_table": "program_versions",
|
||||
"ssl_verify": True,
|
||||
"ca_bundle_path": ""
|
||||
"ca_bundle_path": "",
|
||||
"cleanup_backup_on_success": True,
|
||||
"clean_install_before_copy": True,
|
||||
"preserve_globs": ["data/*.sqlite", "data/*.json", "data/*.db"],
|
||||
"allowed_update_levels": ["patch", "minor", "major"],
|
||||
"clean_install_levels": ["minor", "major"]
|
||||
},
|
||||
"report": {
|
||||
"output_path": str(get_data_dir() / "reports")
|
||||
|
|
@ -415,11 +425,13 @@ class AppController:
|
|||
self.logger.info(f"수동 업데이트 확인 결과: 새 버전 {version_info.version}")
|
||||
self._show_update_prompt(version_info)
|
||||
except UpdaterConfigError as e:
|
||||
self.logger.warning(f"업데이트 확인 실패(설정): {e}")
|
||||
messagebox.showerror("업데이트 설정 오류", str(e))
|
||||
code = getattr(e, "code", "ERR_CONFIG")
|
||||
self.logger.warning(f"업데이트 확인 실패(설정)[{code}]: {e}")
|
||||
messagebox.showerror("업데이트 설정 오류", self._format_update_error(code, str(e)))
|
||||
except UpdaterNetworkError as e:
|
||||
self.logger.warning(f"업데이트 확인 실패(네트워크): {e}")
|
||||
messagebox.showerror("업데이트 네트워크 오류", str(e))
|
||||
code = getattr(e, "code", "ERR_NETWORK")
|
||||
self.logger.warning(f"업데이트 확인 실패(네트워크)[{code}]: {e}")
|
||||
messagebox.showerror("업데이트 네트워크 오류", self._format_update_error(code, str(e)))
|
||||
except Exception as e:
|
||||
self.logger.error(f"업데이트 수동 확인 실패: {e}", exc_info=True)
|
||||
messagebox.showerror("업데이트 오류", f"업데이트 확인 중 오류가 발생했습니다.\n{e}")
|
||||
|
|
@ -430,7 +442,24 @@ class AppController:
|
|||
|
||||
def _on_update_check_error(self, error: Exception):
|
||||
"""백그라운드 업데이트 확인 실패 콜백"""
|
||||
self.logger.warning(f"백그라운드 업데이트 확인 실패: {error}")
|
||||
code = getattr(error, "code", "ERR_UPDATE_CHECK")
|
||||
self.logger.warning(f"백그라운드 업데이트 확인 실패[{code}]: {error}")
|
||||
|
||||
@staticmethod
|
||||
def _format_update_error(code: str, detail: str) -> str:
|
||||
mapping = {
|
||||
"ERR_UPDATER_EXE_NOT_FOUND": "업데이터 실행 파일이 없어 업데이트를 진행할 수 없습니다.",
|
||||
"ERR_UPDATER_TEMP_NOT_FOUND": "임시 업데이터 파일 생성에 실패했습니다.",
|
||||
"ERR_UPDATER_LAUNCH": "업데이터 실행에 실패했습니다.",
|
||||
"ERR_NO_DOWNLOAD_URL": "다운로드 주소가 없어 업데이트를 시작할 수 없습니다.",
|
||||
"ERR_PERMISSION": "파일 권한 문제로 업데이트를 진행할 수 없습니다.",
|
||||
"ERR_SSL_VERIFY": "SSL 인증서 검증에 실패했습니다.",
|
||||
"ERR_TIMEOUT": "업데이트 서버 연결 시간이 초과되었습니다.",
|
||||
"ERR_NETWORK_REQUEST": "업데이트 서버 통신에 실패했습니다.",
|
||||
"ERR_PREPARE_UNKNOWN": "업데이트 준비 중 알 수 없는 오류가 발생했습니다.",
|
||||
}
|
||||
base = mapping.get(code, "업데이트 처리 중 오류가 발생했습니다.")
|
||||
return f"[{code}] {base}\n{detail}" if detail else f"[{code}] {base}"
|
||||
|
||||
def _show_update_prompt(self, version_info):
|
||||
"""업데이트 설치 여부를 사용자에게 확인"""
|
||||
|
|
@ -458,10 +487,14 @@ class AppController:
|
|||
release_note = (version_info.release_note or "-").strip()
|
||||
release_note_preview = release_note[:200] + ("..." if len(release_note) > 200 else "")
|
||||
current_version = manager.current_version
|
||||
update_level = getattr(version_info, "update_level", "patch")
|
||||
level_label = {"patch": "PATCH", "minor": "MINOR", "major": "MAJOR"}.get(update_level, str(update_level).upper())
|
||||
major_notice = "\n※ MAJOR 업데이트: 클린 설치 정책이 적용될 수 있습니다.\n" if update_level == "major" else ""
|
||||
msg = (
|
||||
f"새 버전이 있습니다.\n\n"
|
||||
f"현재: {current_version}\n"
|
||||
f"최신: {version_info.version}\n\n"
|
||||
f"업데이트 등급: {level_label}{major_notice}\n"
|
||||
f"배포 노트:\n{release_note_preview}\n\n"
|
||||
f"지금 업데이트를 진행할까요?"
|
||||
)
|
||||
|
|
@ -478,13 +511,16 @@ class AppController:
|
|||
|
||||
ok, prep_msg = manager.prepare_update(version_info)
|
||||
if not ok:
|
||||
self.logger.error(f"업데이트 준비 실패: {prep_msg}")
|
||||
messagebox.showerror("업데이트 준비 실패", prep_msg)
|
||||
code = getattr(manager, "last_error_code", "ERR_PREPARE_UNKNOWN")
|
||||
self.logger.error(f"업데이트 준비 실패[{code}]: {prep_msg}")
|
||||
messagebox.showerror("업데이트 준비 실패", self._format_update_error(code, prep_msg))
|
||||
return
|
||||
|
||||
if not manager.launch_updater():
|
||||
self.logger.error("updater.exe 실행 실패")
|
||||
messagebox.showerror("업데이트 실행 실패", "updater.exe 실행에 실패했습니다.")
|
||||
code = getattr(manager, "last_error_code", "ERR_UPDATER_LAUNCH")
|
||||
detail = getattr(manager, "last_error_message", "updater.exe 실행에 실패했습니다.")
|
||||
self.logger.error(f"updater.exe 실행 실패[{code}]: {detail}")
|
||||
messagebox.showerror("업데이트 실행 실패", self._format_update_error(code, detail))
|
||||
return
|
||||
|
||||
self.logger.info("업데이트 프로세스 시작, 메인 앱 종료")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from datetime import datetime, timedelta
|
|||
from typing import Callable, Optional
|
||||
|
||||
from core.exceptions import (
|
||||
ScraperError,
|
||||
LoginFailedError,
|
||||
SessionExpiredError,
|
||||
DatabaseError
|
||||
|
|
@ -97,6 +96,7 @@ class SchedulerManager:
|
|||
# 콜백 함수 (Controller에서 주입)
|
||||
self.is_related_callback: Optional[Callable] = None
|
||||
self.notify_callback: Optional[Callable] = None
|
||||
self.sync_callback: Optional[Callable] = None
|
||||
|
||||
def set_callbacks(self, is_related_func: Callable, notify_func: Callable):
|
||||
"""
|
||||
|
|
@ -111,6 +111,10 @@ class SchedulerManager:
|
|||
self.is_related_callback = is_related_func
|
||||
self.notify_callback = notify_func
|
||||
|
||||
def set_sync_callback(self, sync_func: Callable):
|
||||
"""Supabase 동기화 콜백 설정"""
|
||||
self.sync_callback = sync_func
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
스케줄러 시작
|
||||
|
|
@ -128,8 +132,11 @@ class SchedulerManager:
|
|||
# 백그라운드 스레드에서 스케줄러 루프 실행
|
||||
threading.Thread(target=self._scheduler_loop, daemon=True).start()
|
||||
|
||||
# 첫 크롤링 즉시 실행
|
||||
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
|
||||
# 첫 작업 즉시 실행
|
||||
if self.settings.get('sync', {}).get('enabled', False):
|
||||
threading.Thread(target=self.run_sync_cycle, daemon=True).start()
|
||||
else:
|
||||
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
|
||||
|
||||
self.logger.info("스케줄러 시작됨")
|
||||
|
||||
|
|
@ -148,9 +155,15 @@ class SchedulerManager:
|
|||
"""
|
||||
schedule.clear()
|
||||
|
||||
# 크롤링 주기 (기본: 10분)
|
||||
sync_enabled = self.settings.get('sync', {}).get('enabled', False)
|
||||
|
||||
# 크롤링/동기화 주기
|
||||
crawl_interval = self.settings.get('crawling', {}).get('interval_minutes', 10)
|
||||
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
|
||||
sync_interval = self.settings.get('sync', {}).get('pull_interval_minutes', crawl_interval)
|
||||
if sync_enabled:
|
||||
schedule.every(sync_interval).minutes.do(self.run_sync_cycle)
|
||||
else:
|
||||
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
|
||||
|
||||
# DB 체크 주기 (설정값, 기본: 5분)
|
||||
noti_interval = self.settings.get('noti', {}).get('db_check_interval_minutes', 5)
|
||||
|
|
@ -160,8 +173,10 @@ class SchedulerManager:
|
|||
unchecked_interval = self.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30)
|
||||
schedule.every(unchecked_interval).minutes.do(self.run_unchecked_check_cycle)
|
||||
|
||||
mode_text = '동기화' if sync_enabled else '크롤링'
|
||||
mode_interval = sync_interval if sync_enabled else crawl_interval
|
||||
self.logger.info(
|
||||
f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}분"
|
||||
f"스케줄 업데이트됨: {mode_text} {mode_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}분"
|
||||
)
|
||||
|
||||
def _scheduler_loop(self):
|
||||
|
|
@ -189,6 +204,10 @@ class SchedulerManager:
|
|||
"""
|
||||
self.logger.info("크롤링 사이클 시작...")
|
||||
|
||||
if self.settings.get('sync', {}).get('enabled', False):
|
||||
self.logger.info("동기화 모드 활성화: 크롤링 사이클 건너뜀")
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. 로그인 상태 확인 및 재로그인
|
||||
if not self.model.is_logged_in():
|
||||
|
|
@ -233,6 +252,22 @@ class SchedulerManager:
|
|||
except Exception as e:
|
||||
self.logger.error(f"크롤링 중 예상치 못한 오류: {e}", exc_info=True)
|
||||
|
||||
def run_sync_cycle(self):
|
||||
"""Supabase 동기화 사이클 실행"""
|
||||
if not self.settings.get('sync', {}).get('enabled', False):
|
||||
return
|
||||
|
||||
if not self.sync_callback:
|
||||
self.logger.warning("동기화 콜백 미설정으로 동기화를 건너뜁니다.")
|
||||
return
|
||||
|
||||
self.logger.info("Supabase 동기화 사이클 시작...")
|
||||
try:
|
||||
self.sync_callback()
|
||||
self.logger.info("Supabase 동기화 사이클 완료.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Supabase 동기화 중 오류: {e}", exc_info=True)
|
||||
|
||||
def _process_collected_posts(self, posts_data: list):
|
||||
"""
|
||||
수집된 게시글 데이터 처리 (내부 메서드)
|
||||
|
|
@ -271,7 +306,14 @@ class SchedulerManager:
|
|||
"""
|
||||
상세 내용 수집 (내부 메서드)
|
||||
|
||||
내용이 없거나 최근 글인 경우 상세 파싱을 시도합니다.
|
||||
recheck_hours 설정에 따라 재수집이 필요한 게시글의 상세 내용을 수집합니다.
|
||||
관심글(is_related=1)이거나 내용이 없는 경우를 우선처리합니다.
|
||||
|
||||
정책:
|
||||
- 내용이 없거나 마지막 업데이트가 recheck_hours 이상 경과: 상세 시도
|
||||
- 관심글(차량 관련 등): 공개/비공개 구분 없이 상세 조회 시도
|
||||
- 비관심 & 비공개: 상세 조회 스킵 (불필요한 권한 오류 방지)
|
||||
- fetch_detail_content의 실패(타임아웃, 권한거부 등)는 자동 처리(로그만 기록)
|
||||
"""
|
||||
recheck_hours = self.settings['crawling']['recheck_hours']
|
||||
targets = self.db.get_posts_needing_detail(recheck_hours)
|
||||
|
|
@ -279,21 +321,49 @@ class SchedulerManager:
|
|||
for tgt in targets:
|
||||
voc_id, title, is_related = tgt
|
||||
|
||||
# 비공개 글 체크
|
||||
# 게시글 상세 정보 조회
|
||||
post_check = self.db.get_post_by_id(voc_id)
|
||||
if post_check and post_check['is_public'] == 0:
|
||||
if not post_check:
|
||||
continue
|
||||
|
||||
# 상세 내용 수집
|
||||
is_public = post_check['is_public']
|
||||
|
||||
# 결정 로직: 상세 조회 시도 여부
|
||||
should_attempt_detail = False
|
||||
skip_reason = None
|
||||
|
||||
if is_related:
|
||||
# 관심글: 항상 시도 (차량 관련 등 가치있는 정보)
|
||||
should_attempt_detail = True
|
||||
elif is_public == 1:
|
||||
# 공개글: 시도
|
||||
should_attempt_detail = True
|
||||
else:
|
||||
# 비공개 + 비관심글: 스킵 (불필요한 권한 오류 방지)
|
||||
skip_reason = "비공개 비관심글"
|
||||
|
||||
if not should_attempt_detail:
|
||||
self.logger.debug(
|
||||
f"상세 조회 스킵: {voc_id} ({skip_reason})"
|
||||
)
|
||||
continue
|
||||
|
||||
# 상세 내용 수집 시도 (실패는 자동 처리)
|
||||
detail_data = self.model.fetch_detail_content(voc_id)
|
||||
|
||||
if detail_data:
|
||||
try:
|
||||
self.db.update_detail(voc_id, detail_data)
|
||||
if is_related:
|
||||
self.logger.info(f"상세 내용 업데이트됨: {title}")
|
||||
log_suffix = " [관심글]" if is_related else ""
|
||||
self.logger.info(f"상세 내용 업데이트됨: {title}{log_suffix}")
|
||||
except DatabaseError as e:
|
||||
self.logger.error(f"상세 내용 저장 실패 (ID: {voc_id}): {e.message}")
|
||||
else:
|
||||
# fetch_detail_content에서 None 반환: 타임아웃, 권한거부, 기타 오류
|
||||
# 이는 정상적인 상황이므로 로그만 기록
|
||||
self.logger.debug(
|
||||
f"상세 내용 수집 실패 또는 스킵: {voc_id} (타임아웃/권한/기타)"
|
||||
)
|
||||
|
||||
time.sleep(1.2) # 서버 부하 방지용 딜레이
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ class UIManager:
|
|||
|
||||
from view.dialogs.history_dialog import HistoryDialog
|
||||
self.list_window = HistoryDialog(self.controller, formatted)
|
||||
# UIManager의 list_window를 controller가 접근하도록 동기화
|
||||
self.controller.list_window = self.list_window
|
||||
else:
|
||||
# 기존 창 재사용
|
||||
self.list_window.refresh_data_only()
|
||||
|
|
|
|||
|
|
@ -117,30 +117,12 @@ HWP 보고서 생성 프로세스:
|
|||
import os
|
||||
import re
|
||||
import winreg
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# Windows API 모듈
|
||||
try:
|
||||
import win32print
|
||||
import win32ui
|
||||
import win32con
|
||||
import win32com.client
|
||||
HAS_WIN32 = True
|
||||
except ImportError:
|
||||
HAS_WIN32 = False
|
||||
|
||||
# ReportLab (PDF)
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
HAS_REPORTLAB = True
|
||||
except ImportError:
|
||||
HAS_REPORTLAB = False
|
||||
|
||||
from utils.path_utils import get_base_dir
|
||||
from utils.logger import get_logger
|
||||
from utils.voc_parser import VOCParser
|
||||
|
|
@ -148,6 +130,17 @@ from utils.train_analyzer import TrainAnalyzer
|
|||
from utils.date_utils import DateScheduleUtils
|
||||
from services.timetable_service import TimetableService
|
||||
|
||||
# 외부 모듈 가용성 체크
|
||||
HAS_WIN32 = (
|
||||
importlib.util.find_spec("win32print") is not None
|
||||
and importlib.util.find_spec("win32ui") is not None
|
||||
and importlib.util.find_spec("win32com.client") is not None
|
||||
)
|
||||
HAS_REPORTLAB = (
|
||||
importlib.util.find_spec("reportlab") is not None
|
||||
and importlib.util.find_spec("reportlab.pdfgen") is not None
|
||||
)
|
||||
|
||||
BASE_DIR = get_base_dir()
|
||||
CONFIG_FILE = BASE_DIR / "data" / "settings.json"
|
||||
|
||||
|
|
@ -285,6 +278,46 @@ class ReportService:
|
|||
self.logger.warning(f"보안 모듈 등록 실패: {e}")
|
||||
return None
|
||||
|
||||
def _normalize_report_date(self, raw_date: str) -> tuple[str, str]:
|
||||
"""보고서 날짜를 표준 포맷으로 정규화합니다."""
|
||||
text = str(raw_date or "").strip()
|
||||
dt_obj = None
|
||||
|
||||
for fmt in (
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d",
|
||||
"%Y.%m.%d %H:%M:%S",
|
||||
"%Y.%m.%d",
|
||||
"%Y/%m/%d %H:%M:%S",
|
||||
"%Y/%m/%d",
|
||||
):
|
||||
try:
|
||||
dt_obj = datetime.strptime(text, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if dt_obj is None:
|
||||
m = re.search(r"(\d{4})[./-](\d{1,2})[./-](\d{1,2})", text)
|
||||
if m:
|
||||
y, mm, dd = m.groups()
|
||||
try:
|
||||
dt_obj = datetime(int(y), int(mm), int(dd))
|
||||
except ValueError:
|
||||
dt_obj = None
|
||||
|
||||
if dt_obj is None:
|
||||
dt_obj = datetime.now()
|
||||
|
||||
return dt_obj.strftime("%Y.%m.%d"), dt_obj.strftime("%Y%m%d")
|
||||
|
||||
def _ensure_file_extension(self, filename: str, extension: str) -> str:
|
||||
"""파일명에 확장자가 없거나 다른 경우 지정 확장자를 강제합니다."""
|
||||
ext = extension if extension.startswith(".") else f".{extension}"
|
||||
if not filename.lower().endswith(ext.lower()):
|
||||
filename = f"{filename}{ext}"
|
||||
return filename
|
||||
|
||||
def _parse_voc_info(self, title, content, doc_date):
|
||||
"""
|
||||
VOC 정보 파싱 (통합 메서드)
|
||||
|
|
@ -379,6 +412,9 @@ class ReportService:
|
|||
if not HAS_WIN32:
|
||||
return False, "윈도우 인쇄 모듈(pywin32)이 로드되지 않았습니다.\n(pip install pywin32)"
|
||||
|
||||
win32print = importlib.import_module("win32print")
|
||||
win32ui = importlib.import_module("win32ui")
|
||||
|
||||
try:
|
||||
hDC = win32ui.CreateDC()
|
||||
hDC.CreatePrinterDC(win32print.GetDefaultPrinter())
|
||||
|
|
@ -431,12 +467,18 @@ class ReportService:
|
|||
if not HAS_WIN32:
|
||||
return False, "윈도우 자동화 모듈(pywin32)이 로드되지 않았습니다."
|
||||
|
||||
win32_client = importlib.import_module("win32com.client")
|
||||
|
||||
try:
|
||||
# 1. 한글 실행
|
||||
try:
|
||||
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
|
||||
except:
|
||||
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
|
||||
gencache = getattr(win32_client, "gencache", None)
|
||||
if gencache is not None:
|
||||
hwp = gencache.EnsureDispatch("HWPFrame.HwpObject")
|
||||
else:
|
||||
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
|
||||
except Exception:
|
||||
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
|
||||
|
||||
# 2. 보안 모듈 등록
|
||||
dll_path = BASE_DIR / "assets" / "FilePathCheckerModule.dll"
|
||||
|
|
@ -445,7 +487,7 @@ class ReportService:
|
|||
if reg_module_name:
|
||||
res = hwp.RegisterModule("FilePathCheckDLL", reg_module_name)
|
||||
if not res:
|
||||
self.logger.warning(f"보안 모듈 적용 실패")
|
||||
self.logger.warning("보안 모듈 적용 실패")
|
||||
|
||||
hwp.XHwpWindows.Item(0).Visible = True
|
||||
except Exception as e:
|
||||
|
|
@ -463,7 +505,7 @@ class ReportService:
|
|||
# 4. 데이터 파싱
|
||||
raw_title = data.get('title', '')
|
||||
raw_content = data.get('content', '')
|
||||
doc_date = str(data.get('date', '')).replace('-', '.')
|
||||
doc_date, date_clean = self._normalize_report_date(data.get('date', ''))
|
||||
|
||||
formatted_content = self.parser.format_voc_content(raw_title, raw_content)
|
||||
parsed = self._parse_voc_info(raw_title, raw_content, doc_date)
|
||||
|
|
@ -549,11 +591,12 @@ class ReportService:
|
|||
|
||||
# 10. 파일명 생성 및 저장
|
||||
safe_title = re.sub(r'[\\/:*?"<>|\r\n]', "", parsed['pure_title']).strip()
|
||||
date_clean = doc_date.replace(".", "")
|
||||
f_train = f"{train_num}열차" if train_num else ""
|
||||
base_name = f"{line_str_file} {f_train} {set_str} {car_str} {safe_title} 관련".strip()
|
||||
base_name = re.sub(r'\s+', ' ', base_name)
|
||||
final_filename = f"{base_name}({date_clean})(VOC).hwp"
|
||||
final_filename = f"{base_name}({date_clean})(VOC)"
|
||||
final_filename = re.sub(r'[\\/:*?"<>|\r\n]', "", final_filename).strip(" .")
|
||||
final_filename = self._ensure_file_extension(final_filename, ".hwp")
|
||||
|
||||
# 저장 경로
|
||||
output_dir = self.config.get('report', {}).get('output_path', '')
|
||||
|
|
@ -583,13 +626,20 @@ class ReportService:
|
|||
if not HAS_REPORTLAB:
|
||||
return False, "PDF 생성 모듈(reportlab)이 설치되지 않았습니다.\n(pip install reportlab)"
|
||||
|
||||
canvas = importlib.import_module("reportlab.pdfgen.canvas")
|
||||
page_sizes = importlib.import_module("reportlab.lib.pagesizes")
|
||||
pdfmetrics = importlib.import_module("reportlab.pdfbase.pdfmetrics")
|
||||
ttfonts = importlib.import_module("reportlab.pdfbase.ttfonts")
|
||||
A4 = page_sizes.A4
|
||||
TTFont = ttfonts.TTFont
|
||||
|
||||
try:
|
||||
date_str = str(data.get('date') or '').replace('-', '')[:8]
|
||||
if not date_str:
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
_, date_str = self._normalize_report_date(data.get('date') or '')
|
||||
|
||||
safe_title = "".join([c for c in data['title'] if c.isalnum() or c in (' ', '(', ')', '[', ']')]).strip()
|
||||
filename = f"{safe_title}({date_str})(VOC).pdf"
|
||||
filename = f"{safe_title}({date_str})(VOC)"
|
||||
filename = re.sub(r'[\\/:*?"<>|\r\n]', "", filename).strip(" .")
|
||||
filename = self._ensure_file_extension(filename, ".pdf")
|
||||
|
||||
# 저장 경로
|
||||
output_dir = self.config.get('report', {}).get('output_path', '')
|
||||
|
|
|
|||
|
|
@ -165,11 +165,11 @@ class VOCScraper:
|
|||
target_depts = target_depts if target_depts else self.target_depts
|
||||
|
||||
try:
|
||||
# 0. 목록 페이지인지 확인하고 아니면 이동
|
||||
if "act=searchList" not in self.page.url or not self._check_data_exists():
|
||||
if not self._navigate_to_list_via_physical_click():
|
||||
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
|
||||
return {"status": "error", "msg": "목록 페이지 접근 실패"}
|
||||
# 0. 항상 목록 페이지로 이동하여 최신 상태 확보
|
||||
# 캐시된 페이지 사용 시 신규 게시글 누락 가능하므로 조건부 체크 제거
|
||||
if not self._navigate_to_list_via_physical_click():
|
||||
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
|
||||
return {"status": "error", "msg": "목록 페이지 접근 실패"}
|
||||
|
||||
# 1. 페이지네이션 정보 확인
|
||||
total_cnt_ele = self.page.ele("text:page )", timeout=2)
|
||||
|
|
@ -231,9 +231,10 @@ class VOCScraper:
|
|||
is_public_txt = cols[5].text(strip=True)
|
||||
is_public = 1 if is_public_txt == '○' or is_public_txt == 'O' else 0
|
||||
|
||||
# 비공개 게시글도 메타데이터에 포함 (is_public=0으로 마킹)
|
||||
# 상세 페이지 접근 시 권한 문제 발생 가능하므로 로깅만 수행
|
||||
if is_public == 0:
|
||||
self.logger.debug(f"비공개 게시글 생략: {voc_id}")
|
||||
continue
|
||||
self.logger.debug(f"비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)")
|
||||
|
||||
status = cols[6].text(strip=True)
|
||||
|
||||
|
|
@ -253,14 +254,13 @@ class VOCScraper:
|
|||
|
||||
post_info["is_related"] = 1 if is_target else 0
|
||||
|
||||
# 상세 수집 대상이면 (관심 게시글이거나 공개글이면)
|
||||
# *여기서는 '상세 수집'을 별도로 하지 않고 목록 정보만 우선 저장*
|
||||
# 상세 정보는 update_detail 로직이나 필요시점에 호출
|
||||
# 단, 본문 내용 검색이 필요하다면 여기서 들어가야 함.
|
||||
# 현재 요구사항은 알림이 중요하므로, 관련성이 있으면 상세를 긁는게 맞음.
|
||||
# 상세 수집 전략: 관련성 있는 게시글만 상세 조회 시도
|
||||
# 비공개 게시글은 권한 오류 가능하지만, try-catch로 처리됨
|
||||
# 차량 등 관련 부서의 경우 is_public 상관없이 시도 (update_detail은 이미 오류처리)
|
||||
|
||||
# 리스트 수집 시 상세 내용도 긁고 싶다면:
|
||||
if is_target: # 관심 대상인 경우 상세까지 긁어서 정확도 높임
|
||||
if is_target:
|
||||
# 관심 게시글: 공개/비공개 구분 없이 상세 조회 시도
|
||||
# 비공개면 fetch_detail_content에서 타임아웃으로 처리됨
|
||||
detail = self.fetch_detail_content(voc_id)
|
||||
if detail:
|
||||
# 상세에서 가져온 더 정확한 정보로 덮어쓰기
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
{
|
||||
"config_url": "https://jwt.m1tcloud.cc/config",
|
||||
"default_environment": "main",
|
||||
"update_policy": {
|
||||
"preserve_globs": [
|
||||
"data/*.sqlite",
|
||||
"data/*.json",
|
||||
"data/*.db"
|
||||
],
|
||||
"cleanup_backup_on_success": true,
|
||||
"clean_install_before_copy": true,
|
||||
"allowed_update_levels": [
|
||||
"patch",
|
||||
"minor",
|
||||
"major"
|
||||
],
|
||||
"clean_install_levels": [
|
||||
"minor",
|
||||
"major"
|
||||
]
|
||||
},
|
||||
"fallback": {
|
||||
"supabase_url": "https://kong.m1tcloud.cc",
|
||||
"anon_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIn0.S_qrtc9owXm1gIrP41rhpmcDMMdXvbufahyObsTFpv0"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
|
|||
|
||||
import json
|
||||
import logging
|
||||
import copy
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -49,6 +50,7 @@ class VersionInfo:
|
|||
release_note: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
min_required_version: Optional[str] = None
|
||||
update_level: str = "patch"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -66,6 +68,10 @@ class UpdateConfig:
|
|||
target_path: str
|
||||
version: str
|
||||
restart_exe: str = "voc_noti.exe"
|
||||
update_level: str = "patch"
|
||||
preserve_globs: list[str] | None = None
|
||||
cleanup_backup_on_success: bool = True
|
||||
clean_install_before_copy: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -74,17 +80,22 @@ class UpdateConfig:
|
|||
|
||||
class UpdateError(Exception):
|
||||
"""업데이트 관련 기본 예외"""
|
||||
pass
|
||||
def __init__(self, message: str, code: str = "ERR_UPDATE_UNKNOWN"):
|
||||
self.message = message
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class NetworkError(UpdateError):
|
||||
"""네트워크 연결 실패"""
|
||||
pass
|
||||
def __init__(self, message: str, code: str = "ERR_NETWORK"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class ConfigError(UpdateError):
|
||||
"""설정 파일 오류"""
|
||||
pass
|
||||
def __init__(self, message: str, code: str = "ERR_CONFIG"):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
def _parse_ssl_verify(value: Any, default: bool = True) -> bool:
|
||||
|
|
@ -299,6 +310,44 @@ def _resolve_connection_config_path(raw_path: str) -> Path:
|
|||
return module_candidate
|
||||
|
||||
|
||||
def _load_local_update_policy(config_path: Optional[Path]) -> dict[str, Any]:
|
||||
"""updater/config.json에서 update_policy를 로드합니다."""
|
||||
if not config_path or not config_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
raw = _strip_json_comments(config_path.read_text(encoding="utf-8"))
|
||||
config = json.loads(raw)
|
||||
policy = config.get("update_policy", {})
|
||||
if isinstance(policy, dict):
|
||||
return policy
|
||||
except Exception as e:
|
||||
logger.warning(f"update_policy 로드 실패, 기본값 사용: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_update_level(value: Any) -> str:
|
||||
"""update_level 정규화 (patch/minor/major)"""
|
||||
level = str(value or "patch").strip().lower()
|
||||
if level not in {"patch", "minor", "major"}:
|
||||
return "patch"
|
||||
return level
|
||||
|
||||
|
||||
def _normalize_level_list(values: Any, default: list[str]) -> list[str]:
|
||||
"""레벨 목록 정규화"""
|
||||
if not isinstance(values, list):
|
||||
values = default
|
||||
normalized = []
|
||||
for value in values:
|
||||
level = _normalize_update_level(value)
|
||||
if level not in normalized:
|
||||
normalized.append(level)
|
||||
if not normalized:
|
||||
normalized = default
|
||||
return normalized
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 버전 비교 유틸리티
|
||||
# ============================================================================
|
||||
|
|
@ -380,6 +429,11 @@ class UpdateManager:
|
|||
version_table: str = "program_version",
|
||||
ssl_verify: bool = True,
|
||||
ca_bundle_path: str = "",
|
||||
preserve_globs: Optional[list[str]] = None,
|
||||
cleanup_backup_on_success: bool = True,
|
||||
clean_install_before_copy: bool = False,
|
||||
allowed_update_levels: Optional[list[str]] = None,
|
||||
clean_install_levels: Optional[list[str]] = None,
|
||||
):
|
||||
"""
|
||||
UpdateManager 초기화
|
||||
|
|
@ -399,9 +453,24 @@ class UpdateManager:
|
|||
self.version_table = version_table
|
||||
self.ssl_verify = ssl_verify
|
||||
self.ca_bundle_path = ca_bundle_path
|
||||
self.preserve_globs = list(preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"])
|
||||
self.cleanup_backup_on_success = cleanup_backup_on_success
|
||||
self.clean_install_before_copy = clean_install_before_copy
|
||||
self.allowed_update_levels = _normalize_level_list(allowed_update_levels, ["patch", "minor", "major"])
|
||||
self.clean_install_levels = _normalize_level_list(clean_install_levels, ["minor", "major"])
|
||||
|
||||
self._latest_version: Optional[VersionInfo] = None
|
||||
self._stop_flag = threading.Event()
|
||||
self.last_error_code = ""
|
||||
self.last_error_message = ""
|
||||
|
||||
def _set_last_error(self, code: str, message: str) -> None:
|
||||
self.last_error_code = code
|
||||
self.last_error_message = message
|
||||
|
||||
def _clear_last_error(self) -> None:
|
||||
self.last_error_code = ""
|
||||
self.last_error_message = ""
|
||||
|
||||
@property
|
||||
def config_path(self) -> Path:
|
||||
|
|
@ -436,10 +505,11 @@ class UpdateManager:
|
|||
None: 업데이트 없음
|
||||
"""
|
||||
try:
|
||||
self._clear_last_error()
|
||||
if not self.supabase_url:
|
||||
raise ConfigError("Supabase URL이 비어있습니다.")
|
||||
raise ConfigError("Supabase URL이 비어있습니다.", code="ERR_SUPABASE_URL_EMPTY")
|
||||
if not self.supabase_key:
|
||||
raise ConfigError("Supabase API 키가 비어있습니다.")
|
||||
raise ConfigError("Supabase API 키가 비어있습니다.", code="ERR_SUPABASE_KEY_EMPTY")
|
||||
|
||||
# Supabase REST API 호출
|
||||
headers = {
|
||||
|
|
@ -490,19 +560,27 @@ class UpdateManager:
|
|||
return None
|
||||
|
||||
latest = data[0]
|
||||
update_level = _normalize_update_level(latest.get("update_level", "patch"))
|
||||
version_info = VersionInfo(
|
||||
version=latest.get("version", ""),
|
||||
is_stable=latest.get("is_stable", True),
|
||||
release_note=latest.get("release_note"),
|
||||
download_url=latest.get("download_url"),
|
||||
min_required_version=latest.get("min_required_version")
|
||||
min_required_version=latest.get("min_required_version"),
|
||||
update_level=update_level,
|
||||
)
|
||||
|
||||
self._latest_version = version_info
|
||||
|
||||
if update_level not in self.allowed_update_levels:
|
||||
logger.info(
|
||||
f"업데이트 레벨 정책으로 건너뜀: level={update_level} / 허용={self.allowed_update_levels}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 버전 비교
|
||||
if compare_versions(self.current_version, version_info.version) < 0:
|
||||
logger.info(f"새 버전 발견: {version_info.version}")
|
||||
logger.info(f"새 버전 발견: {version_info.version} (level={update_level})")
|
||||
return version_info
|
||||
else:
|
||||
logger.info(f"최신 버전 사용 중: {self.current_version}")
|
||||
|
|
@ -510,19 +588,21 @@ class UpdateManager:
|
|||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("업데이트 서버 연결 시간 초과")
|
||||
raise NetworkError("서버 연결 시간 초과")
|
||||
raise NetworkError("서버 연결 시간 초과", code="ERR_TIMEOUT")
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.warning(f"업데이트 SSL 검증 실패: {e}")
|
||||
raise NetworkError(
|
||||
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요."
|
||||
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요.",
|
||||
code="ERR_SSL_VERIFY",
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"업데이트 확인 실패: {e}")
|
||||
raise NetworkError(f"네트워크 오류: {e}")
|
||||
raise NetworkError(f"네트워크 오류: {e}", code="ERR_NETWORK_REQUEST")
|
||||
except ConfigError:
|
||||
raise
|
||||
except (KeyError, json.JSONDecodeError) as e:
|
||||
logger.error(f"버전 정보 파싱 오류: {e}")
|
||||
self._set_last_error("ERR_RESPONSE_PARSE", str(e))
|
||||
return None
|
||||
|
||||
def is_update_available(self) -> bool:
|
||||
|
|
@ -549,8 +629,10 @@ class UpdateManager:
|
|||
tuple[bool, str]: (성공 여부, 메시지)
|
||||
"""
|
||||
try:
|
||||
self._clear_last_error()
|
||||
# 다운로드 URL 확인
|
||||
if not version_info.download_url:
|
||||
self._set_last_error("ERR_NO_DOWNLOAD_URL", "다운로드 URL이 없습니다.")
|
||||
return False, "다운로드 URL이 없습니다."
|
||||
|
||||
install_dir = self.install_dir
|
||||
|
|
@ -561,7 +643,14 @@ class UpdateManager:
|
|||
download_url=version_info.download_url,
|
||||
target_path=str(install_dir),
|
||||
version=version_info.version,
|
||||
restart_exe=restart_exe
|
||||
restart_exe=restart_exe,
|
||||
update_level=_normalize_update_level(version_info.update_level),
|
||||
preserve_globs=copy.deepcopy(self.preserve_globs),
|
||||
cleanup_backup_on_success=self.cleanup_backup_on_success,
|
||||
clean_install_before_copy=(
|
||||
self.clean_install_before_copy
|
||||
or _normalize_update_level(version_info.update_level) in self.clean_install_levels
|
||||
),
|
||||
)
|
||||
|
||||
temp_config_path = self.config_path.with_suffix(".tmp")
|
||||
|
|
@ -572,6 +661,10 @@ class UpdateManager:
|
|||
"target_path": config.target_path,
|
||||
"version": config.version,
|
||||
"restart_exe": config.restart_exe,
|
||||
"update_level": config.update_level,
|
||||
"preserve_globs": config.preserve_globs,
|
||||
"cleanup_backup_on_success": config.cleanup_backup_on_success,
|
||||
"clean_install_before_copy": config.clean_install_before_copy,
|
||||
},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
|
|
@ -584,6 +677,7 @@ class UpdateManager:
|
|||
# updater.exe 복사
|
||||
updater_src = install_dir / self.UPDATER_EXE_NAME
|
||||
if not updater_src.exists():
|
||||
self._set_last_error("ERR_UPDATER_EXE_NOT_FOUND", f"updater.exe를 찾을 수 없습니다: {updater_src}")
|
||||
return False, f"updater.exe를 찾을 수 없습니다: {updater_src}"
|
||||
|
||||
shutil.copy2(updater_src, self.temp_updater_path)
|
||||
|
|
@ -593,9 +687,11 @@ class UpdateManager:
|
|||
|
||||
except PermissionError as e:
|
||||
logger.error(f"파일 권한 오류: {e}")
|
||||
self._set_last_error("ERR_PERMISSION", str(e))
|
||||
return False, f"파일 권한 오류: {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"업데이트 준비 실패: {e}")
|
||||
self._set_last_error("ERR_PREPARE_UNKNOWN", str(e))
|
||||
return False, str(e)
|
||||
|
||||
def launch_updater(self) -> bool:
|
||||
|
|
@ -606,8 +702,10 @@ class UpdateManager:
|
|||
bool: 실행 성공 여부
|
||||
"""
|
||||
try:
|
||||
self._clear_last_error()
|
||||
if not self.temp_updater_path.exists():
|
||||
logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}")
|
||||
self._set_last_error("ERR_UPDATER_TEMP_NOT_FOUND", str(self.temp_updater_path))
|
||||
return False
|
||||
|
||||
# updater.exe 실행
|
||||
|
|
@ -622,6 +720,7 @@ class UpdateManager:
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"updater.exe 실행 실패: {e}")
|
||||
self._set_last_error("ERR_UPDATER_LAUNCH", str(e))
|
||||
return False
|
||||
|
||||
def start_background_check(
|
||||
|
|
@ -692,11 +791,34 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
|
|||
supabase_key = str(update_settings.get("supabase_key", "")).strip()
|
||||
ssl_verify_raw = update_settings.get("ssl_verify", None)
|
||||
ca_bundle_path = str(update_settings.get("ca_bundle_path", "")).strip()
|
||||
config_path_raw = str(update_settings.get("connection_config_path", "")).strip()
|
||||
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
|
||||
local_policy = _load_local_update_policy(config_path)
|
||||
|
||||
preserve_globs = update_settings.get("preserve_globs", local_policy.get("preserve_globs", ["data/*.sqlite", "data/*.json", "data/*.db"]))
|
||||
if not isinstance(preserve_globs, list):
|
||||
preserve_globs = ["data/*.sqlite", "data/*.json", "data/*.db"]
|
||||
preserve_globs = [str(p).strip() for p in preserve_globs if str(p).strip()]
|
||||
|
||||
cleanup_backup_on_success = _parse_ssl_verify(
|
||||
update_settings.get("cleanup_backup_on_success", local_policy.get("cleanup_backup_on_success", True)),
|
||||
default=True,
|
||||
)
|
||||
clean_install_before_copy = _parse_ssl_verify(
|
||||
update_settings.get("clean_install_before_copy", local_policy.get("clean_install_before_copy", False)),
|
||||
default=False,
|
||||
)
|
||||
allowed_update_levels = _normalize_level_list(
|
||||
update_settings.get("allowed_update_levels", local_policy.get("allowed_update_levels", ["patch", "minor", "major"])),
|
||||
["patch", "minor", "major"],
|
||||
)
|
||||
clean_install_levels = _normalize_level_list(
|
||||
update_settings.get("clean_install_levels", local_policy.get("clean_install_levels", ["minor", "major"])),
|
||||
["minor", "major"],
|
||||
)
|
||||
|
||||
if not supabase_url or not supabase_key:
|
||||
config_path_raw = update_settings.get("connection_config_path", "")
|
||||
env_name = update_settings.get("environment")
|
||||
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
|
||||
conn = load_updater_connection_config(config_path=config_path, environment=env_name)
|
||||
supabase_url = conn["supabase_url"]
|
||||
supabase_key = conn["supabase_key"]
|
||||
|
|
@ -719,4 +841,9 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
|
|||
version_table=update_settings.get("version_table", "program_version"),
|
||||
ssl_verify=ssl_verify,
|
||||
ca_bundle_path=ca_bundle_path,
|
||||
preserve_globs=preserve_globs,
|
||||
cleanup_backup_on_success=cleanup_backup_on_success,
|
||||
clean_install_before_copy=clean_install_before_copy,
|
||||
allowed_update_levels=allowed_update_levels,
|
||||
clean_install_levels=clean_install_levels,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# 업데이트 로그
|
||||
|
||||
## v3.2.2 (2026-02-19)
|
||||
- 업데이트 레벨 정책 도입 (`program_versions.update_level`)
|
||||
- `patch/minor/major` 레벨 파싱
|
||||
- `allowed_update_levels`에 없는 레벨은 자동 스킵
|
||||
- `clean_install_levels` 기반 대규모 업데이트 시 클린 설치 자동 적용
|
||||
- 오류 코드 체계 도입
|
||||
- `ERR_SSL_VERIFY`, `ERR_TIMEOUT`, `ERR_NETWORK_REQUEST`
|
||||
- `ERR_UPDATER_EXE_NOT_FOUND`, `ERR_UPDATER_TEMP_NOT_FOUND`, `ERR_UPDATER_LAUNCH`
|
||||
- `ERR_NO_DOWNLOAD_URL`, `ERR_PERMISSION`, `ERR_PREPARE_UNKNOWN`
|
||||
- 메인 앱에서 코드 기반 분기 메시지 처리
|
||||
- 보존 정책 확장
|
||||
- 기본 보존: `data/*.sqlite`, `data/*.json`, `data/*.db`
|
||||
- 정책 위치: `app/updater/config.json:update_policy` + `settings.update` 오버라이드
|
||||
- 롤백/정리 강화
|
||||
- 실패 시 롤백을 기본 동작으로 고정 (BadZip/권한 오류 포함)
|
||||
- 성공 시 백업 폴더 자동 정리 옵션 (`cleanup_backup_on_success`)
|
||||
- 설정 UI 확장
|
||||
- 시스템 탭에서 `allowed_update_levels`, `clean_install_levels`, `preserve_globs` 편집 가능
|
||||
|
||||
## v3.2.1 (2026-02-18)
|
||||
- 업데이터 연결정보 외부화
|
||||
- `app/updater/config.json` 도입 (원격 config + fallback)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ CustomTkinter 기반으로 다운로드 진행률과 상태를 표시합니다.
|
|||
|
||||
import json
|
||||
import logging
|
||||
import fnmatch
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -52,6 +53,10 @@ class UpdateConfig:
|
|||
target_path: str
|
||||
version: str
|
||||
restart_exe: str = "voc_noti.exe"
|
||||
update_level: str = "patch"
|
||||
preserve_globs: list[str] = field(default_factory=lambda: ["data/*.sqlite", "data/*.json", "data/*.db"])
|
||||
cleanup_backup_on_success: bool = True
|
||||
clean_install_before_copy: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -233,7 +238,11 @@ class UpdaterGUI(ctk.CTk):
|
|||
download_url=data.get('download_url', ''),
|
||||
target_path=data.get('target_path', ''),
|
||||
version=data.get('version', ''),
|
||||
restart_exe=data.get('restart_exe', 'voc_noti.exe')
|
||||
restart_exe=data.get('restart_exe', 'voc_noti.exe'),
|
||||
update_level=str(data.get('update_level', 'patch') or 'patch').strip().lower(),
|
||||
preserve_globs=[str(p) for p in (data.get('preserve_globs') or ["data/*.sqlite", "data/*.json", "data/*.db"]) if str(p).strip()],
|
||||
cleanup_backup_on_success=bool(data.get('cleanup_backup_on_success', True)),
|
||||
clean_install_before_copy=bool(data.get('clean_install_before_copy', False)),
|
||||
)
|
||||
|
||||
if not config.download_url:
|
||||
|
|
@ -243,7 +252,7 @@ class UpdaterGUI(ctk.CTk):
|
|||
if not config.version:
|
||||
raise ConfigError("version이 비어 있습니다.")
|
||||
|
||||
logger.info(f"설정 로드 완료: 버전 {config.version}")
|
||||
logger.info(f"설정 로드 완료: 버전 {config.version} (level={config.update_level})")
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
|
|
@ -323,7 +332,49 @@ class UpdaterGUI(ctk.CTk):
|
|||
self._update_status("설치 중...", "파일 교체 중...")
|
||||
self._update_progress(0.75)
|
||||
|
||||
def _rollback_from_backup() -> None:
|
||||
if backup_path and backup_path.exists():
|
||||
logger.info("롤백 중...")
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.move(str(backup_path), str(target_path))
|
||||
|
||||
try:
|
||||
preserve_patterns = self.config.preserve_globs or []
|
||||
|
||||
def _matches_preserve(rel_path: str) -> bool:
|
||||
rel = rel_path.replace('\\', '/')
|
||||
return any(fnmatch.fnmatch(rel, pattern.replace('\\', '/')) for pattern in preserve_patterns)
|
||||
|
||||
def _restore_preserved_files(backup_root: Path, target_root: Path) -> None:
|
||||
if not preserve_patterns:
|
||||
return
|
||||
restored = 0
|
||||
for backup_item in backup_root.rglob('*'):
|
||||
if not backup_item.is_file():
|
||||
continue
|
||||
rel = backup_item.relative_to(backup_root).as_posix()
|
||||
if not _matches_preserve(rel):
|
||||
continue
|
||||
dst = target_root / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(backup_item, dst)
|
||||
restored += 1
|
||||
if restored:
|
||||
logger.info(f"보존 파일 복원: {restored}개")
|
||||
|
||||
def _clean_target_dir(target_root: Path) -> None:
|
||||
if not target_root.exists():
|
||||
return
|
||||
removed = 0
|
||||
for item in list(target_root.iterdir()):
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
logger.info(f"대규모 업데이트 정리 완료: 기존 항목 {removed}개 삭제")
|
||||
|
||||
# 백업 폴더 생성
|
||||
backup_path = target_path.parent / f"{target_path.name}_backup_{self.config.version}"
|
||||
if backup_path.exists():
|
||||
|
|
@ -353,6 +404,9 @@ class UpdaterGUI(ctk.CTk):
|
|||
# 대상 경로 생성
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.config.clean_install_before_copy:
|
||||
_clean_target_dir(target_path)
|
||||
|
||||
# 파일 교체
|
||||
self._update_progress(0.9, "파일 교체 중...")
|
||||
for item in source_root.iterdir():
|
||||
|
|
@ -362,9 +416,16 @@ class UpdaterGUI(ctk.CTk):
|
|||
else:
|
||||
shutil.copy2(item, dst)
|
||||
|
||||
if backup_path and backup_path.exists():
|
||||
_restore_preserved_files(backup_path, target_path)
|
||||
|
||||
self._update_progress(0.95, "완료 중...")
|
||||
logger.info(f"압축 해제 완료: {target_path}")
|
||||
|
||||
if self.config.cleanup_backup_on_success and backup_path and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
logger.info("업데이트 백업 폴더 정리 완료")
|
||||
|
||||
# 임시 파일 정리
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
|
@ -374,16 +435,17 @@ class UpdaterGUI(ctk.CTk):
|
|||
return True, "설치 완료"
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
_rollback_from_backup()
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
return False, f"손상된 zip 파일: {e}"
|
||||
except PermissionError as e:
|
||||
_rollback_from_backup()
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
return False, f"파일 권한 오류: {e}"
|
||||
except Exception as e:
|
||||
# 롤백
|
||||
if backup_path and backup_path.exists():
|
||||
logger.info("롤백 중...")
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.move(str(backup_path), str(target_path))
|
||||
_rollback_from_backup()
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
return False, f"설치 실패: {e}"
|
||||
|
|
|
|||
|
|
@ -62,6 +62,14 @@ class VOCDatabase:
|
|||
)
|
||||
""")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# 2. 마이그레이션: 컬럼 존재 여부 확인 및 추가
|
||||
cur.execute("PRAGMA table_info(posts)")
|
||||
existing_cols = [col[1] for col in cur.fetchall()]
|
||||
|
|
@ -248,15 +256,35 @@ class VOCDatabase:
|
|||
return cur.fetchall()
|
||||
|
||||
def get_posts_needing_detail(self, recheck_hours=3):
|
||||
"""상세 수집이 필요한 게시글 조회 (내용이 없거나 처리중인 최신글)"""
|
||||
"""
|
||||
상세 수집이 필요한 게시글 조회
|
||||
|
||||
조건:
|
||||
1. 내용이 없거나 (content IS NULL OR content = '')
|
||||
2. 또는 마지막 업데이트가 recheck_hours 이상 경과 (updated_at 시간 체크)
|
||||
|
||||
우선순위: is_related DESC, id DESC (최신글/관심글 우선)
|
||||
|
||||
Args:
|
||||
recheck_hours: 재검사 간격(시간 단위). 기본값: 3시간
|
||||
|
||||
Returns:
|
||||
list: (id, title, is_related) 튜플 리스트
|
||||
"""
|
||||
cur = self.conn.cursor()
|
||||
# 내용이 없고 공개된 것 우선
|
||||
|
||||
# recheck 간격 계산
|
||||
from datetime import datetime, timedelta
|
||||
threshold_time = (datetime.now() - timedelta(hours=recheck_hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cur.execute('''
|
||||
SELECT id, title, is_related FROM posts
|
||||
WHERE content IS NULL OR content = ''
|
||||
ORDER BY is_public DESC, id DESC
|
||||
WHERE
|
||||
(content IS NULL OR content = '')
|
||||
OR (updated_at IS NOT NULL AND updated_at < ?)
|
||||
ORDER BY is_related DESC, id DESC
|
||||
LIMIT 10
|
||||
''')
|
||||
''', (threshold_time,))
|
||||
return cur.fetchall()
|
||||
|
||||
def get_all_posts(self, limit=500):
|
||||
|
|
@ -276,6 +304,28 @@ class VOCDatabase:
|
|||
if self.conn:
|
||||
self.conn.close()
|
||||
|
||||
def get_sync_cursor(self, key: str) -> str:
|
||||
"""동기화 커서 조회"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT value FROM sync_state WHERE key = ?", (key,))
|
||||
row = cur.fetchone()
|
||||
return str(row["value"]) if row and row["value"] else ""
|
||||
|
||||
def save_sync_cursor(self, key: str, value: str):
|
||||
"""동기화 커서 저장 (upsert)"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sync_state (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ========================================================================
|
||||
# 통계 쿼리 메서드 (Statistics Queries)
|
||||
# ========================================================================
|
||||
|
|
|
|||
|
|
@ -2,10 +2,26 @@ import customtkinter as ctk
|
|||
from view.theme import theme_manager
|
||||
|
||||
class NotificationDialog(ctk.CTkToplevel):
|
||||
# 미확인 알림 다이얼로그 싱글톤 참조
|
||||
_unchecked_dialog = None
|
||||
|
||||
def __init__(self, controller, title, msg, voc_id=None):
|
||||
# 미확인 알림(⚠️ 미확인 VOC)인 경우 이미 열려있으면 기존 다이얼로그 앞으로 가져오기
|
||||
is_unchecked = title.startswith("⚠️ 미확인 VOC")
|
||||
if is_unchecked and NotificationDialog._unchecked_dialog is not None:
|
||||
# 기존 다이얼로그를 앞으로 가져옴
|
||||
NotificationDialog._unchecked_dialog.lift()
|
||||
NotificationDialog._unchecked_dialog.focus_force()
|
||||
return
|
||||
|
||||
super().__init__(controller.root)
|
||||
self.controller = controller
|
||||
self.voc_id = voc_id
|
||||
self.is_unchecked = is_unchecked
|
||||
|
||||
# 미확인 알림이면 싱글톤 참조 저장
|
||||
if is_unchecked:
|
||||
NotificationDialog._unchecked_dialog = self
|
||||
|
||||
self.title(title)
|
||||
self.attributes("-topmost", True) # 항상 위
|
||||
|
|
@ -42,7 +58,7 @@ class NotificationDialog(ctk.CTkToplevel):
|
|||
|
||||
self.btn_close = ctk.CTkButton(
|
||||
self.btn_frame, text="닫기", width=100,
|
||||
fg_color="gray", command=self.destroy
|
||||
fg_color="gray", command=self._on_close
|
||||
)
|
||||
self.btn_close.pack(side="right", padx=5)
|
||||
|
||||
|
|
@ -66,13 +82,22 @@ class NotificationDialog(ctk.CTkToplevel):
|
|||
self.lift()
|
||||
self.focus_force()
|
||||
|
||||
# 다이얼로그 종료 시 싱글톤 참조 정리
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _on_view(self):
|
||||
if self.voc_id:
|
||||
self.controller.open_list_view(focus_id=self.voc_id)
|
||||
self.destroy()
|
||||
self._on_close()
|
||||
|
||||
def _on_open_list(self):
|
||||
self.controller.open_list_view()
|
||||
self._on_close()
|
||||
|
||||
def _on_close(self):
|
||||
"""다이얼로그 종료 시 싱글톤 참조 정리"""
|
||||
if self.is_unchecked and NotificationDialog._unchecked_dialog is self:
|
||||
NotificationDialog._unchecked_dialog = None
|
||||
self.destroy()
|
||||
|
||||
def _center_window(self):
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
str(self.controller.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30))
|
||||
)
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="상세 재수집 주기 (시간)", font=font).pack(pady=5)
|
||||
self.combo_recheck_hours = ctk.CTkComboBox(self.tab_crawl, values=["1", "2", "3", "5", "6", "12", "24"])
|
||||
self.combo_recheck_hours.pack(pady=5)
|
||||
self.combo_recheck_hours.set(
|
||||
str(self.controller.settings.get('crawling', {}).get('recheck_hours', 3))
|
||||
)
|
||||
|
||||
self.unchecked_delay_var = ctk.BooleanVar(
|
||||
value=self.controller.settings.get('noti', {}).get('unchecked_delay_enabled', True)
|
||||
)
|
||||
|
|
@ -256,6 +263,78 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(5, 0))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="업데이트 정책", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
|
||||
|
||||
update_settings = self.controller.settings.get('update', {})
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="허용 업데이트 등급 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.entry_allowed_levels = ctk.CTkEntry(self.tab_system)
|
||||
self.entry_allowed_levels.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.entry_allowed_levels.insert(0, ", ".join(update_settings.get('allowed_update_levels', ['patch', 'minor', 'major'])))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="클린 설치 적용 등급 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.entry_clean_levels = ctk.CTkEntry(self.tab_system)
|
||||
self.entry_clean_levels.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.entry_clean_levels.insert(0, ", ".join(update_settings.get('clean_install_levels', ['minor', 'major'])))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="보존 파일 패턴 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.entry_preserve_globs = ctk.CTkEntry(self.tab_system)
|
||||
self.entry_preserve_globs.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.entry_preserve_globs.insert(0, ", ".join(update_settings.get('preserve_globs', ['data/*.sqlite', 'data/*.json', 'data/*.db'])))
|
||||
|
||||
self.cleanup_backup_var = ctk.BooleanVar(value=bool(update_settings.get('cleanup_backup_on_success', True)))
|
||||
ctk.CTkSwitch(
|
||||
self.tab_system,
|
||||
text="업데이트 성공 시 백업 폴더 자동 정리",
|
||||
variable=self.cleanup_backup_var,
|
||||
).pack(anchor="w", padx=20, pady=(4, 6))
|
||||
|
||||
self.clean_install_var = ctk.BooleanVar(value=bool(update_settings.get('clean_install_before_copy', True)))
|
||||
ctk.CTkSwitch(
|
||||
self.tab_system,
|
||||
text="업데이트 전 기존 파일 정리(잔존 라이브러리 간섭 방지)",
|
||||
variable=self.clean_install_var,
|
||||
).pack(anchor="w", padx=20, pady=(0, 6))
|
||||
|
||||
ctk.CTkLabel(
|
||||
self.tab_system,
|
||||
text="※ major/minor 업데이트는 기본적으로 클린 설치 권장",
|
||||
font=theme_manager.get_font(10),
|
||||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(0, 0))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="Supabase 동기화", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
|
||||
|
||||
sync_settings = self.controller.settings.get('sync', {})
|
||||
|
||||
self.sync_enabled_var = ctk.BooleanVar(value=bool(sync_settings.get('enabled', False)))
|
||||
ctk.CTkSwitch(
|
||||
self.tab_system,
|
||||
text="크롤링 대신 Supabase 동기화 사용",
|
||||
variable=self.sync_enabled_var,
|
||||
).pack(anchor="w", padx=20, pady=(4, 6))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="동기화 주기(분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.combo_sync_interval = ctk.CTkComboBox(self.tab_system, values=["1", "3", "5", "10", "30"])
|
||||
self.combo_sync_interval.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.combo_sync_interval.set(str(sync_settings.get('pull_interval_minutes', 3)))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="동기화 대상 테이블", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.entry_sync_table = ctk.CTkEntry(self.tab_system)
|
||||
self.entry_sync_table.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.entry_sync_table.insert(0, str(sync_settings.get('table', 'posts')))
|
||||
|
||||
ctk.CTkLabel(
|
||||
self.tab_system,
|
||||
text="※ URL/KEY는 update 연결정보를 재사용합니다(기본).",
|
||||
font=theme_manager.get_font(10),
|
||||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(0, 0))
|
||||
|
||||
ctk.CTkButton(self.tab_system, text="설정 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def _select_report_folder(self):
|
||||
|
|
@ -335,6 +414,7 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.controller.settings['login']['id'] = self.entry_id.get()
|
||||
self.controller.settings['login']['pw'] = self.entry_pw.get()
|
||||
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
|
||||
self.controller.settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())
|
||||
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_crawl_kw.get().split(',') if k.strip()]
|
||||
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
|
||||
|
||||
|
|
@ -360,6 +440,38 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.controller.settings['report'] = {}
|
||||
self.controller.settings['report']['output_path'] = self.report_path_label.cget("text")
|
||||
|
||||
if 'update' not in self.controller.settings:
|
||||
self.controller.settings['update'] = {}
|
||||
|
||||
allowed_levels = [v.strip().lower() for v in self.entry_allowed_levels.get().split(',') if v.strip()]
|
||||
clean_levels = [v.strip().lower() for v in self.entry_clean_levels.get().split(',') if v.strip()]
|
||||
preserve_globs = [v.strip() for v in self.entry_preserve_globs.get().split(',') if v.strip()]
|
||||
|
||||
valid_levels = {"patch", "minor", "major"}
|
||||
allowed_levels = [v for v in allowed_levels if v in valid_levels] or ["patch", "minor", "major"]
|
||||
clean_levels = [v for v in clean_levels if v in valid_levels] or ["minor", "major"]
|
||||
preserve_globs = preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"]
|
||||
|
||||
self.controller.settings['update']['allowed_update_levels'] = allowed_levels
|
||||
self.controller.settings['update']['clean_install_levels'] = clean_levels
|
||||
self.controller.settings['update']['preserve_globs'] = preserve_globs
|
||||
self.controller.settings['update']['cleanup_backup_on_success'] = self.cleanup_backup_var.get()
|
||||
self.controller.settings['update']['clean_install_before_copy'] = self.clean_install_var.get()
|
||||
|
||||
if 'sync' not in self.controller.settings:
|
||||
self.controller.settings['sync'] = {}
|
||||
|
||||
self.controller.settings['sync']['enabled'] = self.sync_enabled_var.get()
|
||||
self.controller.settings['sync']['pull_interval_minutes'] = int(self.combo_sync_interval.get())
|
||||
self.controller.settings['sync']['table'] = self.entry_sync_table.get().strip() or 'posts'
|
||||
self.controller.settings['sync'].setdefault('pull_batch_size', 500)
|
||||
self.controller.settings['sync'].setdefault('timeout_sec', 10)
|
||||
self.controller.settings['sync'].setdefault('use_update_connection', True)
|
||||
self.controller.settings['sync'].setdefault('supabase_url', '')
|
||||
self.controller.settings['sync'].setdefault('supabase_key', '')
|
||||
self.controller.settings['sync'].setdefault('ssl_verify', True)
|
||||
self.controller.settings['sync'].setdefault('ca_bundle_path', '')
|
||||
|
||||
self.controller.save_settings()
|
||||
self.controller.update_config_runtime()
|
||||
|
||||
|
|
|
|||
|
|
@ -249,3 +249,67 @@ python app/setup.py build
|
|||
추가 스모크 검증:
|
||||
- 빌드된 `updater.exe`를 개발 경로에 복사 후 `prepare_update()` 실행
|
||||
- 결과: `PREPARE True` / `%TEMP%/updater.exe` 존재 확인
|
||||
|
||||
---
|
||||
|
||||
## 10. 업데이트 레벨/보존/클린설치 정책 (2026-02-19)
|
||||
|
||||
### 10.1 update_level 정책
|
||||
|
||||
- Supabase `program_versions.update_level`을 사용 (`patch`, `minor`, `major`)
|
||||
- `settings.update.allowed_update_levels`에 없는 레벨은 업데이트 후보에서 스킵
|
||||
- `settings.update.clean_install_levels`에 포함된 레벨은 클린 설치 강제
|
||||
|
||||
기본값:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowed_update_levels": ["patch", "minor", "major"],
|
||||
"clean_install_levels": ["minor", "major"]
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 보존 정책
|
||||
|
||||
- 기본 보존 대상(glob): `data/*.sqlite`, `data/*.json`, `data/*.db`
|
||||
- 정책 소스 우선순위:
|
||||
1) `settings.update.*`
|
||||
2) `app/updater/config.json:update_policy`
|
||||
|
||||
### 10.3 대규모 업데이트 간섭 대응
|
||||
|
||||
- `clean_install_before_copy=true`면 대상 폴더를 먼저 정리 후 신규 파일 복사
|
||||
- 목적: 구버전 잔존 DLL/라이브러리 간섭 제거
|
||||
|
||||
### 10.4 롤백 기본 동작
|
||||
|
||||
- 실패 시 롤백은 기본 동작 (손상 zip, 권한 오류, 예기치 못한 예외 포함)
|
||||
- 성공 시 `cleanup_backup_on_success=true`면 백업 폴더 자동 삭제
|
||||
|
||||
### 10.5 시나리오 소거 체크리스트
|
||||
|
||||
- [x] 손상 zip 파일 → 설치 실패 + 롤백
|
||||
- [x] 파일 권한 부족 → 설치 실패 + 롤백
|
||||
- [x] 대규모 업데이트 잔존 파일 간섭 → 클린 설치로 제거
|
||||
- [x] 사용자 데이터 덮어쓰기 위험 → 보존 패턴 복원
|
||||
- [x] 정책 미스매치(update_level 미허용) → 업데이트 스킵 + 로그
|
||||
- [x] 미정의 예외 → 포괄 예외 처리 + 실패 메시지 + 롤백
|
||||
|
||||
### 10.6 메인 앱 연동 포인트
|
||||
|
||||
- 백그라운드 체크 실패는 `NetworkError/ConfigError`로 메인 컨트롤러 콜백 전달
|
||||
- 업데이트 안내 시 레벨(update_level) 기반 사용자 정책을 UI 메시지로 표기 가능
|
||||
|
||||
### 10.7 오류 코드 분기 처리
|
||||
|
||||
- UpdateManager 오류 코드 예시:
|
||||
- 네트워크/인증: `ERR_SSL_VERIFY`, `ERR_TIMEOUT`, `ERR_NETWORK_REQUEST`
|
||||
- 준비/실행: `ERR_NO_DOWNLOAD_URL`, `ERR_UPDATER_EXE_NOT_FOUND`, `ERR_UPDATER_TEMP_NOT_FOUND`, `ERR_UPDATER_LAUNCH`, `ERR_PERMISSION`, `ERR_PREPARE_UNKNOWN`
|
||||
- 메인 앱(`Controller`)은 코드 기반으로 사용자 안내 문구를 분기 처리함
|
||||
|
||||
### 10.8 설정 UI 반영
|
||||
|
||||
- 설정 > 시스템 탭에서 아래 정책을 직접 편집 가능:
|
||||
- `allowed_update_levels`
|
||||
- `clean_install_levels`
|
||||
- `preserve_globs`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
APP_DIR = ROOT_DIR / "app"
|
||||
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
from view.dialogs.settings_dialog import SettingsDialog
|
||||
|
||||
|
||||
class _DummyField:
|
||||
def __init__(self, value: str):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _DummyVar:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _DummyLabel:
|
||||
def __init__(self, text: str):
|
||||
self._text = text
|
||||
|
||||
def cget(self, key: str):
|
||||
if key == "text":
|
||||
return self._text
|
||||
return ""
|
||||
|
||||
|
||||
class TestSettingsDialogUpdatePolicy(unittest.TestCase):
|
||||
def test_save_all_persists_update_policy_fields(self):
|
||||
controller = SimpleNamespace()
|
||||
controller.settings = {
|
||||
"login": {"id": "old", "pw": "old"},
|
||||
"crawling": {"interval_minutes": 10, "recheck_hours": 3, "keywords": [], "target_depts": []},
|
||||
"noti": {"db_check_interval_minutes": 3, "unchecked_check_interval_minutes": 10, "unchecked_delay_enabled": True},
|
||||
"theme": "Dark",
|
||||
"report": {"output_path": "D:/reports"},
|
||||
"update": {},
|
||||
}
|
||||
controller.save_settings_called = 0
|
||||
controller.update_runtime_called = 0
|
||||
|
||||
def _save_settings():
|
||||
controller.save_settings_called += 1
|
||||
|
||||
def _update_runtime():
|
||||
controller.update_runtime_called += 1
|
||||
|
||||
controller.save_settings = _save_settings
|
||||
controller.update_config_runtime = _update_runtime
|
||||
|
||||
dialog = object.__new__(SettingsDialog)
|
||||
dialog.controller = controller
|
||||
dialog.entry_id = _DummyField("116696")
|
||||
dialog.entry_pw = _DummyField("pw")
|
||||
dialog.combo_interval = _DummyField("10")
|
||||
dialog.combo_recheck_hours = _DummyField("6")
|
||||
dialog.entry_crawl_kw = _DummyField("1호선, 서면")
|
||||
dialog.entry_dept = _DummyField("차량")
|
||||
dialog.combo_noti_interval = _DummyField("3")
|
||||
dialog.combo_unchecked_interval = _DummyField("10")
|
||||
dialog.unchecked_delay_var = _DummyVar(True)
|
||||
dialog.combo_theme = _DummyField("Dark")
|
||||
dialog.sound_var = _DummyVar(True)
|
||||
dialog.use_related_filter_var = _DummyVar(True)
|
||||
dialog.report_path_label = _DummyLabel("D:/reports")
|
||||
|
||||
dialog.entry_allowed_levels = _DummyField("patch, major, wrong")
|
||||
dialog.entry_clean_levels = _DummyField("minor, major")
|
||||
dialog.entry_preserve_globs = _DummyField("data/*.sqlite, data/*.json, data/*.db")
|
||||
dialog.cleanup_backup_var = _DummyVar(True)
|
||||
dialog.clean_install_var = _DummyVar(True)
|
||||
dialog.sync_enabled_var = _DummyVar(True)
|
||||
dialog.combo_sync_interval = _DummyField("5")
|
||||
dialog.entry_sync_table = _DummyField("posts")
|
||||
dialog.destroy = lambda: None
|
||||
|
||||
with patch("view.dialogs.settings_dialog.theme_manager.set_theme", lambda *_args, **_kwargs: None):
|
||||
SettingsDialog.save_all(dialog)
|
||||
|
||||
crawling_settings = controller.settings["crawling"]
|
||||
self.assertEqual(crawling_settings["recheck_hours"], 6)
|
||||
update_settings = controller.settings["update"]
|
||||
self.assertEqual(update_settings["allowed_update_levels"], ["patch", "major"])
|
||||
self.assertEqual(update_settings["clean_install_levels"], ["minor", "major"])
|
||||
self.assertEqual(update_settings["preserve_globs"], ["data/*.sqlite", "data/*.json", "data/*.db"])
|
||||
self.assertTrue(update_settings["cleanup_backup_on_success"])
|
||||
self.assertTrue(update_settings["clean_install_before_copy"])
|
||||
sync_settings = controller.settings["sync"]
|
||||
self.assertTrue(sync_settings["enabled"])
|
||||
self.assertEqual(sync_settings["pull_interval_minutes"], 5)
|
||||
self.assertEqual(sync_settings["table"], "posts")
|
||||
self.assertEqual(controller.save_settings_called, 1)
|
||||
self.assertEqual(controller.update_runtime_called, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -4,6 +4,7 @@ import tempfile
|
|||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import PropertyMock, patch
|
||||
import requests
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
|
|
@ -105,6 +106,19 @@ class TestUpdateManager(unittest.TestCase):
|
|||
self.assertEqual(conn["supabase_url"], "https://kong.m1tcloud.cc")
|
||||
self.assertEqual(conn["supabase_key"], "remote-key")
|
||||
|
||||
def test_check_for_updates_raises_ssl_error_code(self):
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://kong.m1tcloud.cc",
|
||||
supabase_key="dummy-key",
|
||||
current_version="3.0.0",
|
||||
)
|
||||
|
||||
with patch("app.updater.update_manager.requests.get", side_effect=requests.exceptions.SSLError("ssl fail")):
|
||||
with self.assertRaises(updater_manager.NetworkError) as ctx:
|
||||
manager.check_for_updates()
|
||||
|
||||
self.assertEqual(getattr(ctx.exception, "code", ""), "ERR_SSL_VERIFY")
|
||||
|
||||
def test_create_update_manager_from_settings_reads_connection_config(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.json"
|
||||
|
|
@ -207,6 +221,42 @@ class TestUpdateManager(unittest.TestCase):
|
|||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("updater.exe", message)
|
||||
self.assertEqual(manager.last_error_code, "ERR_UPDATER_EXE_NOT_FOUND")
|
||||
|
||||
def test_launch_updater_sets_error_code_when_temp_exe_missing(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://example.supabase.co",
|
||||
supabase_key="dummy-key",
|
||||
)
|
||||
missing_temp = Path(td) / "missing_updater.exe"
|
||||
|
||||
with patch.object(
|
||||
UpdateManager,
|
||||
"temp_updater_path",
|
||||
new_callable=PropertyMock,
|
||||
return_value=missing_temp,
|
||||
):
|
||||
ok = manager.launch_updater()
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(manager.last_error_code, "ERR_UPDATER_TEMP_NOT_FOUND")
|
||||
|
||||
def test_prepare_update_fails_when_download_url_missing(self):
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://example.supabase.co",
|
||||
supabase_key="dummy-key",
|
||||
)
|
||||
version_info = VersionInfo(
|
||||
version="9.9.9",
|
||||
is_stable=True,
|
||||
download_url=None,
|
||||
)
|
||||
|
||||
ok, message = manager.prepare_update(version_info)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("다운로드 URL", message)
|
||||
self.assertEqual(manager.last_error_code, "ERR_NO_DOWNLOAD_URL")
|
||||
|
||||
def test_prepare_update_writes_config_and_copies_updater(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
|
|
@ -247,6 +297,39 @@ class TestUpdateManager(unittest.TestCase):
|
|||
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(config_data["version"], "9.9.9")
|
||||
self.assertEqual(config_data["download_url"], "https://example.com/update.zip")
|
||||
self.assertIn("preserve_globs", config_data)
|
||||
self.assertIn("data/*.json", config_data["preserve_globs"])
|
||||
self.assertTrue(config_data["cleanup_backup_on_success"])
|
||||
|
||||
def test_check_for_updates_skips_disallowed_update_level(self):
|
||||
manager = UpdateManager(
|
||||
supabase_url="https://kong.m1tcloud.cc",
|
||||
supabase_key="dummy-key",
|
||||
current_version="3.0.0",
|
||||
allowed_update_levels=["patch"],
|
||||
)
|
||||
|
||||
class _Resp200:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"version": "4.0.0",
|
||||
"is_stable": True,
|
||||
"update_level": "major",
|
||||
"release_note": "major release",
|
||||
"download_url": "https://example.com/update.zip",
|
||||
}
|
||||
]
|
||||
|
||||
with patch("app.updater.update_manager.requests.get", return_value=_Resp200()):
|
||||
info = manager.check_for_updates()
|
||||
|
||||
self.assertIsNone(info)
|
||||
|
||||
def test_prepare_update_returns_permission_error_when_copy_fails(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
|
|
@ -346,6 +429,7 @@ class TestUpdaterGUI(unittest.TestCase):
|
|||
root = Path(td)
|
||||
target_dir = root / "app_dir"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / "old.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
bad_zip = root / "bad.zip"
|
||||
bad_zip.write_text("not-a-zip", encoding="utf-8")
|
||||
|
|
@ -360,6 +444,7 @@ class TestUpdaterGUI(unittest.TestCase):
|
|||
ok, message = gui._extract_and_replace(bad_zip)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("손상된 zip", message)
|
||||
self.assertTrue((target_dir / "old.txt").exists())
|
||||
|
||||
def test_extract_and_replace_rolls_back_on_copy_error(self):
|
||||
gui = build_updater_gui_stub()
|
||||
|
|
@ -394,6 +479,54 @@ class TestUpdaterGUI(unittest.TestCase):
|
|||
self.assertIn("denied", message)
|
||||
self.assertTrue((target_dir / "old.txt").exists())
|
||||
|
||||
def test_extract_and_replace_preserves_settings_and_db_on_clean_install(self):
|
||||
gui = build_updater_gui_stub()
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
target_dir = root / "app_dir"
|
||||
data_dir = target_dir / "data"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(data_dir / "settings.json").write_text('{"k":"old"}', encoding="utf-8")
|
||||
(data_dir / "voc.db").write_text("old-db", encoding="utf-8")
|
||||
(target_dir / "stale_lib.dll").write_text("stale", encoding="utf-8")
|
||||
|
||||
zip_file = root / "update.zip"
|
||||
source_dir = root / "zip_source"
|
||||
(source_dir / "data").mkdir(parents=True, exist_ok=True)
|
||||
(source_dir / "app.exe").write_text("new-app", encoding="utf-8")
|
||||
(source_dir / "data" / "settings.json").write_text('{"k":"new"}', encoding="utf-8")
|
||||
(source_dir / "data" / "voc.db").write_text("new-db", encoding="utf-8")
|
||||
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(zip_file, "w") as zf:
|
||||
zf.write(source_dir / "app.exe", arcname="app.exe")
|
||||
zf.write(source_dir / "data" / "settings.json", arcname="data/settings.json")
|
||||
zf.write(source_dir / "data" / "voc.db", arcname="data/voc.db")
|
||||
|
||||
gui.config = UpdateConfig(
|
||||
download_url="https://example.com/update.zip",
|
||||
target_path=str(target_dir),
|
||||
version="9.9.9",
|
||||
restart_exe="voc_noti.exe",
|
||||
preserve_globs=["data/*.sqlite", "data/*.json", "data/*.db"],
|
||||
cleanup_backup_on_success=True,
|
||||
clean_install_before_copy=True,
|
||||
)
|
||||
|
||||
ok, message = gui._extract_and_replace(zip_file)
|
||||
|
||||
self.assertTrue(ok, message)
|
||||
self.assertTrue((target_dir / "app.exe").exists())
|
||||
self.assertFalse((target_dir / "stale_lib.dll").exists())
|
||||
self.assertEqual((data_dir / "settings.json").read_text(encoding="utf-8"), '{"k":"old"}')
|
||||
self.assertEqual((data_dir / "voc.db").read_text(encoding="utf-8"), "old-db")
|
||||
|
||||
backup_dir = root / "app_dir_backup_9.9.9"
|
||||
self.assertFalse(backup_dir.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue