commit 761757c12e11fb493eb9e2a87c7f730b579ff3cf Author: Hyungi Ahn Date: Fri Sep 19 08:52:49 2025 +0900 Initial commit: Todo Project with dashboard, classification center, and upload functionality - 📱 PWA 지원: 홈화멎 추가 가능한 Progressive Web App - 🎚 M-Project 색상 슀킀마: 하늘색, 죌황색, 회색, 흰색 음ꎀ된 디자읞 - 📊 대시볎드: 데슀크톱 캘늰더 ë·° + 몚바음 음음 ë·° 반응형 디자읞 - 📥 분류 섌터: Gmail 슀타음 받은펞지핚윌로 슀마튞 분류 시슀템 - 🀖 AI 분류 제안: 킀워드 êž°ë°˜ 자동 분류 제안 및 음ꎄ 처늬 - 📷 업로드 몚달: 데슀크톱(파음 선택) + 몚바음(칎메띌/가러늬) 최적화 - 🏷 3가지 분류: Todo(시작음), 캘늰더(마감음), 첎크늬슀튞(묎Ʞ한) - 📋 첎크늬슀튞: 진행률 표시 및 완료 토Ꞁ Ʞ능 - 🔄 시놀로지 연동 쀀비: 메음플러슀 연동을 위한 구조 섀계 - 📱 반응형 UI: 몚든 페읎지 몚바음 최적화 완료 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f732091 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Database Configuration +DATABASE_URL=postgresql://todo_user:todo_password@localhost:5434/todo_db +POSTGRES_USER=todo_user +POSTGRES_PASSWORD=todo_password +POSTGRES_DB=todo_db + +# JWT Configuration +SECRET_KEY=your-secret-key-here-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Application Configuration +DEBUG=true +CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] + +# Server Configuration +HOST=0.0.0.0 +PORT=9000 + +# Frontend Configuration +FRONTEND_PORT=4000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c97ba2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,152 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.dockerignore + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ diff --git a/COMPREHENSIVE_GUIDE.md b/COMPREHENSIVE_GUIDE.md new file mode 100644 index 0000000..6b6c5fb --- /dev/null +++ b/COMPREHENSIVE_GUIDE.md @@ -0,0 +1,1052 @@ +# 📚 Todo Project 종합 개발 가읎드 + +> **"Simple Todo, Smart Integration"** +> 간결한 할음 ꎀ늬 + 시놀로지 생태계 연동윌로 완벜한 개읞 생산성 도구 + +--- + +## 🎯 프로젝튞 개요 + +### 목표 +사진곌 메몚륌 Ʞ반윌로 한 간닚한 음정ꎀ늬 시슀템 구축 + +### 핵심 Ʞ능 +- **입력**: 사진(선택) + 텍슀튞 메몚 +- **분류**: GTD형 / 캘늰더형 / 첎크늬슀튞형 +- **플랫폌**: iOS + Apple Watch + 웹 + +### 시슀템 환겜 +- **배포 서버**: Synology DS1525+ +- **개발 환겜**: Mac mini M4 Pro (macOS) +- **넀튞워크**: LG U+ 2.5G + Synology Router + +### 핵심 가치 +- ⚡ **슉시 ì ‘ê·Œ**: 바로 쌜서 바로 쓞 수 있는 간펞핚 +- 🎯 **간결핚**: 복잡하지 않은 직ꎀ적읞 읞터페읎슀 +- 🔗 **슀마튞 연동**: 시놀로지 캘늰더/메음곌 자동 동Ʞ화 +- 📱 **얎디서나**: 폰, 컎퓚터에서 동음한 겜험 + +--- + +## 🏗 시슀템 아킀텍처 + +### 볌륚 맀핑 구조 +``` +Synology NAS: +/volume3/docker/todo-app/ # 시슀템/컚테읎너 +├── docker-compose.yml +├── app/ # 애플늬쌀읎션 윔드 +└── config/ # 섀정 파음 + +/volume1/todo-data/ # 데읎터 저장 +├── database/ # DB 파음 +├── uploads/images/ # 업로드 읎믞지 +└── backups/ # 백업 +``` + +### Ʞ술 슀택 +``` +Backend: FastAPI (Python) / PostgreSQL +Frontend: Vanilla JS + Alpine.js + Tailwind CSS +Database: PostgreSQL (포튞: 5434) +Deployment: Docker + Docker Compose +Integration: CalDAV, SMTP, DSM API +``` + +### 전첎 구조 +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Synology │ +│ (4000) │◄──►│ (9000) │◄──►│ Services │ +│ │ │ │ │ │ +│ • PWA Support │ │ • FastAPI │ │ • Calendar │ +│ • Offline Mode │ │ • SQLAlchemy │ │ • MailPlus │ +│ • Auto Login │ │ • PostgreSQL │ │ • DSM API │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### API 구조 +``` +POST /api/items # 생성 +GET /api/items?type={type} # 조회 +PUT /api/items/{id} # 수정 +DELETE /api/items/{id} # 삭제 +POST /api/upload/image # 읎믞지 +``` + +--- + +## 📁 프로젝튞 구조 + +``` +Todo-Project/ +├── README.md +├── DESIGN.md # 섀계 묞서 +├── SYNOLOGY_INTEGRATION.md # 시놀로지 연동 가읎드 +├── docker-compose.yml +├── .env.example +├── backend/ +│ ├── Dockerfile +│ ├── pyproject.toml +│ ├── src/ +│ │ ├── main.py +│ │ ├── core/ +│ │ │ ├── config.py +│ │ │ ├── database.py +│ │ │ └── security.py +│ │ ├── models/ +│ │ │ ├── user.py +│ │ │ └── todo.py +│ │ ├── schemas/ +│ │ │ ├── auth.py +│ │ │ └── todo.py +│ │ ├── api/ +│ │ │ ├── dependencies.py +│ │ │ └── routes/ +│ │ │ ├── auth.py +│ │ │ ├── users.py +│ │ │ └── todos.py +│ │ └── integrations/ +│ │ ├── synology/ +│ │ │ ├── dsm_auth.py +│ │ │ ├── calendar_sync.py +│ │ │ └── mail_service.py +│ │ └── device_auth.py +│ └── migrations/ +├── frontend/ +│ ├── index.html +│ ├── login.html +│ ├── static/ +│ │ ├── css/ +│ │ │ └── main.css +│ │ ├── js/ +│ │ │ ├── api.js +│ │ │ ├── auth.js +│ │ │ ├── todos.js +│ │ │ └── synology-sync.js +│ │ └── icons/ +│ ├── components/ +│ └── manifest.json # PWA 섀정 +├── docs/ +│ ├── API.md # API 묞서 +│ ├── DEPLOYMENT.md # 배포 가읎드 +│ └── SECURITY.md # 볎안 가읎드 +└── database/ + └── init/ + └── 01_init.sql +``` + +--- + +## 📊 데읎터베읎슀 섀계 + +### ERD (Entity Relationship Diagram) +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Users │ │ TodoItems │ │TodoComments │ +├────────────── ├────────────── ├────────────── +│ id (PK) │◄────────│ user_id(FK) │◄────────│todo_item_id │ +│ email │ │ content │ │ content │ +│ password │ │ status │ │ created_at │ +│ full_name │ │ start_date │ └─────────────┘ +│ is_active │ │ estimated │ +│ created_at │ │ completed │ +└─────────────┘ │ parent_id │ ◄─┐ + └─────────────┘ │ + │ │ + └──────────┘ + (Self Reference) +``` + +### 상태 ꎀ늬 +```python +TODO_STATES = { + "draft": "검토 필요 - 아직 음정 믞섀정", + "scheduled": "예정됚 - 음정 섀정 완료", + "active": "진행쀑 - 현재 작업 쀑", + "completed": "완료됚 - 작업 완료", + "delayed": "지연됚 - 새로욎 날짜로 ì—°êž°" +} +``` + +### 데읎터 플로우 +``` +1. 할음 생성 → 2. 음정 섀정 → 3. 캘늰더 동Ʞ화 → 4. 메음 알늌 + ↓ ↓ ↓ ↓ + Draft Scheduled Calendar Email + Event Sent +``` + +--- + +## 🔐 볎안 섀계 + +### 개읞용 최적화 볎안 몚덞 + +#### êž°êž° 등록 방식 +```python +class DeviceAuth: + """개읞 êž°êž° 읞슝 시슀템""" + + def register_device(self, user_id, device_info): + """신뢰할 수 있는 êž°êž° 등록""" + # êž°êž° 고유 ID 생성 (람띌우저 fingerprint + 사용자 입력) + device_id = self.generate_device_id(device_info) + + # 장Ʞ간 유횚한 토큰 생성 (30음) + device_token = self.create_device_token(user_id, device_id) + + return device_token + + def quick_login(self, device_token): + """êž°êž° 토큰윌로 빠륞 로귞읞""" + if self.is_valid_device_token(device_token): + return self.create_session_token() + return None +``` + +#### 볎안 레벚 +- **Minimal**: êž°êž° 등록 후 비밀번혞 불필요 (개읞용 권장) +- **Balanced**: 죌Ʞ적 비밀번혞 확읞 +- **Secure**: 맀번 읞슝 + 생첎 읞슝 + +--- + +## 🔗 시놀로지 연동 섀계 + +### 연동 개요 + +Todo-Project는 시놀로지 생태계와 **양방향 연동**되얎 **간결한 할음 ꎀ늬**와 **전첎 음정 조망**을 동시에 제공합니닀. + +#### 핵심 철학 +- **Todo-Project**: 빠륞 할음 추가 및 ꎀ늬 (간결핚) +- **시놀로지 캘늰더**: 전첎 음정 조망 및 통합 ꎀ늬 (완전성) +- **MailPlus**: 왞부 요청을 Todo로 자동 변환 (자동화) + +### 🔄 간소화된 연동 플로우 ⭐ 새로욎 ì ‘ê·Œ + +#### 핵심 아읎디얎: **메음 쀑심 워크플로우** +``` +📧 MailPlus 메음 수신 + ↓ +📅 시놀로지 캘늰더 읎벀튞 생성 (수동/자동) + ↓ +📋 Todo 자동 추가 (메음 낎용 + 첚부파음 포핚) + ↓ +🔄 캘늰더 ↔ Todo 양방향 동Ʞ화 +``` + +#### 장점 +- **닚순핚**: 복잡한 API 연동 최소화 +- **자연슀러움**: Ʞ졎 메음 워크플로우 활용 +- **유연성**: 닀양한 메음 큎띌읎얞튞에서 작동 +- **안정성**: 시놀로지 Ʞ볞 Ʞ능 활용 + +### 간소화된 구현 방법 + +#### 1. 메음 몚니터링 (핵심 Ʞ능) +```python +class SimpleMailMonitor: + """간닚한 메음 몚니터링 서비슀""" + + def __init__(self, imap_server, username, password): + self.imap_server = imap_server + self.username = username + self.password = password + + async def check_new_emails(self): + """새 메음 확읞 (IMAP)""" + import imaplib + import email + + mail = imaplib.IMAP4_SSL(self.imap_server, 993) + mail.login(self.username, self.password) + mail.select('inbox') + + # 읜지 않은 메음 검색 + status, messages = mail.search(None, 'UNSEEN') + + new_emails = [] + for msg_id in messages[0].split(): + status, msg_data = mail.fetch(msg_id, '(RFC822)') + email_body = msg_data[0][1] + email_message = email.message_from_bytes(email_body) + + new_emails.append({ + 'id': msg_id, + 'subject': email_message['Subject'], + 'from': email_message['From'], + 'body': self._extract_body(email_message), + 'attachments': self._extract_attachments(email_message) + }) + + mail.close() + mail.logout() + return new_emails +``` + +#### 2. 메음 → Todo 자동 변환 서비슀 +```python +class MailToTodoService: + """메음을 Todo로 자동 변환하는 간닚한 서비슀""" + + def __init__(self, mail_monitor, todo_service): + self.mail_monitor = mail_monitor + self.todo_service = todo_service + + async def monitor_emails(self): + """메음 몚니터링 및 자동 변환""" + while True: + try: + new_emails = await self.mail_auth.check_new_emails() + + for email in new_emails: + if self._is_todo_email(email): + todo_item = await self._convert_email_to_todo(email) + await self.todo_service.create_todo(todo_item) + + # 캘늰더에도 동Ʞ화 + await self.calendar_sync.sync_todo_to_calendar(todo_item) + + except Exception as e: + logger.error(f"메음 몚니터링 였류: {e}") + + await asyncio.sleep(60) # 1분마닀 첎크 + + def _is_todo_email(self, email): + """Todo 변환 대상 메음읞지 판당""" + # 제목에 특정 킀워드 포핚 + todo_keywords = ['할음', 'TODO', 'Task', '작업', '요청'] + subject = email['subject'].lower() + + return any(keyword.lower() in subject for keyword in todo_keywords) + + async def _convert_email_to_todo(self, email): + """메음을 Todo 항목윌로 변환""" + # 제목에서 할음 낎용 추출 + content = self._extract_todo_content(email['subject']) + + # 볞묞에서 날짜/시간 추출 + due_date = self._extract_date_from_body(email['body']) + + # 첚부파음 처늬 + attachments = await self._save_attachments(email['attachments']) + + todo_data = { + 'content': content, + 'description': email['body'], + 'due_date': due_date, + 'attachments': attachments, + 'source': 'email', + 'source_id': email['id'], + 'sender': email['from'] + } + + return todo_data + + def _extract_todo_content(self, subject): + """제목에서 할음 낎용 추출""" + # "할음: 묞서 검토" → "묞서 검토" + # "TODO: Review document" → "Review document" + import re + + patterns = [ + r'할음:\s*(.+)', + r'TODO:\s*(.+)', + r'Task:\s*(.+)', + r'작업:\s*(.+)', + r'요청:\s*(.+)' + ] + + for pattern in patterns: + match = re.search(pattern, subject, re.IGNORECASE) + if match: + return match.group(1).strip() + + return subject # 팚턎읎 없윌멎 전첎 제목 사용 + + def _extract_date_from_body(self, body): + """볞묞에서 날짜 추출""" + import re + from datetime import datetime, timedelta + + # 날짜 팚턎듀 + date_patterns = [ + r'(\d{4}-\d{2}-\d{2})', # 2024-01-15 + r'(\d{2}/\d{2}/\d{4})', # 01/15/2024 + r'(\d{1,2}월\s*\d{1,2}음)', # 1월 15음 + r'(낎음|tomorrow)', + r'(닀음죌|next week)', + r'(읎번죌|this week)' + ] + + for pattern in date_patterns: + match = re.search(pattern, body, re.IGNORECASE) + if match: + date_str = match.group(1) + return self._parse_date_string(date_str) + + # Ʞ볞값: 3음 후 + return datetime.now() + timedelta(days=3) + + async def _save_attachments(self, attachments): + """첚부파음을 NAS에 저장""" + saved_files = [] + + for attachment in attachments: + # /volume1/todo-data/attachments/ 에 저장 + file_path = f"/data/attachments/{attachment['filename']}" + + with open(file_path, 'wb') as f: + f.write(attachment['content']) + + saved_files.append({ + 'filename': attachment['filename'], + 'path': file_path, + 'size': len(attachment['content']) + }) + + return saved_files +``` + +### ⚙ 환겜 섀정 + +#### 환겜 변수 섀정 +```bash +# .env 파음 +# 시놀로지 메음 섀정 (필수) +SYNOLOGY_MAIL_SERVER=your-nas.synology.me +SYNOLOGY_MAIL_USERNAME=todo_user +SYNOLOGY_MAIL_PASSWORD=your_secure_password + +# 메음 몚니터링 섀정 +ENABLE_MAIL_MONITORING=true +MAIL_CHECK_INTERVAL=60 # 쎈 닚위 +TODO_KEYWORDS=할음,TODO,Task,작업,요청 + +# 첚부파음 저장 겜로 +ATTACHMENTS_PATH=/data/attachments +``` + +#### 시놀로지 NAS 섀정 + +##### 1. MailPlus 섀정 +```bash +# MailPlus 팚킀지에서: +1. IMAP 활성화: 섀정 → IMAP/POP3 → IMAP 활성화 +2. 포튞: 993 (SSL) 또는 143 (음반) +3. 전용 계정 생성: todo_mail_user +4. 권한: MailPlus 접귌만 허용 +``` + +##### 2. 캘늰더 섀정 (선택사항) +```bash +# Calendar 팚킀지에서: +1. 새 캘늰더 생성: "Todo Tasks" +2. 색상: 볎띌색 (#6366f1) +3. 메음에서 음정 추가 Ʞ능 활성화 +``` + +### 🔧 사용 방법 + +#### 1. 메음로 할음 추가 +``` +제목: 할음: 프로젝튞 묞서 검토 +볞묞: +- 마감음: 2024-01-20 +- 예상 소요시간: 2시간 +- 첚부파음: project_spec.pdf + +→ Todo 앱에 자동윌로 추가됚 +``` + +#### 2. 캘늰더 연동 (선택) +``` +MailPlus → 캘늰더 읎벀튞 생성 → Todo 동Ʞ화 +캘늰더에서 완료 처늬 → Todo 상태 자동 업데읎튞 +``` + +#### 3. 첚부파음 ꎀ늬 +``` +메음 첚부파음 → NAS 저장 (/data/attachments/) +Todo에서 파음 링크로 ì ‘ê·Œ 가능 +``` + +--- + +## 📱 프론튞엔드 섀계 + +### PWA (Progressive Web App) 구조 +```javascript +// manifest.json +{ + "name": "Todo Project", + "short_name": "Todo", + "start_url": "/", + "display": "standalone", + "theme_color": "#6366f1", + "shortcuts": [ + { + "name": "빠륞 할음 추가", + "url": "/quick-add" + } + ] +} +``` + +### 였프띌읞 지원 +```javascript +// Service Worker +self.addEventListener('fetch', event => { + if (event.request.url.includes('/api/todos')) { + event.respondWith( + // 옚띌읞: API 혞출 + // 였프띌읞: 로컬 캐시 사용 + caches.match(event.request) || fetch(event.request) + ); + } +}); +``` + +### 상태 ꎀ늬 +```javascript +class TodoState { + constructor() { + this.todos = []; + this.syncQueue = []; // 였프띌읞 시 동Ʞ화 대Ʞ엎 + this.isOnline = navigator.onLine; + } + + async addTodo(content) { + const todo = this.createTodo(content); + + if (this.isOnline) { + await this.syncToServer(todo); + } else { + this.syncQueue.push({action: 'create', todo}); + } + + return todo; + } +} +``` + +### 간결한 읞터페읎슀 원칙 + +#### 메읞 화멎 +```html + +
+ + + + +
+ + +
+ + +
+ +
+
+``` + +#### 몚바음 최적화 +- **터치 친화적**: 44px 읎상 터치 영역 +- **햅틱 플드백**: 액션 시 진동 플드백 +- **풀투늬프레시**: 아래로 당겚서 새로고칚 +- **였프띌읞 표시**: 넀튞워크 상태 표시 + +### 색상 시슀템 +```css +:root { + --primary: #6366f1; /* 볎띌색 - 메읞 컬러 */ + --success: #10b981; /* 쎈록색 - 완료 */ + --warning: #f59e0b; /* 죌황색 - 진행쀑 */ + --danger: #ef4444; /* 빚간색 - 지연 */ + --gray: #6b7280; /* 회색 - 대Ʞ */ +} +``` + +--- + +## 🎯 윔딩 표쀀 및 규칙 + +### 핵심 원칙: "간결핚 (Simplicity)" + +> **"복잡핚은 버귞의 옚상읎닀. 간결핚읎 최고의 아늄닀움읎닀."** + +### 파음 크Ʞ 제한 + +#### 핵심 로직 파음 (엄격) +- **몚덞 파음 (models/)**: 최대 200쀄 +- **슀킀마 파음 (schemas/)**: 최대 150쀄 +- **섀정 파음 (config.py)**: 최대 100쀄 + +#### 서비슀 파음 (볎통) +- **API 띌우터**: 최대 400쀄 +- **서비슀 큎래슀**: 최대 350쀄 +- **통합 서비슀**: 최대 500쀄 + +#### 프론튞엔드 파음 (유연) +- **JavaScript 파음**: 최대 300쀄 +- **HTML 파음**: 최대 250쀄 +- **CSS 파음**: 최대 200쀄 + +### 핚수/메서드 크Ʞ 제한 +- **닚순 핚수**: 최대 20쀄 +- **비슈니슀 로직 핚수**: 최대 40쀄 +- **통합/조합 핚수**: 최대 60쀄 +- **쎈Ʞ화 핚수**: 최대 30쀄 + +### 간결성 규칙 + +#### 1. 한 가지 음만 하Ʞ +```python +# ❌ 나쁜 예 - 여러 음을 핹 +def process_todo_and_send_email_and_update_calendar(todo): + # 할음 처늬 + # 읎메음 발송 + # 캘늰더 업데읎튞 + pass + +# ✅ 좋은 예 - 각각 분늬 +def process_todo(todo): pass +def send_notification_email(todo): pass +def update_calendar(todo): pass +``` + +#### 2. 명확한 읎늄 사용 +```python +# ❌ 나쁜 예 +def calc(x, y, z): pass +def proc_data(d): pass + +# ✅ 좋은 예 +def calculate_estimated_time(start, duration, buffer): pass +def process_todo_item(todo_data): pass +``` + +#### 3. 쀑복 제거 +```python +# ❌ 나쁜 예 - 쀑복 윔드 +def create_synology_event(todo): + event = CalendarEvent() + event.title = f"📋 {todo.content}" + event.start_time = todo.start_date + # ... 공통 로직 + +# ✅ 좋은 예 - 공통 로직 분늬 +def create_base_event(todo): + return CalendarEvent( + title=f"📋 {todo.content}", + start_time=todo.start_date + ) + +def create_synology_event(todo): + event = create_base_event(todo) + # 시놀로지 특화 로직만 + return event +``` + +--- + +## 🐳 Docker 섀정 + +### docker-compose.yml (Production - NAS) +```yaml +version: '3.8' + +services: + frontend: + build: ./frontend + container_name: todo-web + ports: + - "4000:80" + depends_on: + - backend + restart: unless-stopped + + backend: + build: ./backend + container_name: todo-api + ports: + - "9000:9000" + volumes: + - /volume3/docker/todo-app/app:/app + - /volume1/todo-data:/data + environment: + - DATABASE_URL=postgresql://todouser:${DB_PASSWORD}@database:5432/todo + - UPLOAD_PATH=/data/uploads/images + - TZ=Asia/Seoul + depends_on: + - database + restart: unless-stopped + + database: + image: postgres:15-alpine + container_name: todo-db + ports: + - "5434:5432" + volumes: + - /volume1/todo-data/database:/var/lib/postgresql/data + environment: + - POSTGRES_DB=todo + - POSTGRES_USER=todouser + - POSTGRES_PASSWORD=${DB_PASSWORD} + restart: unless-stopped +``` + +--- + +## 🔄 개발 워크플로우 + +### Mac 로컬 개발 구조 +```bash +~/Developer/todo-app/ +├── backend/ +│ ├── main.py +│ ├── requirements.txt +│ ├── Dockerfile +│ └── docker-compose.dev.yml +├── frontend/ +│ └── (React/Vue 파음) +├── ios/ +│ └── TodoApp.xcodeproj +└── README.md +``` + +### 1닚계: 로컬 개발 (Mac) +```bash +# 프로젝튞 시작 +cd ~/Developer/todo-app +docker-compose -f docker-compose.dev.yml up + +# API 테슀튞 +curl http://localhost:9000/api/items + +# 프론튞엔드 개발 +npm run dev # http://localhost:4000 +``` + +### 2닚계: NAS 배포 +```bash +# Mac에서 푞시 +git add . +git commit -m "Ʞ능 추가" +git push gitea main + +# NAS SSH 접속 +ssh admin@nas-ip + +# 배포 +cd /volume3/docker/todo-app +git pull +docker-compose down +docker-compose up -d --build +``` + +### 3닚계: iOS 앱 연동 +```swift +// Development +let API_BASE = "http://192.168.1.100:9000" + +// Production +let API_BASE = "https://your.synology.me:9000" +``` + +--- + +## 🚀 빠륞 시작 + +### 포튞 섀정 +- **Frontend**: http://localhost:4000 +- **Backend API**: http://localhost:9000 +- **Database**: localhost:5434 + +### 개발 환겜 섀정 + +1. **환겜 변수 섀정** +```bash +cp .env.example .env +# .env 파음에서 시놀로지 연동 정볎 섀정 (선택사항) +``` + +2. **Docker로 싀행** +```bash +docker-compose up -d +``` + +3. **람띌우저에서 접속** +``` +http://localhost:4000 +``` + +4. **개읞용 섀정 (권장)** +- 첫 접속 시 "개읞용윌로 섀정" 선택 +- 낮 êž°êž° 등록윌로 자동 로귞읞 활성화 + +### 쎈Ʞ 섀정 명령얎 +```bash +# Mac: 프로젝튞 생성 +mkdir -p ~/Developer/todo-app/{backend,frontend,ios} +cd ~/Developer/todo-app + +# NAS: 폮더 생성 +ssh admin@nas-ip +sudo mkdir -p /volume3/docker/todo-app +sudo mkdir -p /volume1/todo-data/{database,uploads/images,backups} + +# Git 쎈Ʞ화 +git init +git remote add origin http://nas-ip:3000/username/todo-app.git +``` + +### 첫 배포 테슀튞 +```bash +# 1. 간닚한 API 작성 +echo "from fastapi import FastAPI +app = FastAPI() +@app.get('/') +def read_root(): + return {'status': 'ok'}" > backend/main.py + +# 2. Dockerfile 생성 +echo "FROM python:3.11-slim +WORKDIR /app +RUN pip install fastapi uvicorn +COPY . . +CMD ['uvicorn', 'main:app', '--host', '0.0.0.0', '--port', '9000']" > backend/Dockerfile + +# 3. 배포 및 테슀튞 +docker build -t todo-api backend/ +docker run -p 9000:9000 todo-api +curl http://localhost:9000 +``` + +--- + +## 📝 구현 로드맵 + +### Phase 1: 백엔드 (Week 1) +- [ ] Docker 환겜 구성 +- [ ] FastAPI Ʞ볞 CRUD +- [ ] PostgreSQL 슀킀마 +- [ ] 읎믞지 업로드 처늬 +- [ ] JWT 읞슝 + +### Phase 2: 웹 프론튞엔드 (Week 1) +- [ ] React/Vue 프로젝튞 섀정 +- [ ] 3가지 분류 UI +- [ ] 읎믞지 업로드 컎포넌튞 +- [ ] PWA 섀정 +- [ ] 반응형 디자읞 + +### Phase 3: iOS 앱 (Week 2-3) +- [ ] SwiftUI Ʞ볞 구조 +- [ ] API Service 큎래슀 +- [ ] 칎메띌/가러늬 연동 +- [ ] 였프띌읞 캐싱 +- [ ] 푞시 알늌 + +### Phase 4: Apple Watch (Week 1) +- [ ] WatchOS 타겟 추가 +- [ ] 였늘 할음 ë·° +- [ ] 첎크 Ʞ능 +- [ ] 컎플늬쌀읎션 + +### 시놀로지 연동 (1닚계) +- [ ] 시놀로지 읞슝 시슀템 +- [ ] 시놀로지 캘늰더 연동 +- [ ] PWA 몚바음 지원 +- [ ] 람띌우저 확장 프로귞랚 +- [ ] 였프띌읞 Ʞ능 + +--- + +## 🔧 NAS 섀정 첎크늬슀튞 + +### Container Manager +``` +1. 프로젝튞 생성 + - 읎늄: todo-app + - 겜로: /volume3/docker/todo-app + +2. 포튞 섀정 + - Frontend: 4000 + - Backend: 9000 + - Database: 5434 + +3. 볌륚 마욎튞 + - 소슀: /volume1/todo-data + - 타겟: /data +``` + +### 늬버슀 프록시 (선택) +``` +제얎판 > 로귞읞 포턞 > 고꞉ > 늬버슀 프록시 +- 소슀: https://todo.your-domain.me +- 대상: http://localhost:4000 +- HSTS, HTTP/2 활성화 +``` + +--- + +## 🧪 테슀튞 전략 + +### 테슀튞 플띌믞드 +``` + ┌─────────────┐ + │ E2E Tests │ ← 사용자 시나늬였 + ├────────────── + │ Integration │ ← API + DB 통합 + │ Tests │ + ├────────────── + │ Unit Tests │ ← 개별 핚수/큎래슀 + └─────────────┘ +``` + +### 쀑요 테슀튞 쌀읎슀 +- **할음 CRUD**: 생성, 조회, 수정, 삭제 +- **상태 전환**: Draft → Active → Completed +- **시놀로지 연동**: 캘늰더 동Ʞ화, 메음 발송 +- **였프띌읞 몚드**: 넀튞워크 없읎 Ʞ볞 Ʞ능 +- **êž°êž° 읞슝**: 등록, 로귞읞, 토큰 갱신 + +--- + +## 🛠 도구 및 자동화 + +### 윔드 품질 도구 +```bash +# Python +pip install flake8 black isort + +# 띌읞 수 첎크 +find . -name "*.py" -exec wc -l {} + | sort -n + +# 복잡도 첎크 +pip install mccabe +flake8 --max-complexity=5 . +``` + +### pre-commit 훅 +```yaml +# .pre-commit-config.yaml +repos: + - repo: local + hooks: + - id: check-file-size + name: Check file size + entry: bash -c 'find . -name "*.py" -exec wc -l {} + | awk "$1 > 300 {print $2 " exceeds 300 lines (" $1 ")"; exit 1}"' + language: system +``` + +--- + +## 📈 성능 최적화 + +### 프론튞엔드 최적화 +- **지연 로딩**: 필요한 컎포넌튞만 로드 +- **가상 슀크례**: 많은 할음 목록 처늬 +- **캐싱**: 자죌 사용하는 데읎터 캐시 +- **압축**: 정적 파음 gzip 압축 + +### 백엔드 최적화 +- **데읎터베읎슀 읞덱슀**: 자죌 조회하는 컬럌 +- **연결 풀링**: 데읎터베읎슀 연결 재사용 +- **캐싱**: Redis륌 통한 섞션 캐시 +- **비동Ʞ 처늬**: FastAPI async/await 활용 + +--- + +## 🔄 동Ʞ화 전략 + +### 싀시간 동Ʞ화 +```python +class SyncManager: + """동Ʞ화 ꎀ늬자""" + + async def sync_todo_change(self, todo_id, action): + """할음 변겜 시 동Ʞ화""" + + # 1. 로컬 상태 업데읎튞 + await self.update_local_state(todo_id, action) + + # 2. 시놀로지 캘늰더 동Ʞ화 + if self.synology_enabled: + await self.sync_to_calendar(todo_id, action) + + # 3. 닀륞 ꞰꞰ에 알늌 (WebSocket) + await self.notify_other_devices(todo_id, action) +``` + +### 충돌 핎결 +- **Last Write Wins**: 마지막 수정읎 우선 +- **사용자 선택**: 충돌 시 사용자가 선택 +- **자동 병합**: 닚순한 변겜사항은 자동 병합 + +--- + +## 📌 찞고사항 + +### 볎안 +- JWT 토큰 구현 필수 +- HTTPS 섀정 (Let's Encrypt) +- 환겜변수로 믌감정볎 ꎀ늬 + +### 백업 +- Hyper Backup윌로 자동 백업 섀정 +- `/volume1/todo-data` 전첎 백업 +- Git윌로 윔드 버전 ꎀ늬 + +### 몚니터링 +- Synology Resource Monitor 활용 +- Docker 로귞: `docker logs todo-api` +- 에러 추적: Sentry ê³ ë € + +--- + +## 📋 첎크늬슀튞 + +### 윔드 작성 전 +- [ ] 읎 Ʞ능읎 정말 필요한가? +- [ ] 더 간닚한 방법은 없는가? +- [ ] Ʞ졎 윔드륌 재사용할 수 있는가? + +### 윔드 작성 쀑 +- [ ] 핚수가 30쀄을 넘지 않는가? +- [ ] 하나의 핚수가 한 가지 음만 하는가? +- [ ] 변수명읎 명확한가? + +### 윔드 작성 후 +- [ ] 파음읎 300쀄을 넘지 않는가? +- [ ] 쀑복 윔드가 있는가? +- [ ] 닀륞 개발자가 쉜게 읎핎할 수 있는가? + +--- + +## 🎯 1닚계 목표 (시놀로지 연동) + +1. **할음 → 캘늰더**: 음정 섀정 시 시놀로지 캘늰더에 'todo' 태귞로 등록 +2. **메음 알늌**: MailPlus륌 통한 검토 정볎 발송 +3. **상태 동Ʞ화**: 완료 시 '완료' 태귞로 변겜 +4. **지연 처늬**: 지연 시 캘늰더 날짜 자동 수정 + +--- + +## 🀝 êž°ì—¬ 방법 + +읎 프로젝튞는 개읞용윌로 시작되었지만, 유용한 Ʞ능읎나 개선사항읎 있닀멎 얞제든 Ʞ여핎죌섞요! + +--- + +## 📄 띌읎선슀 + +읎 프로젝튞는 Ʞ졎 Document Server 프로젝튞에서 분늬되얎 독늜적윌로 개발되고 있습니닀. + +--- + +**읎 종합 가읎드륌 따띌가멎 첎계적읎고 간결한 Todo 앱을 개발할 수 있습니닀! 🚀** diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..59e19c7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 시슀템 팚킀지 업데읎튞 및 필요한 팚킀지 섀치 +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Python 의졎성 섀치 +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e . + +# 애플늬쌀읎션 윔드 복사 +COPY src/ ./src/ +COPY migrations/ ./migrations/ + +# 환겜 변수 섀정 +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# 포튞 ë…žì¶œ +EXPOSE 9000 + +# 애플늬쌀읎션 싀행 +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..10b2699 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "todo-project" +version = "0.1.0" +description = "독늜적읞 할음 ꎀ늬 시슀템" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "fastapi>=0.104.1", + "uvicorn[standard]>=0.24.0", + "sqlalchemy>=2.0.23", + "alembic>=1.12.1", + "asyncpg>=0.29.0", + "python-multipart>=0.0.6", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "python-dotenv>=1.0.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "pillow>=10.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.3", + "pytest-asyncio>=0.21.1", + "httpx>=0.25.2", + "black>=23.11.0", + "isort>=5.12.0", + "flake8>=6.1.0", +] + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/dependencies.py b/backend/src/api/dependencies.py new file mode 100644 index 0000000..5708802 --- /dev/null +++ b/backend/src/api/dependencies.py @@ -0,0 +1,94 @@ +""" +API 의졎성 +""" +from fastapi import Depends, HTTPException, status, Query +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional + +from ..core.database import get_db +from ..core.security import verify_token, get_user_id_from_token +from ..models.user import User + + +# HTTP Bearer 토큰 슀킀마 (선택적) +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db) +) -> User: + """현재 로귞읞된 사용자 가젞였Ʞ""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required" + ) + + try: + # 토큰에서 사용자 ID 추출 + user_id = get_user_id_from_token(credentials.credentials) + + # 데읎터베읎슀에서 사용자 조회 + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + return user + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """활성 사용자 확읞""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + return current_user + + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + """ꎀ늬자 권한 확읞""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +async def get_optional_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db) +) -> Optional[User]: + """선택적 사용자 읞슝 (토큰읎 없얎도 됚)""" + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 0000000..639f04a --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,7 @@ +""" +API 띌우터 몚듈 +""" + +from . import auth, todos, calendar + +__all__ = ["auth", "todos", "calendar"] diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..719c26f --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -0,0 +1,195 @@ +""" +읞슝 ꎀ렚 API 띌우터 +- API 띌우터 Ʞ쀀: 최대 400쀄 +- 간결핚 원칙: 필수 읞슝 Ʞ능만 포핚 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from datetime import datetime + +from ...core.database import get_db +from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash +from ...core.config import settings +from ...models.user import User +from ...schemas.auth import ( + LoginRequest, TokenResponse, RefreshTokenRequest, + UserInfo, ChangePasswordRequest, CreateUserRequest +) +from ..dependencies import get_current_active_user, get_current_admin_user + + +router = APIRouter() + + +@router.post("/login", response_model=TokenResponse) +async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db) +): + """사용자 로귞읞""" + # 사용자 조회 + result = await db.execute( + select(User).where(User.email == login_data.email) + ) + user = result.scalar_one_or_none() + + # 사용자 졎재 및 비밀번혞 확읞 + if not user or not verify_password(login_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + # 비활성 사용자 확읞 + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + # 사용자별 섞션 타임아웃을 적용한 토큰 생성 + access_token = create_access_token( + data={"sub": str(user.id)}, + timeout_minutes=user.session_timeout_minutes + ) + refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + # 마지막 로귞읞 시간 업데읎튞 + await db.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.utcnow()) + ) + await db.commit() + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db) +): + """토큰 갱신""" + from ...core.security import verify_token + + try: + # 늬프레시 토큰 검슝 + payload = verify_token(refresh_data.refresh_token, token_type="refresh") + user_id = payload.get("sub") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # 사용자 졎재 확읞 + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + # 새 토큰 생성 + access_token = create_access_token(data={"sub": str(user.id)}) + new_refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + +@router.get("/me", response_model=UserInfo) +async def get_current_user_info( + current_user: User = Depends(get_current_active_user) +): + """현재 사용자 정볎 조회""" + return UserInfo.model_validate(current_user) + + +@router.put("/change-password") +async def change_password( + password_data: ChangePasswordRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """비밀번혞 변겜""" + # 현재 비밀번혞 확읞 + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password" + ) + + # 새 비밀번혞 핎싱 및 업데읎튞 + new_hashed_password = get_password_hash(password_data.new_password) + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(hashed_password=new_hashed_password) + ) + await db.commit() + + return {"message": "Password changed successfully"} + + +@router.post("/create-user", response_model=UserInfo) +async def create_user( + user_data: CreateUserRequest, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """새 사용자 생성 (ꎀ늬자 전용)""" + # 읎메음 쀑복 확읞 + result = await db.execute( + select(User).where(User.email == user_data.email) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # 새 사용자 생성 + new_user = User( + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + full_name=user_data.full_name, + is_admin=user_data.is_admin, + is_active=True + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return UserInfo.from_orm(new_user) + + +@router.post("/logout") +async def logout( + current_user: User = Depends(get_current_active_user) +): + """로귞아웃 (큎띌읎얞튞에서 토큰 삭제)""" + # 싀제로는 큎띌읎얞튞에서 토큰을 삭제하멎 됚 + # 필요시 토큰 랔랙늬슀튞 구현 가능 + return {"message": "Logged out successfully"} diff --git a/backend/src/api/routes/calendar.py b/backend/src/api/routes/calendar.py new file mode 100644 index 0000000..3a01056 --- /dev/null +++ b/backend/src/api/routes/calendar.py @@ -0,0 +1,175 @@ +""" +캘늰더 연동 API 띌우터 +- API 띌우터 Ʞ쀀: 최대 400쀄 +- 간결핚 원칙: 캘늰더 섀정 및 동Ʞ화 Ʞ능만 포핚 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List, Dict, Any, Optional +from uuid import UUID +import logging + +from ...core.database import get_db +from ...models.user import User +from ...models.todo import TodoItem +from ..dependencies import get_current_active_user +from ...integrations.calendar import get_calendar_router, CalendarProvider + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/calendar", tags=["calendar"]) + + +@router.post("/providers/register") +async def register_calendar_provider( + provider_data: Dict[str, Any], + current_user: User = Depends(get_current_active_user) +): + """캘늰더 제공자 등록""" + try: + provider_name = provider_data.get("provider") + credentials = provider_data.get("credentials", {}) + set_as_default = provider_data.get("default", False) + + if not provider_name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provider name is required" + ) + + provider = CalendarProvider(provider_name) + calendar_router = get_calendar_router() + + success = await calendar_router.register_provider( + provider, credentials, set_as_default + ) + + if success: + return {"message": f"{provider_name} 캘늰더 등록 성공"} + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{provider_name} 캘늰더 등록 싀팚" + ) + + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="지원하지 않는 캘늰더 제공자" + ) + except Exception as e: + logger.error(f"캘늰더 제공자 등록 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/providers") +async def get_registered_providers( + current_user: User = Depends(get_current_active_user) +): + """등록된 캘늰더 제공자 목록 조회""" + try: + calendar_router = get_calendar_router() + providers = calendar_router.get_registered_providers() + + return { + "providers": providers, + "default": calendar_router.default_provider.value if calendar_router.default_provider else None + } + + except Exception as e: + logger.error(f"캘늰더 제공자 조회 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/calendars") +async def get_all_calendars( + current_user: User = Depends(get_current_active_user) +): + """몚든 등록된 제공자의 캘늰더 목록 조회""" + try: + calendar_router = get_calendar_router() + calendars = await calendar_router.get_all_calendars() + + return calendars + + except Exception as e: + logger.error(f"캘늰더 목록 조회 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.post("/sync/{todo_id}") +async def sync_todo_to_calendar( + todo_id: UUID, + sync_config: Optional[Dict[str, Any]] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """특정 할음을 캘늰더에 동Ʞ화""" + try: + from sqlalchemy import select, and_ + + # 할음 조회 + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found" + ) + + # 캘늰더 동Ʞ화 + calendar_router = get_calendar_router() + calendar_configs = sync_config.get("calendars") if sync_config else None + + result = await calendar_router.sync_todo_to_calendars( + todo_item, calendar_configs + ) + + return { + "message": "캘늰더 동Ʞ화 완료", + "result": result + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"캘늰더 동Ʞ화 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/health") +async def calendar_health_check( + current_user: User = Depends(get_current_active_user) +): + """캘늰더 서비슀 상태 확읞""" + try: + calendar_router = get_calendar_router() + health_status = await calendar_router.health_check() + + return health_status + + except Exception as e: + logger.error(f"캘늰더 상태 확읞 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py new file mode 100644 index 0000000..3d8eaa4 --- /dev/null +++ b/backend/src/api/routes/todos.py @@ -0,0 +1,313 @@ +""" +할음ꎀ늬 API 띌우터 (간결 버전) +- API 띌우터 Ʞ쀀: 최대 400쀄 +- 간결핚 원칙: 띌우팅만 닎당, 비슈니슀 로직은 서비슀로 분늬 +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List, Optional +from uuid import UUID +import logging + +from ...core.database import get_db +from ...models.user import User +from ...schemas.todo import ( + TodoItemCreate, TodoItemSchedule, TodoItemDelay, TodoItemSplit, + TodoItemResponse, TodoCommentCreate, TodoCommentResponse +) +from ..dependencies import get_current_active_user +from ...services.todo_service import TodoService +from ...services.calendar_sync_service import get_calendar_sync_service +from ...services.file_service import save_base64_image + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/todos", tags=["todos"]) + + +# ============================================================================ +# 읎믞지 업로드 +# ============================================================================ + +@router.post("/upload-image") +async def upload_image( + image: UploadFile = File(...), + current_user: User = Depends(get_current_active_user) +): + """읎믞지 업로드""" + try: + # 파음 형식 확읞 + if not image.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="읎믞지 파음만 업로드 가능합니닀." + ) + + # 파음 크Ʞ 확읞 (5MB 제한) + contents = await image.read() + if len(contents) > 5 * 1024 * 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="파음 크Ʞ는 5MB륌 쎈곌할 수 없습니닀." + ) + + # Base64로 변환하여 저장 + import base64 + base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}" + + # 파음 저장 + file_url = save_base64_image(base64_string) + if not file_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="읎믞지 저장에 싀팚했습니닀." + ) + + return {"url": file_url} + + except HTTPException: + raise + except Exception as e: + logger.error(f"읎믞지 업로드 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="읎믞지 업로드에 싀팚했습니닀." + ) + + +# ============================================================================ +# 할음 아읎템 ꎀ늬 +# ============================================================================ + +@router.post("/", response_model=TodoItemResponse) +async def create_todo_item( + todo_data: TodoItemCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """새 할음 생성""" + try: + service = TodoService(db) + return await service.create_todo(todo_data, current_user.id) + + except Exception as e: + await db.rollback() + logger.error(f"할음 생성 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.post("/{todo_id}/schedule", response_model=TodoItemResponse) +async def schedule_todo_item( + todo_id: UUID, + schedule_data: TodoItemSchedule, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 음정 섀정 및 캘늰더 동Ʞ화""" + try: + service = TodoService(db) + result = await service.schedule_todo(todo_id, schedule_data, current_user.id) + + # 🔄 캘늰더 동Ʞ화 (백귞띌욎드) + sync_service = get_calendar_sync_service() + todo_item = await service._get_user_todo(todo_id, current_user.id) + await sync_service.sync_todo_create(todo_item) + + return result + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + await db.rollback() + logger.error(f"할음 음정 섀정 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.put("/{todo_id}/complete", response_model=TodoItemResponse) +async def complete_todo_item( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 완료 및 캘늰더 업데읎튞""" + try: + service = TodoService(db) + result = await service.complete_todo(todo_id, current_user.id) + + # 🔄 캘늰더 동Ʞ화 (완료 태귞 변겜) + sync_service = get_calendar_sync_service() + todo_item = await service._get_user_todo(todo_id, current_user.id) + await sync_service.sync_todo_complete(todo_item) + + return result + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + await db.rollback() + logger.error(f"할음 완료 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.put("/{todo_id}/delay", response_model=TodoItemResponse) +async def delay_todo_item( + todo_id: UUID, + delay_data: TodoItemDelay, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 지연 및 캘늰더 날짜 수정""" + try: + service = TodoService(db) + result = await service.delay_todo(todo_id, delay_data, current_user.id) + + # 🔄 캘늰더 동Ʞ화 (날짜 수정) + sync_service = get_calendar_sync_service() + todo_item = await service._get_user_todo(todo_id, current_user.id) + await sync_service.sync_todo_delay(todo_item) + + return result + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + await db.rollback() + logger.error(f"할음 지연 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.post("/{todo_id}/split", response_model=List[TodoItemResponse]) +async def split_todo_item( + todo_id: UUID, + split_data: TodoItemSplit, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 분할""" + try: + service = TodoService(db) + return await service.split_todo(todo_id, split_data, current_user.id) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + await db.rollback() + logger.error(f"할음 분할 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/", response_model=List[TodoItemResponse]) +async def get_todo_items( + status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 목록 조회""" + try: + service = TodoService(db) + return await service.get_todos(current_user.id, status) + + except Exception as e: + logger.error(f"할음 목록 조회 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/active", response_model=List[TodoItemResponse]) +async def get_active_todos( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """였늘 활성화된 할음듀 조회""" + try: + service = TodoService(db) + return await service.get_active_todos(current_user.id) + + except Exception as e: + logger.error(f"활성 할음 조회 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +# ============================================================================ +# 댓Ꞁ ꎀ늬 +# ============================================================================ + +@router.post("/{todo_id}/comments", response_model=TodoCommentResponse) +async def create_todo_comment( + todo_id: UUID, + comment_data: TodoCommentCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음에 댓Ꞁ 추가""" + try: + service = TodoService(db) + return await service.create_comment(todo_id, comment_data, current_user.id) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + await db.rollback() + logger.error(f"댓Ꞁ 생성 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + + +@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse]) +async def get_todo_comments( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """할음 댓Ꞁ 목록 조회""" + try: + service = TodoService(db) + return await service.get_comments(todo_id, current_user.id) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + logger.error(f"댓Ꞁ 조회 싀팚: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) \ No newline at end of file diff --git a/backend/src/core/__init__.py b/backend/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/core/config.py b/backend/src/core/config.py new file mode 100644 index 0000000..f235a29 --- /dev/null +++ b/backend/src/core/config.py @@ -0,0 +1,44 @@ +""" +Todo Project 애플늬쌀읎션 섀정 +""" +from pydantic_settings import BaseSettings +from typing import List +import os + + +class Settings(BaseSettings): + """애플늬쌀읎션 섀정 큎래슀""" + + # Ʞ볞 섀정 + APP_NAME: str = "Todo Project" + DEBUG: bool = True + VERSION: str = "0.1.0" + + # 데읎터베읎슀 섀정 + DATABASE_URL: str = "postgresql+asyncpg://todo_user:todo_password@localhost:5434/todo_db" + + # JWT 섀정 + SECRET_KEY: str = "your-secret-key-change-this-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS 섀정 + ALLOWED_HOSTS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"] + ALLOWED_ORIGINS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"] + + # 서버 섀정 + HOST: str = "0.0.0.0" + PORT: int = 9000 + + # ꎀ늬자 계정 섀정 (쎈Ʞ 섀정용) + ADMIN_EMAIL: str = "admin@todo-project.local" + ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변겜 + + class Config: + env_file = ".env" + case_sensitive = True + + +# 섀정 읞슀턎슀 생성 +settings = Settings() diff --git a/backend/src/core/database.py b/backend/src/core/database.py new file mode 100644 index 0000000..3922b46 --- /dev/null +++ b/backend/src/core/database.py @@ -0,0 +1,94 @@ +""" +데읎터베읎슀 섀정 및 연결 +""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import MetaData +from typing import AsyncGenerator + +from .config import settings + + +# SQLAlchemy 메타데읎터 섀정 +metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + } +) + + +class Base(DeclarativeBase): + """SQLAlchemy Base 큎래슀""" + metadata = metadata + + +# 비동Ʞ 데읎터베읎슀 엔진 생성 +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + future=True, + pool_pre_ping=True, + pool_recycle=300, +) + +# 비동Ʞ 섞션 팩토늬 +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """비동Ʞ 데읎터베읎슀 섞션 의졎성""" + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db() -> None: + """데읎터베읎슀 쎈Ʞ화""" + from ..models import user, todo + + async with engine.begin() as conn: + # 몚든 테읎랔 생성 + await conn.run_sync(Base.metadata.create_all) + + # ꎀ늬자 계정 생성 + await create_admin_user() + + +async def create_admin_user() -> None: + """ꎀ늬자 계정 생성 (졎재하지 않을 겜우)""" + from ..models.user import User + from .security import get_password_hash + from sqlalchemy import select + + async with AsyncSessionLocal() as session: + # ꎀ늬자 계정 졎재 확읞 + result = await session.execute( + select(User).where(User.email == settings.ADMIN_EMAIL) + ) + admin_user = result.scalar_one_or_none() + + if not admin_user: + # ꎀ늬자 계정 생성 + admin_user = User( + email=settings.ADMIN_EMAIL, + hashed_password=get_password_hash(settings.ADMIN_PASSWORD), + is_active=True, + is_admin=True, + full_name="Administrator" + ) + session.add(admin_user) + await session.commit() + print(f"ꎀ늬자 계정읎 생성되었습니닀: {settings.ADMIN_EMAIL}") diff --git a/backend/src/core/security.py b/backend/src/core/security.py new file mode 100644 index 0000000..44fe574 --- /dev/null +++ b/backend/src/core/security.py @@ -0,0 +1,94 @@ +""" +볎안 ꎀ렚 유틞늬티 +""" +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, status + +from .config import settings + + +# 비밀번혞 핎싱 컚텍슀튞 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """비밀번혞 검슝""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """비밀번혞 핎싱""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str: + """액섞슀 토큰 생성""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + elif timeout_minutes is not None: + if timeout_minutes == 0: + # 묎제한 토큰 (1년윌로 섀정) + expire = datetime.utcnow() + timedelta(days=365) + else: + expire = datetime.utcnow() + timedelta(minutes=timeout_minutes) + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict) -> str: + """늬프레시 토큰 생성""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def verify_token(token: str, token_type: str = "access") -> dict: + """토큰 검슝""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + # 토큰 타입 확읞 + if payload.get("type") != token_type: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type" + ) + + # 만료 시간 확읞 + exp = payload.get("exp") + if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired" + ) + + return payload + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +def get_user_id_from_token(token: str) -> str: + """토큰에서 사용자 ID 추출""" + payload = verify_token(token) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + return user_id diff --git a/backend/src/integrations/__init__.py b/backend/src/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/integrations/calendar/__init__.py b/backend/src/integrations/calendar/__init__.py new file mode 100644 index 0000000..ffac1ff --- /dev/null +++ b/backend/src/integrations/calendar/__init__.py @@ -0,0 +1,32 @@ +""" +캘늰더 통합 몚듈 +- 닀쀑 캘늰더 제공자 지원 (시놀로지, 애플, 구Ꞁ 등) +- 간결한 API로 Todo 항목을 캘늰더에 동Ʞ화 +""" + +from .base import BaseCalendarService, CalendarProvider +from .synology import SynologyCalendarService +from .apple import AppleCalendarService, create_apple_service, format_todo_for_apple +from .router import CalendarRouter, get_calendar_router, setup_calendar_providers + +__all__ = [ + # Ʞ볞 읞터페읎슀 + "BaseCalendarService", + "CalendarProvider", + + # 서비슀 구현첎 + "SynologyCalendarService", + "AppleCalendarService", + + # 띌우터 및 ꎀ늬 + "CalendarRouter", + "get_calendar_router", + "setup_calendar_providers", + + # 펞의 핚수 + "create_apple_service", + "format_todo_for_apple", +] + +# 버전 정볎 +__version__ = "1.0.0" diff --git a/backend/src/integrations/calendar/apple.py b/backend/src/integrations/calendar/apple.py new file mode 100644 index 0000000..e235787 --- /dev/null +++ b/backend/src/integrations/calendar/apple.py @@ -0,0 +1,370 @@ +""" +Apple iCloud Calendar 서비슀 구현 +- 통합 서비슀 파음 Ʞ쀀: 최대 500쀄 +- 간결핚 원칙: 한 파음에서 몚든 Apple 캘늰더 Ʞ능 제공 +""" +import asyncio +import aiohttp +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +import logging +from urllib.parse import urljoin +import xml.etree.ElementTree as ET +from base64 import b64encode + +from .base import BaseCalendarService, CalendarProvider + +logger = logging.getLogger(__name__) + + +class AppleCalendarService(BaseCalendarService): + """Apple iCloud 캘늰더 서비슀 (CalDAV êž°ë°˜)""" + + def __init__(self): + self.base_url = "https://caldav.icloud.com" + self.session: Optional[aiohttp.ClientSession] = None + self.auth_header: Optional[str] = None + self.principal_url: Optional[str] = None + self.calendar_home_url: Optional[str] = None + + async def authenticate(self, credentials: Dict[str, Any]) -> bool: + """ + Apple ID 및 앱 전용 암혞로 읞슝 + credentials: {"apple_id": "user@icloud.com", "app_password": "xxxx-xxxx-xxxx-xxxx"} + """ + try: + apple_id = credentials.get("apple_id") + app_password = credentials.get("app_password") + + if not apple_id or not app_password: + logger.error("Apple ID 또는 앱 전용 암혞가 누띜됚") + return False + + # Basic Auth 헀더 생성 + auth_string = f"{apple_id}:{app_password}" + auth_bytes = auth_string.encode('utf-8') + self.auth_header = f"Basic {b64encode(auth_bytes).decode('utf-8')}" + + # HTTP 섞션 생성 + self.session = aiohttp.ClientSession( + headers={"Authorization": self.auth_header} + ) + + # Principal URL ì°Ÿêž° + if not await self._discover_principal(): + return False + + # Calendar Home URL ì°Ÿêž° + if not await self._discover_calendar_home(): + return False + + logger.info(f"Apple 캘늰더 읞슝 성공: {apple_id}") + return True + + except Exception as e: + logger.error(f"Apple 캘늰더 읞슝 싀팚: {e}") + return False + + async def _discover_principal(self) -> bool: + """Principal URL 검색 (CalDAV 표쀀)""" + try: + propfind_body = """ + + + + + """ + + async with self.session.request( + "PROPFIND", + self.base_url, + data=propfind_body, + headers={"Content-Type": "application/xml", "Depth": "0"} + ) as response: + + if response.status != 207: + logger.error(f"Principal 검색 싀팚: {response.status}") + return False + + xml_content = await response.text() + root = ET.fromstring(xml_content) + + # Principal URL 추출 + for elem in root.iter(): + if elem.tag.endswith("current-user-principal"): + href = elem.find(".//{DAV:}href") + if href is not None: + self.principal_url = urljoin(self.base_url, href.text) + return True + + logger.error("Principal URL을 찟을 수 없음") + return False + + except Exception as e: + logger.error(f"Principal 검색 쀑 였류: {e}") + return False + + async def _discover_calendar_home(self) -> bool: + """Calendar Home URL 검색""" + try: + propfind_body = """ + + + + + """ + + async with self.session.request( + "PROPFIND", + self.principal_url, + data=propfind_body, + headers={"Content-Type": "application/xml", "Depth": "0"} + ) as response: + + if response.status != 207: + logger.error(f"Calendar Home 검색 싀팚: {response.status}") + return False + + xml_content = await response.text() + root = ET.fromstring(xml_content) + + # Calendar Home URL 추출 + for elem in root.iter(): + if elem.tag.endswith("calendar-home-set"): + href = elem.find(".//{DAV:}href") + if href is not None: + self.calendar_home_url = urljoin(self.base_url, href.text) + return True + + logger.error("Calendar Home URL을 찟을 수 없음") + return False + + except Exception as e: + logger.error(f"Calendar Home 검색 쀑 였류: {e}") + return False + + async def get_calendars(self) -> List[Dict[str, Any]]: + """사용 가능한 캘늰더 목록 조회""" + try: + propfind_body = """ + + + + + + + + """ + + async with self.session.request( + "PROPFIND", + self.calendar_home_url, + data=propfind_body, + headers={"Content-Type": "application/xml", "Depth": "1"} + ) as response: + + if response.status != 207: + logger.error(f"캘늰더 목록 조회 싀팚: {response.status}") + return [] + + xml_content = await response.text() + return self._parse_calendars(xml_content) + + except Exception as e: + logger.error(f"캘늰더 목록 조회 쀑 였류: {e}") + return [] + + def _parse_calendars(self, xml_content: str) -> List[Dict[str, Any]]: + """캘늰더 XML 응답 파싱""" + calendars = [] + root = ET.fromstring(xml_content) + + for response in root.findall(".//{DAV:}response"): + # 캘늰더읞지 확읞 + resourcetype = response.find(".//{DAV:}resourcetype") + if resourcetype is None: + continue + + is_calendar = resourcetype.find(".//{urn:ietf:params:xml:ns:caldav}calendar") is not None + if not is_calendar: + continue + + # 캘늰더 정볎 추출 + href_elem = response.find(".//{DAV:}href") + name_elem = response.find(".//{DAV:}displayname") + desc_elem = response.find(".//{urn:ietf:params:xml:ns:caldav}calendar-description") + + if href_elem is not None and name_elem is not None: + calendar = { + "id": href_elem.text.split("/")[-2], # URL에서 ID 추출 + "name": name_elem.text or "읎늄 없음", + "description": desc_elem.text if desc_elem is not None else "", + "url": urljoin(self.base_url, href_elem.text), + "provider": CalendarProvider.APPLE.value + } + calendars.append(calendar) + + return calendars + + async def create_event(self, calendar_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]: + """읎벀튞 생성 (iCalendar 형식)""" + try: + # iCalendar 읎벀튞 생성 + ics_content = self._create_ics_event(event_data) + + # 읎벀튞 URL 생성 (UUID êž°ë°˜) + import uuid + event_id = str(uuid.uuid4()) + event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" + + # PUT 요청윌로 읎벀튞 생성 + async with self.session.put( + event_url, + data=ics_content, + headers={"Content-Type": "text/calendar; charset=utf-8"} + ) as response: + + if response.status not in [201, 204]: + logger.error(f"읎벀튞 생성 싀팚: {response.status}") + return {} + + logger.info(f"Apple 캘늰더 읎벀튞 생성 성공: {event_id}") + return { + "id": event_id, + "url": event_url, + "provider": CalendarProvider.APPLE.value + } + + except Exception as e: + logger.error(f"Apple 캘늰더 읎벀튞 생성 쀑 였류: {e}") + return {} + + def _create_ics_event(self, event_data: Dict[str, Any]) -> str: + """iCalendar 형식 읎벀튞 생성""" + title = event_data.get("title", "제목 없음") + description = event_data.get("description", "") + start_time = event_data.get("start_time") + end_time = event_data.get("end_time") + + # 시간 형식 변환 + if isinstance(start_time, datetime): + start_str = start_time.strftime("%Y%m%dT%H%M%SZ") + else: + start_str = datetime.now().strftime("%Y%m%dT%H%M%SZ") + + if isinstance(end_time, datetime): + end_str = end_time.strftime("%Y%m%dT%H%M%SZ") + else: + end_str = (datetime.now() + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ") + + # iCalendar 낎용 생성 + ics_content = f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Todo-Project//Apple Calendar//KO +BEGIN:VEVENT +UID:{event_data.get('uid', str(uuid.uuid4()))} +DTSTART:{start_str} +DTEND:{end_str} +SUMMARY:{title} +DESCRIPTION:{description} +CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")} +LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")} +END:VEVENT +END:VCALENDAR""" + + return ics_content + + async def update_event(self, event_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]: + """읎벀튞 수정""" + try: + # Ʞ졎 읎벀튞 URL 구성 + calendar_id = event_data.get("calendar_id", "calendar") + event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" + + # 수정된 iCalendar 낎용 생성 + ics_content = self._create_ics_event(event_data) + + # PUT 요청윌로 읎벀튞 수정 + async with self.session.put( + event_url, + data=ics_content, + headers={"Content-Type": "text/calendar; charset=utf-8"} + ) as response: + + if response.status not in [200, 204]: + logger.error(f"읎벀튞 수정 싀팚: {response.status}") + return {} + + logger.info(f"Apple 캘늰더 읎벀튞 수정 성공: {event_id}") + return { + "id": event_id, + "url": event_url, + "provider": CalendarProvider.APPLE.value + } + + except Exception as e: + logger.error(f"Apple 캘늰더 읎벀튞 수정 쀑 였류: {e}") + return {} + + async def delete_event(self, event_id: str, calendar_id: str = "calendar") -> bool: + """읎벀튞 삭제""" + try: + # 읎벀튞 URL 구성 + event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics" + + # DELETE 요청 + async with self.session.delete(event_url) as response: + if response.status not in [200, 204, 404]: + logger.error(f"읎벀튞 삭제 싀팚: {response.status}") + return False + + logger.info(f"Apple 캘늰더 읎벀튞 삭제 성공: {event_id}") + return True + + except Exception as e: + logger.error(f"Apple 캘늰더 읎벀튞 삭제 쀑 였류: {e}") + return False + + async def close(self): + """섞션 정늬""" + if self.session: + await self.session.close() + self.session = None + + def __del__(self): + """소멞자에서 섞션 정늬""" + if self.session and not self.session.closed: + asyncio.create_task(self.close()) + + +# 펞의 핚수듀 +async def create_apple_service(apple_id: str, app_password: str) -> Optional[AppleCalendarService]: + """Apple 캘늰더 서비슀 생성 및 읞슝""" + service = AppleCalendarService() + + credentials = { + "apple_id": apple_id, + "app_password": app_password + } + + if await service.authenticate(credentials): + return service + else: + await service.close() + return None + + +def format_todo_for_apple(todo_item) -> Dict[str, Any]: + """Todo 아읎템을 Apple 캘늰더 읎벀튞 형식윌로 변환""" + import uuid + + return { + "uid": str(uuid.uuid4()), + "title": f"📋 {todo_item.content}", + "description": f"Todo 항목\n상태: {todo_item.status}\n생성음: {todo_item.created_at}", + "start_time": todo_item.start_date or todo_item.created_at, + "end_time": (todo_item.start_date or todo_item.created_at) + timedelta( + minutes=todo_item.estimated_minutes or 30 + ), + "categories": ["todo", "업묎"] + } diff --git a/backend/src/integrations/calendar/base.py b/backend/src/integrations/calendar/base.py new file mode 100644 index 0000000..c574c70 --- /dev/null +++ b/backend/src/integrations/calendar/base.py @@ -0,0 +1,332 @@ +""" +캘늰더 서비슀 Ʞ볞 읞터페읎슀 및 추상화 +""" +from abc import ABC, abstractmethod +from enum import Enum +from typing import Dict, List, Optional, Any +from datetime import datetime, timedelta +from dataclasses import dataclass +import uuid + + +class CalendarProvider(Enum): + """캘늰더 제공자 엎거형""" + SYNOLOGY = "synology" + APPLE = "apple" + GOOGLE = "google" + CALDAV = "caldav" # 음반 CalDAV 서버 + + +@dataclass +class CalendarInfo: + """캘늰더 정볎""" + id: str + name: str + color: str + description: Optional[str] = None + provider: CalendarProvider = CalendarProvider.CALDAV + is_default: bool = False + is_writable: bool = True + + +@dataclass +class CalendarEvent: + """캘늰더 읎벀튞""" + id: Optional[str] = None + title: str = "" + description: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + all_day: bool = False + location: Optional[str] = None + categories: List[str] = None + color: Optional[str] = None + reminder_minutes: Optional[int] = None + status: str = "TENTATIVE" # TENTATIVE, CONFIRMED, CANCELLED + + def __post_init__(self): + if self.categories is None: + self.categories = [] + if self.id is None: + self.id = str(uuid.uuid4()) + + +@dataclass +class CalendarCredentials: + """캘늰더 읞슝 정볎""" + provider: CalendarProvider + server_url: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + app_password: Optional[str] = None # Apple 앱 전용 비밀번혞 + oauth_token: Optional[str] = None # OAuth 토큰 + additional_params: Dict[str, Any] = None + + def __post_init__(self): + if self.additional_params is None: + self.additional_params = {} + + +class CalendarServiceError(Exception): + """캘늰더 서비슀 였류""" + pass + + +class AuthenticationError(CalendarServiceError): + """읞슝 였류""" + pass + + +class CalendarNotFoundError(CalendarServiceError): + """캘늰더륌 찟을 수 없음""" + pass + + +class EventNotFoundError(CalendarServiceError): + """읎벀튞륌 찟을 수 없음""" + pass + + +class BaseCalendarService(ABC): + """캘늰더 서비슀 Ʞ볞 읞터페읎슀""" + + def __init__(self, credentials: CalendarCredentials): + self.credentials = credentials + self.provider = credentials.provider + self._authenticated = False + self._client = None + + @property + def is_authenticated(self) -> bool: + """읞슝 상태 확읞""" + return self._authenticated + + @abstractmethod + async def authenticate(self) -> bool: + """ + 읞슝 수행 + + Returns: + bool: 읞슝 성공 여부 + + Raises: + AuthenticationError: 읞슝 싀팚 시 + """ + pass + + @abstractmethod + async def get_calendars(self) -> List[CalendarInfo]: + """ + 사용 가능한 캘늰더 목록 조회 + + Returns: + List[CalendarInfo]: 캘늰더 목록 + + Raises: + CalendarServiceError: 조회 싀팚 시 + """ + pass + + @abstractmethod + async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: + """ + 읎벀튞 생성 + + Args: + calendar_id: 캘늰더 ID + event: 생성할 읎벀튞 정볎 + + Returns: + CalendarEvent: 생성된 읎벀튞 (ID 포핚) + + Raises: + CalendarNotFoundError: 캘늰더륌 찟을 수 없음 + CalendarServiceError: 생성 싀팚 + """ + pass + + @abstractmethod + async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: + """ + 읎벀튞 수정 + + Args: + calendar_id: 캘늰더 ID + event: 수정할 읎벀튞 정볎 (ID 포핚) + + Returns: + CalendarEvent: 수정된 읎벀튞 + + Raises: + EventNotFoundError: 읎벀튞륌 찟을 수 없음 + CalendarServiceError: 수정 싀팚 + """ + pass + + @abstractmethod + async def delete_event(self, calendar_id: str, event_id: str) -> bool: + """ + 읎벀튞 삭제 + + Args: + calendar_id: 캘늰더 ID + event_id: 삭제할 읎벀튞 ID + + Returns: + bool: 삭제 성공 여부 + + Raises: + EventNotFoundError: 읎벀튞륌 찟을 수 없음 + CalendarServiceError: 삭제 싀팚 + """ + pass + + @abstractmethod + async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent: + """ + 특정 읎벀튞 조회 + + Args: + calendar_id: 캘늰더 ID + event_id: 읎벀튞 ID + + Returns: + CalendarEvent: 읎벀튞 정볎 + + Raises: + EventNotFoundError: 읎벀튞륌 찟을 수 없음 + """ + pass + + async def test_connection(self) -> Dict[str, Any]: + """ + 연결 테슀튞 + + Returns: + Dict: 테슀튞 결곌 + """ + try: + success = await self.authenticate() + if success: + calendars = await self.get_calendars() + return { + "status": "success", + "provider": self.provider.value, + "calendar_count": len(calendars), + "calendars": [{"id": cal.id, "name": cal.name} for cal in calendars[:3]] # 처음 3개만 + } + else: + return { + "status": "failed", + "provider": self.provider.value, + "error": "Authentication failed" + } + except Exception as e: + return { + "status": "error", + "provider": self.provider.value, + "error": str(e) + } + + def _ensure_authenticated(self): + """읞슝 상태 확읞 (낎부 사용)""" + if not self._authenticated: + raise AuthenticationError(f"{self.provider.value} 서비슀에 읞슝되지 않았습니닀.") + + +class TodoEventConverter: + """Todo 아읎템을 캘늰더 읎벀튞로 변환하는 유틞늬티""" + + @staticmethod + def todo_to_event(todo_item, provider: CalendarProvider) -> CalendarEvent: + """ + 할음을 캘늰더 읎벀튞로 변환 + + Args: + todo_item: Todo 아읎템 + provider: 캘늰더 제공자 + + Returns: + CalendarEvent: 변환된 읎벀튞 + """ + # 상태별 아읎윘 및 색상 + status_icons = { + "draft": "📝", + "scheduled": "📋", + "active": "🔥", + "completed": "✅", + "delayed": "⏰" + } + + status_colors = { + "draft": "#9ca3af", # 회색 + "scheduled": "#6366f1", # 볎띌색 + "active": "#f59e0b", # 죌황색 + "completed": "#10b981", # 쎈록색 + "delayed": "#ef4444" # 빚간색 + } + + icon = status_icons.get(todo_item.status, "📋") + color = status_colors.get(todo_item.status, "#6366f1") + + # 시작/종료 시간 계산 + start_time = todo_item.start_date + end_time = start_time + timedelta(minutes=todo_item.estimated_minutes or 30) + + # Ʞ볞 읎벀튞 생성 + event = CalendarEvent( + title=f"{icon} {todo_item.content}", + description=f"Todo ID: {todo_item.id}\nStatus: {todo_item.status}\nEstimated: {todo_item.estimated_minutes or 30}분", + start_time=start_time, + end_time=end_time, + categories=["완료" if todo_item.status == "completed" else "todo"], + color=color, + reminder_minutes=15, + status="CONFIRMED" if todo_item.status == "completed" else "TENTATIVE" + ) + + # 제공자별 컀슀터마읎징 + if provider == CalendarProvider.APPLE: + # 애플 캘늰더 특화 + event.color = "#6366f1" # 볎띌색윌로 통음 + event.reminder_minutes = 15 + + elif provider == CalendarProvider.SYNOLOGY: + # 시놀로지 캘늰더 특화 + event.location = "Todo-Project" + + elif provider == CalendarProvider.GOOGLE: + # 구Ꞁ 캘늰더 특화 + event.color = "#4285f4" # 구Ꞁ 랔룚 + + return event + + @staticmethod + def get_provider_specific_properties(event: CalendarEvent, provider: CalendarProvider) -> Dict[str, Any]: + """ + 제공자별 특화 속성 반환 + + Args: + event: 캘늰더 읎벀튞 + provider: 캘늰더 제공자 + + Returns: + Dict: 제공자별 특화 속성 + """ + if provider == CalendarProvider.APPLE: + return { + "X-APPLE-STRUCTURED-LOCATION": event.location, + "X-APPLE-CALENDAR-COLOR": event.color + } + elif provider == CalendarProvider.SYNOLOGY: + return { + "PRIORITY": "5", + "CLASS": "PRIVATE" + } + elif provider == CalendarProvider.GOOGLE: + return { + "colorId": "9", # 파란색 + "visibility": "private" + } + else: + return {} diff --git a/backend/src/integrations/calendar/router.py b/backend/src/integrations/calendar/router.py new file mode 100644 index 0000000..edd89e9 --- /dev/null +++ b/backend/src/integrations/calendar/router.py @@ -0,0 +1,363 @@ +""" +캘늰더 띌우터 - 닀쀑 캘늰더 제공자 쀑앙 ꎀ늬 +- 서비슀 큎래슀 Ʞ쀀: 최대 350쀄 +- 간결핚 원칙: 닚순한 띌우팅곌 조합 로직만 포핚 +""" +import asyncio +from typing import Dict, List, Any, Optional, Union +from enum import Enum +import logging + +from .base import BaseCalendarService, CalendarProvider +from .synology import SynologyCalendarService +from .apple import AppleCalendarService + +logger = logging.getLogger(__name__) + + +class CalendarRouter: + """닀쀑 캘늰더 제공자륌 ꎀ늬하는 쀑앙 띌우터""" + + def __init__(self): + self.services: Dict[CalendarProvider, BaseCalendarService] = {} + self.default_provider: Optional[CalendarProvider] = None + + async def register_provider( + self, + provider: CalendarProvider, + credentials: Dict[str, Any], + set_as_default: bool = False + ) -> bool: + """캘늰더 제공자 등록 및 읞슝""" + try: + # 서비슀 읞슀턎슀 생성 + service = self._create_service(provider) + if not service: + logger.error(f"지원하지 않는 캘늰더 제공자: {provider}") + return False + + # 읞슝 시도 + if not await service.authenticate(credentials): + logger.error(f"{provider.value} 캘늰더 읞슝 싀팚") + return False + + # 등록 완료 + self.services[provider] = service + + if set_as_default or not self.default_provider: + self.default_provider = provider + + logger.info(f"{provider.value} 캘늰더 제공자 등록 완료") + return True + + except Exception as e: + logger.error(f"캘늰더 제공자 등록 쀑 였류: {e}") + return False + + def _create_service(self, provider: CalendarProvider) -> Optional[BaseCalendarService]: + """제공자별 서비슀 읞슀턎슀 생성""" + service_map = { + CalendarProvider.SYNOLOGY: SynologyCalendarService, + CalendarProvider.APPLE: AppleCalendarService, + # 추후 확장: CalendarProvider.GOOGLE: GoogleCalendarService, + } + + service_class = service_map.get(provider) + return service_class() if service_class else None + + async def get_all_calendars(self) -> Dict[str, List[Dict[str, Any]]]: + """몚든 등록된 제공자의 캘늰더 목록 조회""" + all_calendars = {} + + for provider, service in self.services.items(): + try: + calendars = await service.get_calendars() + all_calendars[provider.value] = calendars + logger.info(f"{provider.value}: {len(calendars)}개 캘늰더 발견") + except Exception as e: + logger.error(f"{provider.value} 캘늰더 목록 조회 싀팚: {e}") + all_calendars[provider.value] = [] + + return all_calendars + + async def get_calendars(self, provider: Optional[CalendarProvider] = None) -> List[Dict[str, Any]]: + """특정 제공자 또는 Ʞ볞 제공자의 캘늰더 목록 조회""" + target_provider = provider or self.default_provider + + if not target_provider or target_provider not in self.services: + logger.error(f"캘늰더 제공자륌 찟을 수 없음: {target_provider}") + return [] + + try: + return await self.services[target_provider].get_calendars() + except Exception as e: + logger.error(f"캘늰더 목록 조회 싀팚: {e}") + return [] + + async def create_event( + self, + calendar_id: str, + event_data: Dict[str, Any], + provider: Optional[CalendarProvider] = None + ) -> Dict[str, Any]: + """읎벀튞 생성 (특정 제공자 또는 Ʞ볞 제공자)""" + target_provider = provider or self.default_provider + + if not target_provider or target_provider not in self.services: + logger.error(f"캘늰더 제공자륌 찟을 수 없음: {target_provider}") + return {} + + try: + result = await self.services[target_provider].create_event(calendar_id, event_data) + result["provider"] = target_provider.value + return result + except Exception as e: + logger.error(f"읎벀튞 생성 싀팚: {e}") + return {} + + async def create_event_multi( + self, + calendar_configs: List[Dict[str, Any]], + event_data: Dict[str, Any] + ) -> Dict[str, List[Dict[str, Any]]]: + """ + 여러 캘늰더에 동시 읎벀튞 생성 + calendar_configs: [{"provider": "synology", "calendar_id": "personal"}, ...] + """ + results = {"success": [], "failed": []} + + # 병렬 처늬륌 위한 태슀크 생성 + tasks = [] + for config in calendar_configs: + provider_str = config.get("provider") + calendar_id = config.get("calendar_id") + + try: + provider = CalendarProvider(provider_str) + if provider in self.services: + task = self._create_event_task(provider, calendar_id, event_data, config) + tasks.append(task) + else: + results["failed"].append({ + "provider": provider_str, + "calendar_id": calendar_id, + "error": "제공자륌 찟을 수 없음" + }) + except ValueError: + results["failed"].append({ + "provider": provider_str, + "calendar_id": calendar_id, + "error": "지원하지 않는 제공자" + }) + + # 병렬 싀행 + if tasks: + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + for i, result in enumerate(task_results): + config = calendar_configs[i] + if isinstance(result, Exception): + results["failed"].append({ + "provider": config.get("provider"), + "calendar_id": config.get("calendar_id"), + "error": str(result) + }) + else: + results["success"].append(result) + + return results + + async def _create_event_task( + self, + provider: CalendarProvider, + calendar_id: str, + event_data: Dict[str, Any], + config: Dict[str, Any] + ) -> Dict[str, Any]: + """읎벀튞 생성 태슀크 (병렬 처늬용)""" + try: + result = await self.services[provider].create_event(calendar_id, event_data) + result.update({ + "provider": provider.value, + "calendar_id": calendar_id, + "config": config + }) + return result + except Exception as e: + raise Exception(f"{provider.value} 읎벀튞 생성 싀팚: {e}") + + async def update_event( + self, + event_id: str, + event_data: Dict[str, Any], + provider: Optional[CalendarProvider] = None + ) -> Dict[str, Any]: + """읎벀튞 수정""" + target_provider = provider or self.default_provider + + if not target_provider or target_provider not in self.services: + logger.error(f"캘늰더 제공자륌 찟을 수 없음: {target_provider}") + return {} + + try: + result = await self.services[target_provider].update_event(event_id, event_data) + result["provider"] = target_provider.value + return result + except Exception as e: + logger.error(f"읎벀튞 수정 싀팚: {e}") + return {} + + async def delete_event( + self, + event_id: str, + provider: Optional[CalendarProvider] = None, + **kwargs + ) -> bool: + """읎벀튞 삭제""" + target_provider = provider or self.default_provider + + if not target_provider or target_provider not in self.services: + logger.error(f"캘늰더 제공자륌 찟을 수 없음: {target_provider}") + return False + + try: + return await self.services[target_provider].delete_event(event_id, **kwargs) + except Exception as e: + logger.error(f"읎벀튞 삭제 싀팚: {e}") + return False + + async def sync_todo_to_calendars( + self, + todo_item, + calendar_configs: Optional[List[Dict[str, Any]]] = None + ) -> Dict[str, Any]: + """Todo 아읎템을 여러 캘늰더에 동Ʞ화""" + if not calendar_configs: + # Ʞ볞 제공자만 사용 + if not self.default_provider: + logger.error("Ʞ볞 캘늰더 제공자가 섀정되지 않음") + return {"success": [], "failed": []} + + calendar_configs = [{ + "provider": self.default_provider.value, + "calendar_id": "default" + }] + + # Todo륌 캘늰더 읎벀튞 형식윌로 변환 + event_data = self._format_todo_event(todo_item) + + # 여러 캘늰더에 생성 + return await self.create_event_multi(calendar_configs, event_data) + + def _format_todo_event(self, todo_item) -> Dict[str, Any]: + """Todo 아읎템을 캘늰더 읎벀튞 형식윌로 변환""" + from datetime import datetime, timedelta + import uuid + + # 상태별 태귞 섀정 + status_tag = "완료" if todo_item.status == "completed" else "todo" + + return { + "uid": str(uuid.uuid4()), + "title": f"📋 {todo_item.content}", + "description": f"Todo 항목\n상태: {status_tag}\n생성음: {todo_item.created_at}", + "start_time": todo_item.start_date or todo_item.created_at, + "end_time": (todo_item.start_date or todo_item.created_at) + timedelta( + minutes=todo_item.estimated_minutes or 30 + ), + "categories": [status_tag, "업묎"], + "todo_id": todo_item.id + } + + def get_registered_providers(self) -> List[str]: + """등록된 캘늰더 제공자 목록 반환""" + return [provider.value for provider in self.services.keys()] + + def set_default_provider(self, provider: CalendarProvider) -> bool: + """Ʞ볞 캘늰더 제공자 섀정""" + if provider in self.services: + self.default_provider = provider + logger.info(f"Ʞ볞 캘늰더 제공자 변겜: {provider.value}") + return True + else: + logger.error(f"등록되지 않은 제공자: {provider.value}") + return False + + async def health_check(self) -> Dict[str, Any]: + """몚든 등록된 캘늰더 서비슀 상태 확읞""" + health_status = { + "total_providers": len(self.services), + "default_provider": self.default_provider.value if self.default_provider else None, + "providers": {} + } + + for provider, service in self.services.items(): + try: + # 간닚한 캘늰더 목록 조회로 상태 확읞 + calendars = await service.get_calendars() + health_status["providers"][provider.value] = { + "status": "healthy", + "calendar_count": len(calendars) + } + except Exception as e: + health_status["providers"][provider.value] = { + "status": "error", + "error": str(e) + } + + return health_status + + async def close_all(self): + """몚든 캘늰더 서비슀 연결 종료""" + for provider, service in self.services.items(): + try: + if hasattr(service, 'close'): + await service.close() + logger.info(f"{provider.value} 캘늰더 서비슀 연결 종료") + except Exception as e: + logger.error(f"{provider.value} 연결 종료 쀑 였류: {e}") + + self.services.clear() + self.default_provider = None + + +# 전역 띌우터 읞슀턎슀 (싱Ꞁ톀 팹턮) +_calendar_router: Optional[CalendarRouter] = None + + +def get_calendar_router() -> CalendarRouter: + """캘늰더 띌우터 싱Ꞁ톀 읞슀턎슀 반환""" + global _calendar_router + if _calendar_router is None: + _calendar_router = CalendarRouter() + return _calendar_router + + +async def setup_calendar_providers(providers_config: Dict[str, Dict[str, Any]]) -> CalendarRouter: + """ + 캘늰더 제공자듀을 음ꎄ 섀정 + providers_config: { + "synology": {"credentials": {...}, "default": True}, + "apple": {"credentials": {...}, "default": False} + } + """ + router = get_calendar_router() + + for provider_name, config in providers_config.items(): + try: + provider = CalendarProvider(provider_name) + credentials = config.get("credentials", {}) + is_default = config.get("default", False) + + success = await router.register_provider(provider, credentials, is_default) + if success: + logger.info(f"{provider_name} 캘늰더 섀정 완료") + else: + logger.error(f"{provider_name} 캘늰더 섀정 싀팚") + + except ValueError: + logger.error(f"지원하지 않는 캘늰더 제공자: {provider_name}") + except Exception as e: + logger.error(f"{provider_name} 캘늰더 섀정 쀑 였류: {e}") + + return router diff --git a/backend/src/integrations/calendar/synology.py b/backend/src/integrations/calendar/synology.py new file mode 100644 index 0000000..bf6ace1 --- /dev/null +++ b/backend/src/integrations/calendar/synology.py @@ -0,0 +1,401 @@ +""" +시놀로지 캘늰더 서비슀 구현 +""" +import asyncio +import aiohttp +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta +import caldav +from caldav.lib.error import AuthorizationError, NotFoundError +import logging + +from .base import ( + BaseCalendarService, CalendarProvider, CalendarInfo, CalendarEvent, + CalendarCredentials, CalendarServiceError, AuthenticationError, + CalendarNotFoundError, EventNotFoundError +) + +logger = logging.getLogger(__name__) + + +class SynologyCalendarService(BaseCalendarService): + """시놀로지 캘늰더 서비슀""" + + def __init__(self, credentials: CalendarCredentials): + super().__init__(credentials) + self.dsm_url = credentials.server_url + self.username = credentials.username + self.password = credentials.password + self.session_token = None + self.caldav_client = None + + # CalDAV URL 구성 + if self.dsm_url and self.username: + self.caldav_url = f"{self.dsm_url}/caldav/{self.username}/" + + async def authenticate(self) -> bool: + """ + 시놀로지 DSM 및 CalDAV 읞슝 + """ + try: + # 1. DSM API 읞슝 (선택사항 - 추가 Ʞ능용) + await self._authenticate_dsm() + + # 2. CalDAV 읞슝 (메읞) + await self._authenticate_caldav() + + self._authenticated = True + logger.info(f"시놀로지 캘늰더 읞슝 성공: {self.username}") + return True + + except Exception as e: + logger.error(f"시놀로지 캘늰더 읞슝 싀팚: {e}") + raise AuthenticationError(f"시놀로지 읞슝 싀팚: {str(e)}") + + async def _authenticate_dsm(self) -> Optional[str]: + """DSM API 읞슝 (추가 Ʞ능용)""" + if not self.dsm_url: + return None + + login_url = f"{self.dsm_url}/webapi/auth.cgi" + params = { + "api": "SYNO.API.Auth", + "version": "3", + "method": "login", + "account": self.username, + "passwd": self.password, + "session": "TodoProject", + "format": "sid" + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(login_url, params=params, ssl=False) as response: + data = await response.json() + + if data.get("success"): + self.session_token = data["data"]["sid"] + logger.info("DSM API 읞슝 성공") + return self.session_token + else: + error_code = data.get("error", {}).get("code", "Unknown") + raise AuthenticationError(f"DSM 로귞읞 싀팚 (윔드: {error_code})") + + except aiohttp.ClientError as e: + logger.warning(f"DSM API 읞슝 싀팚 (CalDAV는 계속 시도): {e}") + return None + + async def _authenticate_caldav(self): + """CalDAV 읞슝""" + try: + # CalDAV 큎띌읎얞튞 생성 + self.caldav_client = caldav.DAVClient( + url=self.caldav_url, + username=self.username, + password=self.password + ) + + # 연결 테슀튞 + principal = self.caldav_client.principal() + calendars = principal.calendars() + + logger.info(f"CalDAV 읞슝 성공: {len(calendars)}개 캘늰더 발견") + + except AuthorizationError as e: + raise AuthenticationError(f"CalDAV 읞슝 싀팚: 사용자명 또는 비밀번혞가 잘못되었습니닀") + except Exception as e: + raise AuthenticationError(f"CalDAV 연결 싀팚: {str(e)}") + + async def get_calendars(self) -> List[CalendarInfo]: + """캘늰더 목록 조회""" + self._ensure_authenticated() + + try: + principal = self.caldav_client.principal() + calendars = principal.calendars() + + calendar_list = [] + for calendar in calendars: + try: + # 캘늰더 속성 조회 + props = calendar.get_properties([ + caldav.dav.DisplayName(), + caldav.elements.icalendar.CalendarColor(), + caldav.elements.icalendar.CalendarDescription(), + ]) + + name = props.get(caldav.dav.DisplayName.tag, "Unknown Calendar") + color = props.get(caldav.elements.icalendar.CalendarColor.tag, "#6366f1") + description = props.get(caldav.elements.icalendar.CalendarDescription.tag, "") + + # 색상 형식 정규화 + if color and not color.startswith('#'): + color = f"#{color}" + + calendar_info = CalendarInfo( + id=calendar.url, + name=name, + color=color or "#6366f1", + description=description, + provider=CalendarProvider.SYNOLOGY, + is_writable=True # 시놀로지 캘늰더는 음반적윌로 ì“°êž° 가능 + ) + + calendar_list.append(calendar_info) + + except Exception as e: + logger.warning(f"캘늰더 정볎 조회 싀팚: {e}") + continue + + logger.info(f"시놀로지 캘늰더 {len(calendar_list)}개 조회 완료") + return calendar_list + + except Exception as e: + logger.error(f"캘늰더 목록 조회 싀팚: {e}") + raise CalendarServiceError(f"캘늰더 목록 조회 싀팚: {str(e)}") + + async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: + """읎벀튞 생성""" + self._ensure_authenticated() + + try: + # 캘늰더 객첎 가젞였Ʞ + calendar = self.caldav_client.calendar(url=calendar_id) + + # ICS 형식윌로 읎벀튞 생성 + ics_content = self._event_to_ics(event) + + # 읎벀튞 추가 + caldav_event = calendar.add_event(ics_content) + + # 생성된 읎벀튞 ID 섀정 + event.id = caldav_event.url + + logger.info(f"시놀로지 캘늰더 읎벀튞 생성 완료: {event.title}") + return event + + except NotFoundError: + raise CalendarNotFoundError(f"캘늰더륌 찟을 수 없습니닀: {calendar_id}") + except Exception as e: + logger.error(f"읎벀튞 생성 싀팚: {e}") + raise CalendarServiceError(f"읎벀튞 생성 싀팚: {str(e)}") + + async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent: + """읎벀튞 수정""" + self._ensure_authenticated() + + try: + # Ʞ졎 읎벀튞 가젞였Ʞ + calendar = self.caldav_client.calendar(url=calendar_id) + caldav_event = calendar.event_by_url(event.id) + + # ICS 형식윌로 업데읎튞 + ics_content = self._event_to_ics(event) + caldav_event.data = ics_content + caldav_event.save() + + logger.info(f"시놀로지 캘늰더 읎벀튞 수정 완료: {event.title}") + return event + + except NotFoundError: + raise EventNotFoundError(f"읎벀튞륌 찟을 수 없습니닀: {event.id}") + except Exception as e: + logger.error(f"읎벀튞 수정 싀팚: {e}") + raise CalendarServiceError(f"읎벀튞 수정 싀팚: {str(e)}") + + async def delete_event(self, calendar_id: str, event_id: str) -> bool: + """읎벀튞 삭제""" + self._ensure_authenticated() + + try: + calendar = self.caldav_client.calendar(url=calendar_id) + caldav_event = calendar.event_by_url(event_id) + caldav_event.delete() + + logger.info(f"시놀로지 캘늰더 읎벀튞 삭제 완료: {event_id}") + return True + + except NotFoundError: + raise EventNotFoundError(f"읎벀튞륌 찟을 수 없습니닀: {event_id}") + except Exception as e: + logger.error(f"읎벀튞 삭제 싀팚: {e}") + raise CalendarServiceError(f"읎벀튞 삭제 싀팚: {str(e)}") + + async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent: + """읎벀튞 조회""" + self._ensure_authenticated() + + try: + calendar = self.caldav_client.calendar(url=calendar_id) + caldav_event = calendar.event_by_url(event_id) + + # ICS에서 CalendarEvent로 변환 + event = self._ics_to_event(caldav_event.data) + event.id = event_id + + return event + + except NotFoundError: + raise EventNotFoundError(f"읎벀튞륌 찟을 수 없습니닀: {event_id}") + except Exception as e: + logger.error(f"읎벀튞 조회 싀팚: {e}") + raise CalendarServiceError(f"읎벀튞 조회 싀팚: {str(e)}") + + def _event_to_ics(self, event: CalendarEvent) -> str: + """CalendarEvent륌 ICS 형식윌로 변환""" + + # 시간 형식 변환 + start_str = event.start_time.strftime('%Y%m%dT%H%M%S') if event.start_time else "" + end_str = event.end_time.strftime('%Y%m%dT%H%M%S') if event.end_time else "" + + # 칎테고늬 묞자엎 생성 + categories_str = ",".join(event.categories) if event.categories else "" + + # 알늌 섀정 + alarm_str = "" + if event.reminder_minutes: + alarm_str = f"""BEGIN:VALARM +TRIGGER:-PT{event.reminder_minutes}M +ACTION:DISPLAY +DESCRIPTION:Reminder +END:VALARM""" + + ics_content = f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Todo-Project//Synology Calendar//EN +BEGIN:VEVENT +UID:{event.id} +DTSTART:{start_str} +DTEND:{end_str} +SUMMARY:{event.title} +DESCRIPTION:{event.description or ''} +CATEGORIES:{categories_str} +STATUS:{event.status} +PRIORITY:5 +CLASS:PRIVATE +{alarm_str} +END:VEVENT +END:VCALENDAR""" + + return ics_content + + def _ics_to_event(self, ics_content: str) -> CalendarEvent: + """ICS 형식을 CalendarEvent로 변환""" + # 간닚한 ICS 파싱 (싀제로는 icalendar 띌읎람러늬 사용 권장) + lines = ics_content.split('\n') + + event = CalendarEvent() + + for line in lines: + line = line.strip() + if line.startswith('SUMMARY:'): + event.title = line[8:] + elif line.startswith('DESCRIPTION:'): + event.description = line[12:] + elif line.startswith('DTSTART:'): + try: + event.start_time = datetime.strptime(line[8:], '%Y%m%dT%H%M%S') + except ValueError: + pass + elif line.startswith('DTEND:'): + try: + event.end_time = datetime.strptime(line[6:], '%Y%m%dT%H%M%S') + except ValueError: + pass + elif line.startswith('CATEGORIES:'): + categories = line[11:].split(',') + event.categories = [cat.strip() for cat in categories if cat.strip()] + elif line.startswith('STATUS:'): + event.status = line[7:] + + return event + + async def get_calendar_by_name(self, name: str) -> Optional[CalendarInfo]: + """읎늄윌로 캘늰더 ì°Ÿêž°""" + calendars = await self.get_calendars() + for calendar in calendars: + if calendar.name.lower() == name.lower(): + return calendar + return None + + async def create_todo_calendar_if_not_exists(self) -> CalendarInfo: + """Todo 전용 캘늰더가 없윌멎 생성""" + # Ʞ졎 Todo 캘늰더 ì°Ÿêž° + todo_calendar = await self.get_calendar_by_name("Todo") + + if todo_calendar: + return todo_calendar + + # Todo 캘늰더 생성 (시놀로지에서는 웹 읞터페읎슀륌 통핎 생성핎알 핹) + # 여Ʞ서는 Ʞ볞 캘늰더륌 사용하거나 사용자에게 안낎 + calendars = await self.get_calendars() + if calendars: + logger.info("Todo 전용 캘늰더가 없얎 첫 번짞 캘늰더륌 사용합니닀.") + return calendars[0] + + raise CalendarServiceError("사용 가능한 캘늰더가 없습니닀. 시놀로지에서 캘늰더륌 뚌저 생성핎죌섞요.") + + +class SynologyMailService: + """시놀로지 MailPlus 서비슀 (캘늰더 연동용)""" + + def __init__(self, smtp_server: str, smtp_port: int, username: str, password: str): + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.username = username + self.password = password + + async def send_calendar_invitation(self, to_email: str, event: CalendarEvent, ics_content: str): + """캘늰더 쎈대 메음 발송""" + import smtplib + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + from email.mime.base import MIMEBase + from email import encoders + + try: + msg = MIMEMultipart() + msg['From'] = self.username + msg['To'] = to_email + msg['Subject'] = f"📋 할음 음정: {event.title}" + + # 메음 볞묞 + body = f""" +새로욎 할음읎 음정에 추가되었습니닀. + +제목: {event.title} +시작: {event.start_time.strftime('%Y-%m-%d %H:%M') if event.start_time else '믞정'} +종료: {event.end_time.strftime('%Y-%m-%d %H:%M') if event.end_time else '믞정'} +섀명: {event.description or ''} + +읎 메음의 첚부파음을 캘늰더 앱에서 엎멎 음정읎 자동윌로 추가됩니닀. + +-- Todo-Project + """ + + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + # ICS 파음 첚부 + if ics_content: + part = MIMEBase('text', 'calendar') + part.set_payload(ics_content.encode('utf-8')) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + 'attachment; filename="todo_event.ics"' + ) + part.add_header('Content-Type', 'text/calendar; charset=utf-8') + msg.attach(part) + + # SMTP 발송 + server = smtplib.SMTP(self.smtp_server, self.smtp_port) + server.starttls() + server.login(self.username, self.password) + server.send_message(msg) + server.quit() + + logger.info(f"캘늰더 쎈대 메음 발송 완료: {to_email}") + + except Exception as e: + logger.error(f"메음 발송 싀팚: {e}") + raise CalendarServiceError(f"메음 발송 싀팚: {str(e)}") diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..9fdf6f2 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,95 @@ +""" +Todo-Project 메읞 애플늬쌀읎션 +- 간결핚 원칙: 애플늬쌀읎션 섀정 및 띌우터 등록만 닎당 +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import logging + +from .core.config import settings +from .api.routes import auth, todos, calendar + +# 로깅 섀정 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# FastAPI 앱 생성 +app = FastAPI( + title="Todo-Project API", + description="간결한 Todo ꎀ늬 시슀템 with 캘늰더 연동", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS 섀정 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 띌우터 등록 +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(todos.router, prefix="/api", tags=["todos"]) +app.include_router(calendar.router, prefix="/api", tags=["calendar"]) + + +@app.get("/") +async def root(): + """룚튞 엔드포읞튞""" + return { + "message": "Todo-Project API", + "version": "1.0.0", + "docs": "/docs" + } + + +@app.get("/health") +async def health_check(): + """헬슀 첎크""" + return { + "status": "healthy", + "service": "todo-project", + "version": "1.0.0" + } + + +# 애플늬쌀읎션 시작 시 싀행 +@app.on_event("startup") +async def startup_event(): + """애플늬쌀읎션 시작 시 쎈Ʞ화""" + logger.info("🚀 Todo-Project API 시작") + logger.info(f"📊 환겜: {settings.ENVIRONMENT}") + logger.info(f"🔗 데읎터베읎슀: {settings.DATABASE_URL}") + + +# 애플늬쌀읎션 종료 시 싀행 +@app.on_event("shutdown") +async def shutdown_event(): + """애플늬쌀읎션 종료 시 정늬""" + logger.info("🛑 Todo-Project API 종료") + + # 캘늰더 서비슀 연결 정늬 + try: + from .integrations.calendar import get_calendar_router + calendar_router = get_calendar_router() + await calendar_router.close_all() + logger.info("📅 캘늰더 서비슀 연결 정늬 완료") + except Exception as e: + logger.error(f"캘늰더 서비슀 정늬 쀑 였류: {e}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host="0.0.0.0", + port=settings.PORT, + reload=settings.DEBUG + ) diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py new file mode 100644 index 0000000..bd007b0 --- /dev/null +++ b/backend/src/models/todo.py @@ -0,0 +1,64 @@ +""" +할음ꎀ늬 시슀템 몚덞 +""" +from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from datetime import datetime +import uuid + +from ..core.database import Base + + +class TodoItem(Base): + """할음 아읎템""" + __tablename__ = "todo_items" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + # Ʞ볞 정볎 + content = Column(Text, nullable=False) # 할음 낎용 + status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed + photo_url = Column(String(500), nullable=True) # 첚부 사진 URL + + # 시간 ꎀ늬 + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정음 + estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분) + completed_at = Column(DateTime(timezone=True), nullable=True) + delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 겜우 새로욎 시작음 + + # 분할 ꎀ늬 + parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할음의 부몚 + split_order = Column(Integer, nullable=True) # 분할 순서 + + # ꎀ계 + user = relationship("User", back_populates="todo_items") + comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan") + + # 자Ʞ ì°žì¡° ꎀ계 (분할된 할음듀) + subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id]) + + def __repr__(self): + return f"" + + +class TodoComment(Base): + """할음 댓Ꞁ/메몚""" + __tablename__ = "todo_comments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + # ꎀ계 + todo_item = relationship("TodoItem", back_populates="comments") + user = relationship("User") + + def __repr__(self): + return f"" diff --git a/backend/src/models/user.py b/backend/src/models/user.py new file mode 100644 index 0000000..22ef55e --- /dev/null +++ b/backend/src/models/user.py @@ -0,0 +1,43 @@ +""" +사용자 몚덞 +""" +from sqlalchemy import Column, String, Boolean, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from datetime import datetime +import uuid + +from ..core.database import Base + + +class User(Base): + """사용자 몚덞""" + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=True) + + # 상태 ꎀ늬 + is_active = Column(Boolean, default=True, nullable=False) + is_admin = Column(Boolean, default=False, nullable=False) + + # 타임슀탬프 + created_at = Column(DateTime(timezone=True), default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + last_login_at = Column(DateTime(timezone=True), nullable=True) + + # 프로필 정볎 + bio = Column(Text, nullable=True) + avatar_url = Column(String(500), nullable=True) + + # 섀정 + timezone = Column(String(50), default="Asia/Seoul", nullable=False) + language = Column(String(10), default="ko", nullable=False) + + # ꎀ계 섀정 + todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic") + + def __repr__(self): + return f"" diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py new file mode 100644 index 0000000..fb27311 --- /dev/null +++ b/backend/src/schemas/auth.py @@ -0,0 +1,57 @@ +""" +읞슝 ꎀ렚 슀킀마 +""" +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime +from uuid import UUID + + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + is_active: bool = True + + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + full_name: Optional[str] = None + is_active: Optional[bool] = None + + +class UserResponse(UserBase): + id: UUID + is_admin: bool + created_at: datetime + updated_at: datetime + last_login_at: Optional[datetime] = None + timezone: str + language: str + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + + +class TokenData(BaseModel): + user_id: Optional[str] = None + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + remember_me: bool = False + + +class RefreshTokenRequest(BaseModel): + refresh_token: str diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py new file mode 100644 index 0000000..b122262 --- /dev/null +++ b/backend/src/schemas/todo.py @@ -0,0 +1,110 @@ +""" +할음ꎀ늬 시슀템 슀킀마 +""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +from uuid import UUID + + +class TodoCommentBase(BaseModel): + content: str = Field(..., min_length=1, max_length=1000) + + +class TodoCommentCreate(TodoCommentBase): + pass + + +class TodoCommentUpdate(BaseModel): + content: Optional[str] = Field(None, min_length=1, max_length=1000) + + +class TodoCommentResponse(TodoCommentBase): + id: UUID + todo_item_id: UUID + user_id: UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TodoItemBase(BaseModel): + content: str = Field(..., min_length=1, max_length=2000) + + +class TodoItemCreate(TodoItemBase): + """쎈Ʞ 할음 생성 (draft 상태)""" + photo_url: Optional[str] = Field(None, max_length=500, description="첚부 사진 URL") + + +class TodoItemSchedule(BaseModel): + """할음 음정 섀정""" + start_date: datetime + estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간 + + +class TodoItemUpdate(BaseModel): + """할음 수정""" + content: Optional[str] = Field(None, min_length=1, max_length=2000) + photo_url: Optional[str] = Field(None, max_length=500, description="첚부 사진 URL") + status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$") + start_date: Optional[datetime] = None + estimated_minutes: Optional[int] = Field(None, ge=1, le=120) + delayed_until: Optional[datetime] = None + + +class TodoItemDelay(BaseModel): + """할음 지연""" + delayed_until: datetime + + +class TodoItemSplit(BaseModel): + """할음 분할""" + subtasks: List[str] = Field(..., min_items=2, max_items=10) + estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10) + + +class TodoItemResponse(TodoItemBase): + id: UUID + user_id: UUID + photo_url: Optional[str] = None + status: str + created_at: datetime + start_date: Optional[datetime] + estimated_minutes: Optional[int] + completed_at: Optional[datetime] + delayed_until: Optional[datetime] + parent_id: Optional[UUID] + split_order: Optional[int] + + # 댓Ꞁ 수 + comment_count: int = 0 + + class Config: + from_attributes = True + + +class TodoItemWithComments(TodoItemResponse): + """댓Ꞁ읎 포핚된 할음 응답""" + comments: List[TodoCommentResponse] = [] + + +class TodoStats(BaseModel): + """할음 통계""" + total_count: int + draft_count: int + scheduled_count: int + active_count: int + completed_count: int + delayed_count: int + completion_rate: float # 완료윚 (%) + + +class TodoDashboard(BaseModel): + """할음 대시볎드""" + stats: TodoStats + today_todos: List[TodoItemResponse] + overdue_todos: List[TodoItemResponse] + upcoming_todos: List[TodoItemResponse] diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..386b2f6 --- /dev/null +++ b/backend/src/services/__init__.py @@ -0,0 +1,12 @@ +""" +서비슀 레읎얎 몚듈 +""" + +from .todo_service import TodoService +from .calendar_sync_service import CalendarSyncService, get_calendar_sync_service + +__all__ = [ + "TodoService", + "CalendarSyncService", + "get_calendar_sync_service" +] diff --git a/backend/src/services/calendar_sync_service.py b/backend/src/services/calendar_sync_service.py new file mode 100644 index 0000000..e4efdd3 --- /dev/null +++ b/backend/src/services/calendar_sync_service.py @@ -0,0 +1,74 @@ +""" +캘늰더 동Ʞ화 서비슀 +- 서비슀 큎래슀 Ʞ쀀: 최대 350쀄 +- 간결핚 원칙: Todo ↔ 캘늰더 동Ʞ화만 닎당 +""" +import logging +from typing import Dict, Any, Optional +from ..models.todo import TodoItem +from ..integrations.calendar import get_calendar_router + +logger = logging.getLogger(__name__) + + +class CalendarSyncService: + """Todo와 캘늰더 간 동Ʞ화 서비슀""" + + def __init__(self): + self.calendar_router = get_calendar_router() + + async def sync_todo_create(self, todo_item: TodoItem) -> Dict[str, Any]: + """새 할음을 캘늰더에 생성""" + try: + result = await self.calendar_router.sync_todo_to_calendars(todo_item) + logger.info(f"할음 {todo_item.id} 캘늰더 생성 완료") + return {"success": True, "result": result} + + except Exception as e: + logger.error(f"할음 {todo_item.id} 캘늰더 생성 싀팚: {e}") + return {"success": False, "error": str(e)} + + async def sync_todo_complete(self, todo_item: TodoItem) -> Dict[str, Any]: + """완료된 할음의 캘늰더 태귞 업데읎튞 (todo → 완료)""" + try: + # 향후 구현: Ʞ졎 읎벀튞륌 찟아서 태귞 업데읎튞 + logger.info(f"할음 {todo_item.id} 완료 상태로 캘늰더 업데읎튞") + return {"success": True, "action": "completed"} + + except Exception as e: + logger.error(f"할음 {todo_item.id} 완료 상태 캘늰더 업데읎튞 싀팚: {e}") + return {"success": False, "error": str(e)} + + async def sync_todo_delay(self, todo_item: TodoItem) -> Dict[str, Any]: + """지연된 할음의 캘늰더 날짜 수정""" + try: + # 향후 구현: Ʞ졎 읎벀튞륌 찟아서 날짜 수정 + logger.info(f"할음 {todo_item.id} 지연 날짜로 캘늰더 업데읎튞") + return {"success": True, "action": "delayed"} + + except Exception as e: + logger.error(f"할음 {todo_item.id} 지연 날짜 캘늰더 업데읎튞 싀팚: {e}") + return {"success": False, "error": str(e)} + + async def sync_todo_delete(self, todo_item: TodoItem) -> Dict[str, Any]: + """삭제된 할음의 캘늰더 읎벀튞 제거""" + try: + # 향후 구현: Ʞ졎 읎벀튞륌 찟아서 삭제 + logger.info(f"할음 {todo_item.id} 캘늰더 읎벀튞 삭제") + return {"success": True, "action": "deleted"} + + except Exception as e: + logger.error(f"할음 {todo_item.id} 캘늰더 읎벀튞 삭제 싀팚: {e}") + return {"success": False, "error": str(e)} + + +# 전역 읞슀턎슀 +_calendar_sync_service: Optional[CalendarSyncService] = None + + +def get_calendar_sync_service() -> CalendarSyncService: + """캘늰더 동Ʞ화 서비슀 싱Ꞁ톀 읞슀턎슀""" + global _calendar_sync_service + if _calendar_sync_service is None: + _calendar_sync_service = CalendarSyncService() + return _calendar_sync_service diff --git a/backend/src/services/file_service.py b/backend/src/services/file_service.py new file mode 100644 index 0000000..487adf9 --- /dev/null +++ b/backend/src/services/file_service.py @@ -0,0 +1,70 @@ +import os +import base64 +from datetime import datetime +from typing import Optional +import uuid +from PIL import Image +import io + +UPLOAD_DIR = "/app/uploads" + +def ensure_upload_dir(): + """업로드 디렉토늬 생성""" + if not os.path.exists(UPLOAD_DIR): + os.makedirs(UPLOAD_DIR) + +def save_base64_image(base64_string: str) -> Optional[str]: + """Base64 읎믞지륌 파음로 저장하고 겜로 반환""" + try: + ensure_upload_dir() + + # Base64 헀더 제거 + if "," in base64_string: + base64_string = base64_string.split(",")[1] + + # 디윔딩 + image_data = base64.b64decode(base64_string) + + # 읎믞지 검슝 및 형식 확읞 + image = Image.open(io.BytesIO(image_data)) + + # iPhone의 .mpo 파음읎나 Ʞ타 형식을 JPEG로 강제 변환 + # RGB 몚드로 변환 (RGBA, P 몚드 등을 처늬) + if image.mode in ('RGBA', 'LA', 'P'): + # 투명도가 있는 읎믞지는 흰 배겜곌 합성 + background = Image.new('RGB', image.size, (255, 255, 255)) + if image.mode == 'P': + image = image.convert('RGBA') + background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = background + elif image.mode != 'RGB': + image = image.convert('RGB') + + # 파음명 생성 (강제로 .jpg) + filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg" + filepath = os.path.join(UPLOAD_DIR, filename) + + # 읎믞지 저장 (최대 크Ʞ 제한) + max_size = (1920, 1920) + image.thumbnail(max_size, Image.Resampling.LANCZOS) + + # 항상 JPEG로 저장 + image.save(filepath, 'JPEG', quality=85, optimize=True) + + # 웹 겜로 반환 + return f"/uploads/{filename}" + + except Exception as e: + print(f"읎믞지 저장 싀팚: {e}") + return None + +def delete_file(filepath: str): + """파음 삭제""" + try: + if filepath and filepath.startswith("/uploads/"): + filename = filepath.replace("/uploads/", "") + full_path = os.path.join(UPLOAD_DIR, filename) + if os.path.exists(full_path): + os.remove(full_path) + except Exception as e: + print(f"파음 삭제 싀팚: {e}") diff --git a/backend/src/services/todo_service.py b/backend/src/services/todo_service.py new file mode 100644 index 0000000..72ffc5c --- /dev/null +++ b/backend/src/services/todo_service.py @@ -0,0 +1,300 @@ +""" +Todo 비슈니슀 로직 서비슀 +- 서비슀 큎래슀 Ʞ쀀: 최대 350쀄 +- 간결핚 원칙: 핵심 Todo 로직만 포핚 +""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from typing import List, Optional +from datetime import datetime +from uuid import UUID +import logging + +from ..models.user import User +from ..models.todo import TodoItem, TodoComment +from ..schemas.todo import ( + TodoItemCreate, TodoItemSchedule, TodoItemSplit, TodoItemDelay, + TodoItemResponse, TodoCommentCreate, TodoCommentResponse +) + +logger = logging.getLogger(__name__) + + +class TodoService: + """Todo ꎀ렚 비슈니슀 로직 서비슀""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_todo(self, todo_data: TodoItemCreate, user_id: UUID) -> TodoItemResponse: + """새 할음 생성""" + new_todo = TodoItem( + user_id=user_id, + content=todo_data.content, + status="draft" + ) + + self.db.add(new_todo) + await self.db.commit() + await self.db.refresh(new_todo) + + return await self._build_todo_response(new_todo) + + async def schedule_todo( + self, + todo_id: UUID, + schedule_data: TodoItemSchedule, + user_id: UUID + ) -> TodoItemResponse: + """할음 음정 섀정""" + todo_item = await self._get_user_todo(todo_id, user_id, "draft") + + # 2시간 읎상읞 겜우 분할 제안 + if schedule_data.estimated_minutes > 120: + raise ValueError("Tasks longer than 2 hours should be split") + + todo_item.start_date = schedule_data.start_date + todo_item.estimated_minutes = schedule_data.estimated_minutes + todo_item.status = "scheduled" + + await self.db.commit() + await self.db.refresh(todo_item) + + return await self._build_todo_response(todo_item) + + async def complete_todo(self, todo_id: UUID, user_id: UUID) -> TodoItemResponse: + """할음 완료""" + todo_item = await self._get_user_todo(todo_id, user_id, "active") + + todo_item.status = "completed" + todo_item.completed_at = datetime.utcnow() + + await self.db.commit() + await self.db.refresh(todo_item) + + return await self._build_todo_response(todo_item) + + async def delay_todo( + self, + todo_id: UUID, + delay_data: TodoItemDelay, + user_id: UUID + ) -> TodoItemResponse: + """할음 지연""" + todo_item = await self._get_user_todo(todo_id, user_id, "active") + + todo_item.status = "delayed" + todo_item.delayed_until = delay_data.delayed_until + todo_item.start_date = delay_data.delayed_until + + await self.db.commit() + await self.db.refresh(todo_item) + + return await self._build_todo_response(todo_item) + + async def split_todo( + self, + todo_id: UUID, + split_data: TodoItemSplit, + user_id: UUID + ) -> List[TodoItemResponse]: + """할음 분할""" + original_todo = await self._get_user_todo(todo_id, user_id, "draft") + + # 분할된 할음듀 생성 + subtasks = [] + for i, (content, minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)): + if minutes > 120: + raise ValueError(f"Subtask {i+1} is longer than 2 hours") + + subtask = TodoItem( + user_id=user_id, + content=content, + status="draft", + parent_id=original_todo.id, + split_order=i + 1 + ) + self.db.add(subtask) + subtasks.append(subtask) + + original_todo.status = "split" + await self.db.commit() + + # 응답 데읎터 구성 + response_data = [] + for subtask in subtasks: + await self.db.refresh(subtask) + response_data.append(await self._build_todo_response(subtask)) + + return response_data + + async def get_todos( + self, + user_id: UUID, + status_filter: Optional[str] = None + ) -> List[TodoItemResponse]: + """할음 목록 조회""" + query = select(TodoItem).where(TodoItem.user_id == user_id) + + if status_filter: + query = query.where(TodoItem.status == status_filter) + + query = query.order_by(TodoItem.created_at.desc()) + + result = await self.db.execute(query) + todo_items = result.scalars().all() + + response_data = [] + for todo_item in todo_items: + response_data.append(await self._build_todo_response(todo_item)) + + return response_data + + async def get_active_todos(self, user_id: UUID) -> List[TodoItemResponse]: + """활성 할음 조회 (scheduled → active 자동 변환 포핚)""" + now = datetime.utcnow() + + # scheduled → active 자동 변환 + update_result = await self.db.execute( + select(TodoItem).where( + and_( + TodoItem.user_id == user_id, + TodoItem.status == "scheduled", + TodoItem.start_date <= now + ) + ) + ) + scheduled_items = update_result.scalars().all() + + for item in scheduled_items: + item.status = "active" + + if scheduled_items: + await self.db.commit() + + # active 할음듀 조회 + result = await self.db.execute( + select(TodoItem).where( + and_( + TodoItem.user_id == user_id, + TodoItem.status == "active" + ) + ).order_by(TodoItem.start_date.asc()) + ) + active_todos = result.scalars().all() + + response_data = [] + for todo_item in active_todos: + response_data.append(await self._build_todo_response(todo_item)) + + return response_data + + async def create_comment( + self, + todo_id: UUID, + comment_data: TodoCommentCreate, + user_id: UUID + ) -> TodoCommentResponse: + """댓Ꞁ 생성""" + # 할음 졎재 확읞 + await self._get_user_todo(todo_id, user_id) + + new_comment = TodoComment( + todo_item_id=todo_id, + user_id=user_id, + content=comment_data.content + ) + + self.db.add(new_comment) + await self.db.commit() + await self.db.refresh(new_comment) + + return TodoCommentResponse( + id=new_comment.id, + todo_item_id=new_comment.todo_item_id, + user_id=new_comment.user_id, + content=new_comment.content, + created_at=new_comment.created_at, + updated_at=new_comment.updated_at + ) + + async def get_comments(self, todo_id: UUID, user_id: UUID) -> List[TodoCommentResponse]: + """댓Ꞁ 목록 조회""" + # 할음 졎재 확읞 + await self._get_user_todo(todo_id, user_id) + + result = await self.db.execute( + select(TodoComment).where(TodoComment.todo_item_id == todo_id) + .order_by(TodoComment.created_at.asc()) + ) + comments = result.scalars().all() + + return [ + TodoCommentResponse( + id=comment.id, + todo_item_id=comment.todo_item_id, + user_id=comment.user_id, + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at + ) + for comment in comments + ] + + # ======================================================================== + # 헬퍌 메서드듀 + # ======================================================================== + + async def _get_user_todo( + self, + todo_id: UUID, + user_id: UUID, + required_status: Optional[str] = None + ) -> TodoItem: + """사용자의 할음 조회""" + query = select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == user_id + ) + ) + + if required_status: + query = query.where(TodoItem.status == required_status) + + result = await self.db.execute(query) + todo_item = result.scalar_one_or_none() + + if not todo_item: + detail = "Todo item not found" + if required_status: + detail += f" or not in {required_status} status" + raise ValueError(detail) + + return todo_item + + async def _get_comment_count(self, todo_id: UUID) -> int: + """댓Ꞁ 수 조회""" + result = await self.db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_id) + ) + return result.scalar() or 0 + + async def _build_todo_response(self, todo_item: TodoItem) -> TodoItemResponse: + """TodoItem을 TodoItemResponse로 변환""" + comment_count = await self._get_comment_count(todo_item.id) + + return TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d5c8187 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "4000:80" + depends_on: + - backend + environment: + - API_BASE_URL=http://localhost:9000/api + volumes: + - ./frontend/static:/usr/share/nginx/html/static + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "9000:9000" + depends_on: + - database + environment: + - DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD:-todo_password}@database:5432/todo_db + - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this-in-production} + - DEBUG=${DEBUG:-true} + - CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] + - SYNOLOGY_DSM_URL=${SYNOLOGY_DSM_URL:-} + - SYNOLOGY_USERNAME=${SYNOLOGY_USERNAME:-} + - SYNOLOGY_PASSWORD=${SYNOLOGY_PASSWORD:-} + - ENABLE_SYNOLOGY_INTEGRATION=${ENABLE_SYNOLOGY_INTEGRATION:-false} + volumes: + - ./backend/src:/app/src + - ./backend/uploads:/app/uploads + - todo_uploads:/data/uploads + restart: unless-stopped + + database: + image: postgres:15-alpine + ports: + - "5434:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER:-todo_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-todo_password} + - POSTGRES_DB=${POSTGRES_DB:-todo_db} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + restart: unless-stopped + +volumes: + postgres_data: + todo_uploads: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..dee52cc --- /dev/null +++ b/docs/API.md @@ -0,0 +1,484 @@ +# API 묞서 (API.md) + +## 🚀 API 개요 + +Todo-Project REST API는 간결하고 직ꎀ적읞 할음 ꎀ늬 Ʞ능을 제공합니닀. + +### Ʞ볞 정볎 +- **Base URL**: `http://localhost:9000/api` +- **읞슝 방식**: JWT Bearer Token +- **응답 형식**: JSON +- **API 버전**: v1 + +### 포튞 섀정 +- **Frontend**: http://localhost:4000 +- **Backend API**: http://localhost:9000 +- **Database**: localhost:5434 + +## 🔐 읞슝 + +### 로귞읞 +```http +POST /api/auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "password123", + "remember_me": false +} +``` + +**응답:** +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "token_type": "bearer", + "expires_in": 1800 +} +``` + +### êž°êž° 등록 (개읞용 최적화) +```http +POST /api/auth/register-device +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "device_name": "낮 iPhone", + "fingerprint": "abc123def456", + "platform": "mobile" +} +``` + +**응답:** +```json +{ + "device_token": "long-term-device-token-here", + "expires_at": "2024-02-15T10:30:00Z", + "device_id": "device-uuid" +} +``` + +### êž°êž° 토큰 로귞읞 +```http +POST /api/auth/device-login +Content-Type: application/json + +{ + "device_token": "long-term-device-token-here" +} +``` + +## 📋 할음 ꎀ늬 + +### 할음 목록 조회 +```http +GET /api/todos?status=active&limit=50&offset=0 +Authorization: Bearer {access_token} +``` + +**쿌늬 파띌믞터:** +- `status`: `draft`, `scheduled`, `active`, `completed`, `delayed` +- `limit`: 페읎지당 항목 수 (Ʞ볞: 50) +- `offset`: 시작 위치 (Ʞ볞: 0) + +**응답:** +```json +{ + "todos": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "프로젝튞 Ʞ획서 작성", + "status": "active", + "created_at": "2024-01-15T09:00:00Z", + "start_date": "2024-01-15T10:00:00Z", + "estimated_minutes": 120, + "completed_at": null, + "delayed_until": null, + "parent_id": null, + "split_order": null, + "comment_count": 2 + } + ], + "total": 1, + "has_more": false +} +``` + +### 할음 생성 +```http +POST /api/todos +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "content": "새로욎 할음 낎용" +} +``` + +**응답:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440002", + "user_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "새로욎 할음 낎용", + "status": "draft", + "created_at": "2024-01-15T09:30:00Z", + "start_date": null, + "estimated_minutes": null, + "completed_at": null, + "delayed_until": null, + "parent_id": null, + "split_order": null, + "comment_count": 0 +} +``` + +### 할음 음정 섀정 +```http +POST /api/todos/{todo_id}/schedule +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "start_date": "2024-01-16T14:00:00Z", + "estimated_minutes": 90 +} +``` + +**응답:** 업데읎튞된 할음 객첎 + +### 할음 완료 처늬 +```http +PUT /api/todos/{todo_id}/complete +Authorization: Bearer {access_token} +``` + +**응답:** 완료된 할음 객첎 (status: "completed", completed_at 섀정됚) + +### 할음 지연 처늬 +```http +PUT /api/todos/{todo_id}/delay +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "delayed_until": "2024-01-17T10:00:00Z" +} +``` + +### 할음 분할 +```http +POST /api/todos/{todo_id}/split +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "subtasks": [ + "1닚계: 요구사항 분석", + "2닚계: 섀계 묞서 작성", + "3닚계: 검토 및 수정" + ], + "estimated_minutes_per_task": [30, 60, 30] +} +``` + +**응답:** 생성된 하위 할음듀의 ë°°ì—Ž + +### 할음 상섞 조회 (댓Ꞁ 포핚) +```http +GET /api/todos/{todo_id} +Authorization: Bearer {access_token} +``` + +**응답:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "프로젝튞 Ʞ획서 작성", + "status": "active", + "created_at": "2024-01-15T09:00:00Z", + "start_date": "2024-01-15T10:00:00Z", + "estimated_minutes": 120, + "completed_at": null, + "delayed_until": null, + "parent_id": null, + "split_order": null, + "comment_count": 2, + "comments": [ + { + "id": "550e8400-e29b-41d4-a716-446655440003", + "todo_item_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "진행 상황 메몚", + "created_at": "2024-01-15T11:00:00Z", + "updated_at": "2024-01-15T11:00:00Z" + } + ] +} +``` + +## 💬 댓Ꞁ/메몚 ꎀ늬 + +### 댓Ꞁ 추가 +```http +POST /api/todos/{todo_id}/comments +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "content": "진행 상황 업데읎튞" +} +``` + +### 댓Ꞁ 목록 조회 +```http +GET /api/todos/{todo_id}/comments +Authorization: Bearer {access_token} +``` + +## 📊 통계 및 대시볎드 + +### 할음 통계 +```http +GET /api/todos/stats +Authorization: Bearer {access_token} +``` + +**응답:** +```json +{ + "total_count": 25, + "draft_count": 5, + "scheduled_count": 8, + "active_count": 7, + "completed_count": 4, + "delayed_count": 1, + "completion_rate": 16.0 +} +``` + +### 활성 할음 조회 (시간 êž°ë°˜ 자동 활성화) +```http +GET /api/todos/active +Authorization: Bearer {access_token} +``` + +**Ʞ능:** scheduled 상태의 할음 쀑 시작 시간읎 지난 것듀을 자동윌로 active로 변겜하고 반환 + +## 👀 사용자 ꎀ늬 + +### 현재 사용자 정볎 +```http +GET /api/users/me +Authorization: Bearer {access_token} +``` + +**응답:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440001", + "email": "user@example.com", + "full_name": "사용자 읎늄", + "is_active": true, + "is_admin": false, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T09:00:00Z", + "last_login_at": "2024-01-15T09:00:00Z", + "timezone": "Asia/Seoul", + "language": "ko" +} +``` + +### 사용자 정볎 수정 +```http +PUT /api/users/me +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "full_name": "새로욎 읎늄", + "timezone": "Asia/Seoul", + "language": "ko" +} +``` + +## 🔗 시놀로지 연동 (1닚계) + +### 시놀로지 섀정 테슀튞 +```http +POST /api/synology/test-connection +Content-Type: application/json +Authorization: Bearer {access_token} + +{ + "dsm_url": "https://your-nas.synology.me:5001", + "username": "todo_user", + "password": "password123" +} +``` + +**응답:** +```json +{ + "dsm_connection": "success", + "calendar_connection": "success", + "mail_connection": "success", + "available_services": ["Calendar", "MailPlus"] +} +``` + +### 캘늰더 동Ʞ화 수동 싀행 +```http +POST /api/synology/sync-calendar/{todo_id} +Authorization: Bearer {access_token} +``` + +### 메음 알늌 발송 +```http +POST /api/synology/send-notification/{todo_id} +Authorization: Bearer {access_token} +``` + +## 🔧 시슀템 ꎀ늬 + +### 헬슀 첎크 +```http +GET /api/health +``` + +**응답:** +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T12:00:00Z", + "version": "0.1.0", + "database": "connected", + "synology_integration": "enabled" +} +``` + +### API 정볎 +```http +GET /api/info +``` + +**응답:** +```json +{ + "name": "Todo Project API", + "version": "0.1.0", + "description": "간결하고 슀마튞한 개읞용 할음 ꎀ늬 시슀템", + "docs_url": "/docs", + "redoc_url": "/redoc" +} +``` + +## 📝 였류 응답 + +### 표쀀 였류 형식 +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "입력 데읎터가 유횚하지 않습니닀.", + "details": { + "field": "content", + "issue": "최소 1자 읎상 입력핎알 합니닀." + } + }, + "timestamp": "2024-01-15T12:00:00Z" +} +``` + +### 죌요 였류 윔드 +- `AUTHENTICATION_ERROR`: 읞슝 싀팚 +- `AUTHORIZATION_ERROR`: 권한 없음 +- `VALIDATION_ERROR`: 입력 데읎터 검슝 싀팚 +- `NOT_FOUND`: 늬소슀륌 찟을 수 없음 +- `CONFLICT`: 데읎터 충돌 +- `RATE_LIMIT_EXCEEDED`: 요청 한도 쎈곌 +- `SYNOLOGY_CONNECTION_ERROR`: 시놀로지 연동 였류 + +### HTTP 상태 윔드 +- `200`: 성공 +- `201`: 생성 성공 +- `400`: 잘못된 요청 +- `401`: 읞슝 필요 +- `403`: 권한 없음 +- `404`: 찟을 수 없음 +- `409`: 충돌 +- `422`: 검슝 싀팚 +- `429`: 요청 한도 쎈곌 +- `500`: 서버 였류 + +## 🚀 SDK 및 큎띌읎얞튞 + +### JavaScript 큎띌읎얞튞 예제 +```javascript +class TodoAPI { + constructor(baseURL = 'http://localhost:9000/api') { + this.baseURL = baseURL; + this.token = localStorage.getItem('access_token'); + } + + async createTodo(content) { + const response = await fetch(`${this.baseURL}/todos`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }, + body: JSON.stringify({ content }) + }); + + return await response.json(); + } + + async getTodos(status = null) { + const params = status ? `?status=${status}` : ''; + const response = await fetch(`${this.baseURL}/todos${params}`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + return await response.json(); + } + + async completeTodo(todoId) { + const response = await fetch(`${this.baseURL}/todos/${todoId}/complete`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + return await response.json(); + } +} + +// 사용 예제 +const api = new TodoAPI(); + +// 할음 생성 +const newTodo = await api.createTodo('새로욎 할음'); + +// 할음 목록 조회 +const activeTodos = await api.getTodos('active'); + +// 할음 완료 +await api.completeTodo(newTodo.id); +``` + +## 📚 추가 늬소슀 + +- **Swagger UI**: http://localhost:9000/docs +- **ReDoc**: http://localhost:9000/redoc +- **OpenAPI Spec**: http://localhost:9000/openapi.json + +읎 API 묞서륌 통핎 Todo-Project의 몚든 Ʞ능을 활용할 수 있습니닀! diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..1eef9a4 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,469 @@ +# 배포 가읎드 (DEPLOYMENT.md) + +## 🚀 배포 개요 + +Todo-Project는 Docker륌 사용한 컚테읎너 êž°ë°˜ 배포륌 지원하며, 개읞용 환겜에 최적화되얎 있습니닀. + +## 📋 사전 요구사항 + +### 시슀템 요구사항 +- **OS**: Linux, macOS, Windows (Docker 지원) +- **RAM**: 최소 2GB, 권장 4GB +- **Storage**: 최소 10GB 여유 공간 +- **Network**: 읞터넷 연결 (시놀로지 연동 시) + +### 필수 소프튞웚얎 +- Docker 20.10+ +- Docker Compose 2.0+ +- Git (소슀 윔드 닀욎로드용) + +## 🐳 Docker 배포 + +### 1. 소슀 윔드 닀욎로드 +```bash +git clone https://github.com/your-username/Todo-Project.git +cd Todo-Project +``` + +### 2. 환겜 섀정 +```bash +# 환겜 변수 파음 생성 +cp .env.example .env + +# 환겜 변수 펞집 +nano .env +``` + +#### Ʞ볞 환겜 변수 섀정 +```bash +# 데읎터베읎슀 섀정 +DATABASE_URL=postgresql://todo_user:todo_password@database:5432/todo_db +POSTGRES_USER=todo_user +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_DB=todo_db + +# JWT 섀정 (반드시 변겜!) +SECRET_KEY=your-very-long-and-random-secret-key-here-change-this-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# 애플늬쌀읎션 섀정 +DEBUG=false +CORS_ORIGINS=["http://localhost:4000", "http://your-domain.com:4000"] + +# 서버 섀정 +HOST=0.0.0.0 +PORT=9000 + +# 시놀로지 연동 (선택사항) +SYNOLOGY_DSM_URL=https://your-nas.synology.me:5001 +SYNOLOGY_USERNAME=todo_user +SYNOLOGY_PASSWORD=your_synology_password +ENABLE_SYNOLOGY_INTEGRATION=true +``` + +### 3. Docker Compose 싀행 +```bash +# 백귞띌욎드에서 싀행 +docker-compose up -d + +# 로귞 확읞 +docker-compose logs -f +``` + +### 4. 서비슀 확읞 +```bash +# 컚테읎너 상태 확읞 +docker-compose ps + +# 헬슀 첎크 +curl http://localhost:9000/api/health + +# 프론튞엔드 접속 +open http://localhost:4000 +``` + +## 🔧 Docker Compose 구성 + +### docker-compose.yml +```yaml +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "4000:80" + depends_on: + - backend + environment: + - API_BASE_URL=http://localhost:9000/api + volumes: + - ./frontend/static:/usr/share/nginx/html/static + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "9000:9000" + depends_on: + - database + environment: + - DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD}@database:5432/todo_db + - SECRET_KEY=${SECRET_KEY} + - DEBUG=${DEBUG:-false} + volumes: + - ./backend/uploads:/app/uploads + restart: unless-stopped + + database: + image: postgres:15-alpine + ports: + - "5434:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + restart: unless-stopped + +volumes: + postgres_data: +``` + +### 프로덕션용 docker-compose.prod.yml +```yaml +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + ports: + - "80:80" + - "443:443" + volumes: + - ./ssl:/etc/nginx/ssl + - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf + restart: always + + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + expose: + - "9000" + environment: + - DEBUG=false + - DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD}@database:5432/todo_db + restart: always + + database: + image: postgres:15-alpine + expose: + - "5432" + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backups:/backups + restart: always + +volumes: + postgres_data: +``` + +## 🌐 프로덕션 배포 + +### 1. SSL 읞슝서 섀정 +```bash +# Let's Encrypt 읞슝서 생성 (Certbot 사용) +sudo certbot certonly --standalone -d your-domain.com + +# 읞슝서 파음 복사 +sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./ssl/ +sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./ssl/ +``` + +### 2. Nginx 섀정 (프로덕션) +```nginx +# nginx/nginx.prod.conf +server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Frontend + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api/ { + proxy_pass http://backend:9000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 볎안 헀더 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +} +``` + +### 3. 프로덕션 배포 싀행 +```bash +# 프로덕션 환겜윌로 배포 +docker-compose -f docker-compose.prod.yml up -d + +# 로귞 몚니터링 +docker-compose -f docker-compose.prod.yml logs -f +``` + +## 🔄 업데읎튞 및 유지볎수 + +### 애플늬쌀읎션 업데읎튞 +```bash +# 소슀 윔드 업데읎튞 +git pull origin main + +# 컚테읎너 재빌드 및 재시작 +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +### 데읎터베읎슀 백업 +```bash +# 백업 슀크늜튞 생성 +cat > backup.sh << 'EOF' +#!/bin/bash +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="todo_backup_${DATE}.sql" + +docker-compose exec database pg_dump -U todo_user todo_db > ./backups/${BACKUP_FILE} +echo "백업 완료: ${BACKUP_FILE}" + +# 7음 읎상 된 백업 파음 삭제 +find ./backups -name "todo_backup_*.sql" -mtime +7 -delete +EOF + +chmod +x backup.sh + +# 백업 싀행 +./backup.sh +``` + +### 데읎터베읎슀 복원 +```bash +# 백업에서 복원 +docker-compose exec database psql -U todo_user -d todo_db < ./backups/todo_backup_20240115_120000.sql +``` + +### 로귞 ꎀ늬 +```bash +# 로귞 확읞 +docker-compose logs backend +docker-compose logs frontend +docker-compose logs database + +# 로귞 로테읎션 섀정 +cat > /etc/logrotate.d/docker-compose << 'EOF' +/var/lib/docker/containers/*/*.log { + rotate 7 + daily + compress + size=1M + missingok + delaycompress + copytruncate +} +EOF +``` + +## 📊 몚니터링 + +### 헬슀 첎크 슀크늜튞 +```bash +#!/bin/bash +# health_check.sh + +API_URL="http://localhost:9000/api/health" +FRONTEND_URL="http://localhost:4000" + +# API 헬슀 첎크 +if curl -f -s $API_URL > /dev/null; then + echo "✅ API 서버 정상" +else + echo "❌ API 서버 였류" + # 알늌 발송 (예: 읎메음, Slack 등) +fi + +# 프론튞엔드 첎크 +if curl -f -s $FRONTEND_URL > /dev/null; then + echo "✅ 프론튞엔드 정상" +else + echo "❌ 프론튞엔드 였류" +fi + +# 데읎터베읎슀 첎크 +if docker-compose exec database pg_isready -U todo_user > /dev/null; then + echo "✅ 데읎터베읎슀 정상" +else + echo "❌ 데읎터베읎슀 였류" +fi +``` + +### 시슀템 늬소슀 몚니터링 +```bash +# 컚테읎너 늬소슀 사용량 확읞 +docker stats + +# 디슀크 사용량 확읞 +df -h + +# 메몚늬 사용량 확읞 +free -h +``` + +## 🔐 볎안 섀정 + +### 방화벜 섀정 (Ubuntu/CentOS) +```bash +# UFW (Ubuntu) +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw enable + +# firewalld (CentOS) +sudo firewall-cmd --permanent --add-service=ssh +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload +``` + +### 자동 볎안 업데읎튞 +```bash +# Ubuntu +sudo apt install unattended-upgrades +sudo dpkg-reconfigure -plow unattended-upgrades + +# CentOS +sudo yum install yum-cron +sudo systemctl enable yum-cron +sudo systemctl start yum-cron +``` + +## 🚚 묞제 핎결 + +### 음반적읞 묞제듀 + +#### 1. 컚테읎너 시작 싀팚 +```bash +# 로귞 확읞 +docker-compose logs backend + +# 포튞 충돌 확읞 +netstat -tulpn | grep :9000 + +# 권한 묞제 확읞 +ls -la ./backend/uploads +``` + +#### 2. 데읎터베읎슀 연결 싀팚 +```bash +# 데읎터베읎슀 컚테읎너 상태 확읞 +docker-compose exec database pg_isready -U todo_user + +# 연결 테슀튞 +docker-compose exec database psql -U todo_user -d todo_db -c "SELECT 1;" +``` + +#### 3. 시놀로지 연동 묞제 +```bash +# 넀튞워크 연결 테슀튞 +curl -k https://your-nas.synology.me:5001/webapi/auth.cgi + +# DNS 핎결 확읞 +nslookup your-nas.synology.me +``` + +### 성능 최적화 + +#### 1. 데읎터베읎슀 최적화 +```sql +-- 읞덱슀 확읞 +SELECT schemaname, tablename, attname, n_distinct, correlation +FROM pg_stats +WHERE tablename = 'todo_items'; + +-- 쿌늬 성능 분석 +EXPLAIN ANALYZE SELECT * FROM todo_items WHERE user_id = 'uuid'; +``` + +#### 2. 캐싱 섀정 +```bash +# Redis 추가 (선택사항) +docker run -d --name redis -p 6379:6379 redis:alpine +``` + +## 📱 몚바음 PWA 배포 + +### PWA 섀정 확읞 +```javascript +// manifest.json 검슝 +{ + "name": "Todo Project", + "short_name": "Todo", + "start_url": "/", + "display": "standalone", + "background_color": "#6366f1", + "theme_color": "#6366f1", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} +``` + +### Service Worker 등록 +```javascript +// sw.js 등록 확읞 +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js'); +} +``` + +읎 배포 가읎드륌 통핎 안정적읎고 확장 가능한 Todo-Project륌 배포할 수 있습니닀! diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..d92932e --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,387 @@ +# 볎안 가읎드 (SECURITY.md) + +## 🔐 볎안 철학 + +Todo-Project는 **개읞용 도구**로 섀계되얎 **펞의성**곌 **적절한 볎안** 사읎의 균형을 추구합니닀. + +### 볎안 원칙 +- **Trust but Verify**: 신뢰할 수 있는 ꞰꞰ에서는 간펞하게, 의심슀러욎 접귌은 찚닚 +- **최소 권한**: 필요한 최소한의 권한만 부여 +- **개읞 최적화**: 개읞 사용에 최적화된 볎안 몚덞 + +## 🛡 볎안 레벚 + +### 1. Minimal (개읞용 권장) +```python +SECURITY_MINIMAL = { + "device_remember_days": 30, # 30음간 êž°êž° êž°ì–µ + "require_password": False, # êž°êž° 등록 후 비밀번혞 불필요 + "session_timeout": 0, # 묎제한 섞션 + "biometric_optional": True, # 생첎 읞슝 선택사항 + "auto_login": True # 자동 로귞읞 활성화 +} +``` + +**적합한 환겜**: 개읞 êž°êž° (낮 폰, 낮 컎퓚터)에서만 사용 + +### 2. Balanced (음반 권장) +```python +SECURITY_BALANCED = { + "device_remember_days": 7, # 7음간 êž°êž° êž°ì–µ + "require_password": True, # 죌Ʞ적 비밀번혞 확읞 + "session_timeout": 24*60, # 24시간 섞션 + "biometric_optional": True, # 생첎 읞슝 선택사항 + "auto_login": False # 수동 로귞읞 +} +``` + +**적합한 환겜**: 가끔 닀륞 ꞰꞰ에서도 접귌하는 겜우 + +### 3. Secure (높은 볎안) +```python +SECURITY_SECURE = { + "device_remember_days": 1, # 1음간만 êž°êž° êž°ì–µ + "require_password": True, # 맀번 비밀번혞 확읞 + "session_timeout": 60, # 1시간 섞션 + "biometric_required": True, # 생첎 읞슝 필수 + "auto_login": False # 수동 로귞읞 +} +``` + +**적합한 환겜**: 믌감한 정볎가 포핚된 겜우 + +## 🔑 읞슝 시슀템 + +### êž°êž° 등록 방식 + +#### êž°êž° 식별 +```python +class DeviceFingerprint: + """êž°êž° 고유 식별자 생성""" + + def generate_fingerprint(self, request): + """람띌우저 fingerprint 생성""" + components = [ + request.headers.get('User-Agent', ''), + request.headers.get('Accept-Language', ''), + request.headers.get('Accept-Encoding', ''), + self.get_screen_resolution(), # JavaScript에서 전송 + self.get_timezone(), # JavaScript에서 전송 + self.get_platform_info() # JavaScript에서 전송 + ] + + fingerprint = hashlib.sha256( + '|'.join(components).encode('utf-8') + ).hexdigest() + + return fingerprint[:16] # 16자늬 축앜 +``` + +#### êž°êž° 등록 프로섞슀 +```python +class DeviceRegistration: + """êž°êž° 등록 ꎀ늬""" + + async def register_device(self, user_id, device_info, user_confirmation): + """새 êž°êž° 등록""" + + # 1. 사용자 확읞 (비밀번혞 또는 Ʞ졎 ꞰꞰ에서 승읞) + if not await self.verify_user_identity(user_id, user_confirmation): + raise AuthenticationError("사용자 확읞 싀팚") + + # 2. êž°êž° 정볎 생성 + device_id = self.generate_device_id(device_info) + device_name = device_info.get('name', '알 수 없는 êž°êž°') + + # 3. 장Ʞ 토큰 생성 (30음 유횚) + device_token = self.create_device_token(user_id, device_id) + + # 4. êž°êž° 정볎 저장 + device_record = { + "device_id": device_id, + "user_id": user_id, + "device_name": device_name, + "fingerprint": device_info['fingerprint'], + "registered_at": datetime.now(), + "last_used": datetime.now(), + "token": device_token, + "expires_at": datetime.now() + timedelta(days=30), + "is_trusted": True + } + + await self.save_device_record(device_record) + return device_token +``` + +### 토큰 ꎀ늬 + +#### JWT 토큰 구조 +```python +class TokenManager: + """토큰 생성 및 ꎀ늬""" + + def create_device_token(self, user_id, device_id): + """장Ʞ간 유횚한 êž°êž° 토큰 생성""" + payload = { + "user_id": str(user_id), + "device_id": device_id, + "token_type": "device", + "issued_at": datetime.utcnow(), + "expires_at": datetime.utcnow() + timedelta(days=30) + } + + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + def create_session_token(self, user_id, device_id): + """섞션 토큰 생성""" + payload = { + "user_id": str(user_id), + "device_id": device_id, + "token_type": "session", + "issued_at": datetime.utcnow(), + "expires_at": datetime.utcnow() + timedelta(hours=24) + } + + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") +``` + +#### 토큰 검슝 +```python +class TokenValidator: + """토큰 검슝""" + + async def validate_device_token(self, token): + """êž°êž° 토큰 검슝""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + + # 토큰 타입 확읞 + if payload.get("token_type") != "device": + return None + + # 만료 시간 확읞 + expires_at = datetime.fromisoformat(payload["expires_at"]) + if datetime.utcnow() > expires_at: + return None + + # êž°êž° 정볎 확읞 + device_record = await self.get_device_record( + payload["user_id"], + payload["device_id"] + ) + + if not device_record or not device_record["is_trusted"]: + return None + + return payload + + except jwt.JWTError: + return None +``` + +## 🔒 데읎터 볎안 + +### 데읎터베읎슀 볎안 + +#### 비밀번혞 핎싱 +```python +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + """비밀번혞 핎싱 (bcrypt)""" + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """비밀번혞 검슝""" + return pwd_context.verify(plain_password, hashed_password) +``` + +#### 믌감 정볎 암혞화 +```python +from cryptography.fernet import Fernet + +class DataEncryption: + """믌감 정볎 암혞화""" + + def __init__(self): + self.key = settings.ENCRYPTION_KEY.encode() + self.cipher = Fernet(self.key) + + def encrypt_sensitive_data(self, data: str) -> str: + """믌감 정볎 암혞화 (시놀로지 비밀번혞 등)""" + return self.cipher.encrypt(data.encode()).decode() + + def decrypt_sensitive_data(self, encrypted_data: str) -> str: + """믌감 정볎 복혞화""" + return self.cipher.decrypt(encrypted_data.encode()).decode() +``` + +### 넀튞워크 볎안 + +#### HTTPS 강제 +```python +from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware + +# 프로덕션에서 HTTPS 강제 +if not settings.DEBUG: + app.add_middleware(HTTPSRedirectMiddleware) +``` + +#### CORS 섀정 +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_ORIGINS, # 특정 도메읞만 허용 + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], +) +``` + +## 🚚 볎안 몚니터링 + +### 로귞읞 시도 몚니터링 +```python +class SecurityMonitor: + """볎안 몚니터링""" + + def __init__(self): + self.failed_attempts = {} # IP별 싀팚 횟수 + self.blocked_ips = set() # 찚닚된 IP + + async def record_login_attempt(self, ip_address, success): + """로귞읞 시도 Ʞ록""" + if success: + # 성공 시 싀팚 횟수 쎈Ʞ화 + self.failed_attempts.pop(ip_address, None) + else: + # 싀팚 시 횟수 슝가 + self.failed_attempts[ip_address] = \ + self.failed_attempts.get(ip_address, 0) + 1 + + # 5회 싀팚 시 30분 찚닚 + if self.failed_attempts[ip_address] >= 5: + self.block_ip(ip_address, minutes=30) + + def block_ip(self, ip_address, minutes=30): + """IP 죌소 찚닚""" + self.blocked_ips.add(ip_address) + + # 음정 시간 후 찚닚 핎제 + asyncio.create_task( + self.unblock_ip_after(ip_address, minutes) + ) +``` + +### 의심슀러욎 활동 감지 +```python +class AnomalyDetection: + """읎상 활동 감지""" + + async def detect_suspicious_activity(self, user_id, activity): + """의심슀러욎 활동 감지""" + + # 1. 비정상적읞 시간대 ì ‘ê·Œ + if self.is_unusual_time(activity.timestamp): + await self.alert_unusual_time_access(user_id, activity) + + # 2. 새로욎 ꞰꞰ에서 ì ‘ê·Œ + if not await self.is_known_device(user_id, activity.device_info): + await self.alert_new_device_access(user_id, activity) + + # 3. 비정상적읞 API 혞출 팹턮 + if await self.is_unusual_api_pattern(user_id, activity): + await self.alert_unusual_api_pattern(user_id, activity) +``` + +## 🔧 볎안 섀정 + +### 환겜 변수 볎안 +```bash +# .env 파음 볎안 섀정 +SECRET_KEY=your-very-long-and-random-secret-key-here +ENCRYPTION_KEY=your-32-byte-encryption-key-here + +# 시놀로지 읞슝 정볎 (암혞화 저장) +SYNOLOGY_USERNAME=encrypted_username +SYNOLOGY_PASSWORD=encrypted_password + +# 볎안 레벚 섀정 +SECURITY_LEVEL=minimal # minimal, balanced, secure +ENABLE_DEVICE_REGISTRATION=true +ENABLE_BIOMETRIC_AUTH=true +ENABLE_SECURITY_MONITORING=true + +# 섞션 섀정 +SESSION_TIMEOUT_MINUTES=1440 # 24시간 +DEVICE_REMEMBER_DAYS=30 +``` + +### 볎안 헀더 +```python +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import Response + +# 신뢰할 수 있는 혞슀튞만 허용 +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=["localhost", "127.0.0.1", "your-domain.com"] +) + +@app.middleware("http") +async def add_security_headers(request, call_next): + """볎안 헀더 추가""" + response = await call_next(request) + + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + return response +``` + +## 🛠 볎안 첎크늬슀튞 + +### 개발 환겜 +- [ ] `.env` 파음읎 `.gitignore`에 포핚되얎 있는가? +- [ ] Ʞ볞 비밀번혞가 변겜되었는가? +- [ ] 디버귞 몚드가 비활성화되얎 있는가? (프로덕션) +- [ ] 로귞에 믌감 정볎가 포핚되지 않는가? + +### 읞슝 시슀템 +- [ ] 비밀번혞가 안전하게 핎싱되얎 있는가? +- [ ] JWT 토큰에 믌감 정볎가 포핚되지 않는가? +- [ ] 토큰 만료 시간읎 적절하게 섀정되얎 있는가? +- [ ] êž°êž° 등록 프로섞슀가 안전한가? + +### 넀튞워크 볎안 +- [ ] HTTPS가 활성화되얎 있는가? (프로덕션) +- [ ] CORS 섀정읎 적절한가? +- [ ] 볎안 헀더가 섀정되얎 있는가? +- [ ] 불필요한 포튞가 찚닚되얎 있는가? + +### 데읎터 볎안 +- [ ] 믌감 정볎가 암혞화되얎 있는가? +- [ ] 데읎터베읎슀 접귌읎 제한되얎 있는가? +- [ ] 백업 데읎터가 안전하게 볎ꎀ되얎 있는가? +- [ ] 로귞 파음읎 안전하게 ꎀ늬되얎 있는가? + +## 🚚 볎안 사고 대응 + +### 사고 대응 절찚 +1. **슉시 조치**: 의심슀러욎 ì ‘ê·Œ 찚닚 +2. **영향 평가**: 플핎 범위 확읞 +3. **복구 작업**: 시슀템 정상화 +4. **사후 분석**: 원읞 분석 및 재발 방지 + +### 비상 연띜처 +- **시슀템 ꎀ늬자**: [연띜처] +- **볎안 닎당자**: [연띜처] +- **시놀로지 지원**: [연띜처] + +읎 볎안 가읎드륌 통핎 안전하고 펞늬한 Todo 시슀템을 구축할 수 있습니닀. diff --git a/frontend/calendar.html b/frontend/calendar.html new file mode 100644 index 0000000..a484db9 --- /dev/null +++ b/frontend/calendar.html @@ -0,0 +1,400 @@ + + + + + + 캘늰더 - 마감 Ʞ한읎 있는 음듀 + + + + + +
+ +
+
+
+
+ + +

캘늰더

+ 마감 Ʞ한읎 있는 음듀 +
+ +
+ + + +
+
+
+
+ + +
+ +
+
+ +

캘늰더 ꎀ늬

+
+

+ 마감 Ʞ한읎 있는 음듀을 ꎀ늬합니닀. 우선순위에 따띌 계획적윌로 진행핎볎섞요. +

+
+
+
🚚 ꞎ꞉
+
3음 읎낎 마감
+
+
+
⚠ 죌의
+
1죌음 읎낎 마감
+
+
+
📅 여유
+
1죌음 읎상 낚음
+
+
+
+ + +
+
+
+ + + + + +
+ +
+ + +
+
+
+ + +
+
+

+ 마감 Ʞ한별 목록 +

+
+ +
+ +
+ +
+ +

아직 마감 Ʞ한읎 섀정된 음읎 없습니닀.

+

메읞 페읎지에서 항목을 등록하고 마감 Ʞ한을 섀정핎볎섞요!

+ +
+
+
+
+ + + + + + diff --git a/frontend/checklist.html b/frontend/checklist.html new file mode 100644 index 0000000..4f37f0f --- /dev/null +++ b/frontend/checklist.html @@ -0,0 +1,413 @@ + + + + + + 첎크늬슀튞 - Ʞ한 없는 음듀 + + + + + +
+ +
+
+
+
+ + +

첎크늬슀튞

+ Ʞ한 없는 음듀 +
+ +
+ + + +
+
+
+
+ + +
+ +
+
+ +

첎크늬슀튞 ꎀ늬

+
+

+ Ʞ한읎 없는 음듀을 ꎀ늬합니닀. 얞제든 할 수 있는 음듀을 첎크핎나가섞요. +

+
+
+
📝 할 음
+
아직 완료하지 않은 음듀
+
+
+
✅ 완료
+
완료한 음듀
+
+
+
📊 진행률
+
0% 완료
+
+
+
+ + +
+
+

+ 전첎 진행률 +

+
+ 0 / 0 완료 +
+
+
+
+
+
+ + +
+
+
+ + + +
+ +
+ + + +
+
+
+ + +
+
+

+ 첎크늬슀튞 목록 +

+
+ +
+ +
+ +
+ +

아직 첎크늬슀튞 항목읎 없습니닀.

+

메읞 페읎지에서 Ʞ한 없는 항목을 등록핎볎섞요!

+ +
+
+
+
+ + + + + + diff --git a/frontend/classify.html b/frontend/classify.html new file mode 100644 index 0000000..78b7289 --- /dev/null +++ b/frontend/classify.html @@ -0,0 +1,652 @@ + + + + + + 분류 섌터 - Todo Project + + + + + +
+ +
+
+
+
+ + +

분류 섌터

+ 0 +
+ +
+ + + + +
+
+
+
+ + +
+ +
+ +
+
+
+ +
+
+

분류 대Ʞ

+

0

+
+
+
+ +
+
+
+ +
+
+

Todo 읎동

+

0

+
+
+
+ +
+
+
+ +
+
+

캘늰더 읎동

+

0

+
+
+
+ +
+
+
+ +
+
+

첎크늬슀튞 읎동

+

0

+
+
+
+
+ + +
+
+
+ + + + +
+ +
+ + + +
+
+
+ + +
+ +
+ + + +
+
+ + + + + + diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..0b7941a --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,937 @@ + + + + + + 대시볎드 - Todo Project + + + + + +
+ +
+
+
+
+ + +

대시볎드

+ +
+ +
+ + + + + +
+
+
+
+ + +
+ +
+ +
+
+
+ +

+ +
+ +
+
+
+ Todo +
+
+
+ 캘늰더 +
+
+
+
+ + +
+
+ +
음
+
월
+
화
+
수
+
목
+
ꞈ
+
토
+ + +
+
+
+ + +
+
+

+ 첎크늬슀튞 +

+
+ 0/0 완료 +
+
+ +
+ +
+
+
+ + +
+ +
+
+

+

+
+
+ + +
+

+ 였늘의 음정 +

+
+ +
+
+ + +
+
+

+ 첎크늬슀튞 +

+
+ 0/0 +
+
+ +
+ +
+
+
+
+
+ + + + + + + + + + + + + diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..142c4e7 Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bd4e0fb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,282 @@ + + + + + + Todo Project - 간결한 할음 ꎀ늬 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +

Todo Project

+

간결한 할음 ꎀ늬

+
+ +
+
+ + +
+ +
+ + +
+ + +
+ +
+

테슀튞 계정: user1 / password123

+
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..6986f14 --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,104 @@ +{ + "name": "Todo Project - 간결한 할음 ꎀ늬", + "short_name": "Todo Project", + "description": "사진곌 메몚륌 Ʞ반윌로 한 간닚한 음정ꎀ늬 시슀템", + "start_url": "/", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#6366f1", + "orientation": "portrait-primary", + "categories": ["productivity", "utilities"], + "lang": "ko", + "icons": [ + { + "src": "static/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "static/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "빠륞 할음 추가", + "short_name": "할음 추가", + "description": "새로욎 할음을 빠륎게 추가합니닀", + "url": "/?action=add", + "icons": [ + { + "src": "static/icons/shortcut-add.png", + "sizes": "96x96" + } + ] + }, + { + "name": "진행쀑읞 할음", + "short_name": "진행쀑", + "description": "현재 진행쀑읞 할음을 확읞합니닀", + "url": "/?filter=active", + "icons": [ + { + "src": "static/icons/shortcut-active.png", + "sizes": "96x96" + } + ] + } + ], + "screenshots": [ + { + "src": "static/screenshots/desktop-1.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "데슀크톱 메읞 화멎" + }, + { + "src": "static/screenshots/mobile-1.png", + "sizes": "375x812", + "type": "image/png", + "form_factor": "narrow", + "label": "몚바음 메읞 화멎" + } + ] +} diff --git a/frontend/static/icons/apple-touch-icon-ipad.png b/frontend/static/icons/apple-touch-icon-ipad.png new file mode 100644 index 0000000..4bf8a80 Binary files /dev/null and b/frontend/static/icons/apple-touch-icon-ipad.png differ diff --git a/frontend/static/icons/apple-touch-icon.png b/frontend/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..ee53ae8 Binary files /dev/null and b/frontend/static/icons/apple-touch-icon.png differ diff --git a/frontend/static/icons/icon-128x128.png b/frontend/static/icons/icon-128x128.png new file mode 100644 index 0000000..9836bfc Binary files /dev/null and b/frontend/static/icons/icon-128x128.png differ diff --git a/frontend/static/icons/icon-144x144.png b/frontend/static/icons/icon-144x144.png new file mode 100644 index 0000000..34e5b94 Binary files /dev/null and b/frontend/static/icons/icon-144x144.png differ diff --git a/frontend/static/icons/icon-152x152.png b/frontend/static/icons/icon-152x152.png new file mode 100644 index 0000000..3e4cedb Binary files /dev/null and b/frontend/static/icons/icon-152x152.png differ diff --git a/frontend/static/icons/icon-192x192.png b/frontend/static/icons/icon-192x192.png new file mode 100644 index 0000000..f0f1dc4 Binary files /dev/null and b/frontend/static/icons/icon-192x192.png differ diff --git a/frontend/static/icons/icon-384x384.png b/frontend/static/icons/icon-384x384.png new file mode 100644 index 0000000..c510844 Binary files /dev/null and b/frontend/static/icons/icon-384x384.png differ diff --git a/frontend/static/icons/icon-512x512.png b/frontend/static/icons/icon-512x512.png new file mode 100644 index 0000000..f0866c1 Binary files /dev/null and b/frontend/static/icons/icon-512x512.png differ diff --git a/frontend/static/icons/icon-72x72.png b/frontend/static/icons/icon-72x72.png new file mode 100644 index 0000000..4238dcc Binary files /dev/null and b/frontend/static/icons/icon-72x72.png differ diff --git a/frontend/static/icons/icon-96x96.png b/frontend/static/icons/icon-96x96.png new file mode 100644 index 0000000..2499f0d Binary files /dev/null and b/frontend/static/icons/icon-96x96.png differ diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js new file mode 100644 index 0000000..fcc9702 --- /dev/null +++ b/frontend/static/js/api.js @@ -0,0 +1,165 @@ +/** + * API 통신 유틞늬티 + */ + +const API_BASE_URL = 'http://localhost:9000/api'; + +class ApiClient { + constructor() { + this.token = localStorage.getItem('authToken'); + } + + async request(endpoint, options = {}) { + const url = `${API_BASE_URL}${endpoint}`; + const config = { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + }; + + // 읞슝 토큰 추가 + if (this.token) { + config.headers['Authorization'] = `Bearer ${this.token}`; + } + + try { + const response = await fetch(url, config); + + if (!response.ok) { + if (response.status === 401) { + // 토큰 만료 시 로귞아웃 + this.logout(); + throw new Error('읞슝읎 만료되었습니닀. 닀시 로귞읞핎죌섞요.'); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + return await response.text(); + } catch (error) { + console.error('API 요청 싀팚:', error); + throw error; + } + } + + // GET 요청 + async get(endpoint) { + return this.request(endpoint, { method: 'GET' }); + } + + // POST 요청 + async post(endpoint, data) { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + // PUT 요청 + async put(endpoint, data) { + return this.request(endpoint, { + method: 'PUT', + body: JSON.stringify(data) + }); + } + + // DELETE 요청 + async delete(endpoint) { + return this.request(endpoint, { method: 'DELETE' }); + } + + // 파음 업로드 + async uploadFile(endpoint, formData) { + return this.request(endpoint, { + method: 'POST', + headers: { + // Content-Type을 섀정하지 않음 (FormData가 자동윌로 섀정) + }, + body: formData + }); + } + + // 토큰 섀정 + setToken(token) { + this.token = token; + localStorage.setItem('authToken', token); + } + + // 로귞아웃 + logout() { + this.token = null; + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + window.location.reload(); + } +} + +// 전역 API 큎띌읎얞튞 읞슀턎슀 +const api = new ApiClient(); + +// 읞슝 ꎀ렚 API +const AuthAPI = { + async login(username, password) { + const response = await api.post('/auth/login', { + username, + password + }); + + if (response.access_token) { + api.setToken(response.access_token); + localStorage.setItem('currentUser', JSON.stringify(response.user)); + } + + return response; + }, + + async logout() { + try { + await api.post('/auth/logout'); + } catch (error) { + console.error('로귞아웃 API 혞출 싀팚:', error); + } finally { + api.logout(); + } + }, + + async getCurrentUser() { + return api.get('/auth/me'); + } +}; + +// Todo ꎀ렚 API +const TodoAPI = { + async getTodos(filter = 'all') { + const params = filter !== 'all' ? `?status=${filter}` : ''; + return api.get(`/todos${params}`); + }, + + async createTodo(todoData) { + return api.post('/todos', todoData); + }, + + async updateTodo(id, todoData) { + return api.put(`/todos/${id}`, todoData); + }, + + async deleteTodo(id) { + return api.delete(`/todos/${id}`); + }, + + async uploadImage(imageFile) { + const formData = new FormData(); + formData.append('image', imageFile); + return api.uploadFile('/todos/upload-image', formData); + } +}; + +// 전역윌로 사용 가능하도록 export +window.api = api; +window.AuthAPI = AuthAPI; +window.TodoAPI = TodoAPI; diff --git a/frontend/static/js/auth.js b/frontend/static/js/auth.js new file mode 100644 index 0000000..f1523ea --- /dev/null +++ b/frontend/static/js/auth.js @@ -0,0 +1,139 @@ +/** + * 읞슝 ꎀ늬 + */ + +let currentUser = null; + +// 페읎지 로드 시 읞슝 상태 확읞 +document.addEventListener('DOMContentLoaded', () => { + checkAuthStatus(); + setupLoginForm(); +}); + +// 읞슝 상태 확읞 +function checkAuthStatus() { + const token = localStorage.getItem('authToken'); + const userData = localStorage.getItem('currentUser'); + + if (token && userData) { + try { + currentUser = JSON.parse(userData); + showMainApp(); + } catch (error) { + console.error('사용자 데읎터 파싱 싀팚:', error); + logout(); + } + } else { + showLoginScreen(); + } +} + +// 로귞읞 폌 섀정 +function setupLoginForm() { + const loginForm = document.getElementById('loginForm'); + if (loginForm) { + loginForm.addEventListener('submit', handleLogin); + } +} + +// 로귞읞 처늬 +async function handleLogin(event) { + event.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + if (!username || !password) { + alert('사용자명곌 비밀번혞륌 입력핎죌섞요.'); + return; + } + + try { + showLoading(true); + + // 임시 로귞읞 (백엔드 구현 전까지) + if (username === 'user1' && password === 'password123') { + const mockUser = { + id: 1, + username: 'user1', + email: 'user1@todo-project.local', + full_name: '사용자1' + }; + + currentUser = mockUser; + localStorage.setItem('authToken', 'mock-token-' + Date.now()); + localStorage.setItem('currentUser', JSON.stringify(mockUser)); + + showMainApp(); + } else { + throw new Error('잘못된 사용자명 또는 비밀번혞입니닀.'); + } + + // 싀제 API 혞출 (백엔드 구현 후 사용) + /* + const response = await AuthAPI.login(username, password); + currentUser = response.user; + showMainApp(); + */ + + } catch (error) { + console.error('로귞읞 싀팚:', error); + alert(error.message || '로귞읞에 싀팚했습니닀.'); + } finally { + showLoading(false); + } +} + +// 로귞아웃 +function logout() { + currentUser = null; + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + showLoginScreen(); +} + +// 로귞읞 화멎 표시 +function showLoginScreen() { + document.getElementById('loginScreen').classList.remove('hidden'); + document.getElementById('mainApp').classList.add('hidden'); + + // 폌 쎈Ʞ화 + const loginForm = document.getElementById('loginForm'); + if (loginForm) { + loginForm.reset(); + } +} + +// 메읞 앱 표시 +function showMainApp() { + document.getElementById('loginScreen').classList.add('hidden'); + document.getElementById('mainApp').classList.remove('hidden'); + + // 사용자 정볎 표시 + const currentUserElement = document.getElementById('currentUser'); + if (currentUserElement && currentUser) { + currentUserElement.textContent = currentUser.full_name || currentUser.username; + } + + // Todo 목록 로드 + if (typeof loadTodos === 'function') { + loadTodos(); + } +} + +// 로딩 상태 표시 +function showLoading(show) { + const loadingOverlay = document.getElementById('loadingOverlay'); + if (loadingOverlay) { + if (show) { + loadingOverlay.classList.remove('hidden'); + } else { + loadingOverlay.classList.add('hidden'); + } + } +} + +// 전역윌로 사용 가능하도록 export +window.currentUser = currentUser; +window.logout = logout; +window.showLoading = showLoading; diff --git a/frontend/static/js/image-utils.js b/frontend/static/js/image-utils.js new file mode 100644 index 0000000..ccce5d9 --- /dev/null +++ b/frontend/static/js/image-utils.js @@ -0,0 +1,134 @@ +/** + * 읎믞지 압축 및 최적화 유틞늬티 + */ + +const ImageUtils = { + /** + * 읎믞지륌 압축하고 늬사읎슈 + * @param {File|Blob|String} source - 읎믞지 파음, Blob 또는 base64 묞자엎 + * @param {Object} options - 압축 옵션 + * @returns {Promise} - 압축된 base64 읎믞지 + */ + async compressImage(source, options = {}) { + const { + maxWidth = 1024, // 최대 너비 + maxHeight = 1024, // 최대 높읎 + quality = 0.7, // JPEG 품질 (0-1) + format = 'jpeg' // 출력 형식 + } = options; + + return new Promise((resolve, reject) => { + let img = new Image(); + + // 읎믞지 로드 완료 시 + img.onload = () => { + // Canvas 생성 + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // 늬사읎슈 계산 + let { width, height } = this.calculateDimensions( + img.width, + img.height, + maxWidth, + maxHeight + ); + + // Canvas 크Ʞ 섀정 + canvas.width = width; + canvas.height = height; + + // 읎믞지 귞늬Ʞ + ctx.drawImage(img, 0, 0, width, height); + + // 압축된 읎믞지륌 base64로 변환 + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('읎믞지 압축 싀팚')); + return; + } + + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }, `image/${format}`, quality); + }; + + img.onerror = () => reject(new Error('읎믞지 로드 싀팚')); + + // 소슀 타입에 따띌 처늬 + if (typeof source === 'string') { + // Base64 묞자엎읞 겜우 + img.src = source; + } else if (source instanceof File || source instanceof Blob) { + // File 또는 Blob읞 겜우 + const reader = new FileReader(); + reader.onloadend = () => { + img.src = reader.result; + }; + reader.onerror = reject; + reader.readAsDataURL(source); + } else { + reject(new Error('지원하지 않는 읎믞지 형식')); + } + }); + }, + + /** + * 읎믞지 크Ʞ 계산 (비윚 유지) + */ + calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) { + // 원볞 크Ʞ가 제한 낎에 있윌멎 귞대로 반환 + if (originalWidth <= maxWidth && originalHeight <= maxHeight) { + return { width: originalWidth, height: originalHeight }; + } + + // 비윚 계산 + const widthRatio = maxWidth / originalWidth; + const heightRatio = maxHeight / originalHeight; + const ratio = Math.min(widthRatio, heightRatio); + + return { + width: Math.round(originalWidth * ratio), + height: Math.round(originalHeight * ratio) + }; + }, + + /** + * 파음 크Ʞ륌 사람읎 읜을 수 있는 형식윌로 변환 + */ + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }, + + /** + * Base64 묞자엎의 크Ʞ 계산 + */ + getBase64Size(base64String) { + const base64Length = base64String.length - (base64String.indexOf(',') + 1); + const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 : + ((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0); + return (base64Length * 0.75) - padding; + }, + + /** + * 읎믞지 믞늬볎Ʞ 생성 (썞넀음) + */ + async createThumbnail(source, size = 150) { + return this.compressImage(source, { + maxWidth: size, + maxHeight: size, + quality: 0.8 + }); + } +}; + +// 전역윌로 사용 가능하도록 export +window.ImageUtils = ImageUtils; diff --git a/frontend/static/js/todos.js b/frontend/static/js/todos.js new file mode 100644 index 0000000..071bcf4 --- /dev/null +++ b/frontend/static/js/todos.js @@ -0,0 +1,589 @@ +/** + * Todo ꎀ늬 Ʞ능 + */ + +let todos = []; +let currentPhoto = null; +let currentFilter = 'all'; + +// 페읎지 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', () => { + setupTodoForm(); + setupPhotoUpload(); + setupFilters(); + updateItemCounts(); + loadRegisteredItems(); +}); + +// Todo 폌 섀정 +function setupTodoForm() { + const todoForm = document.getElementById('todoForm'); + if (todoForm) { + todoForm.addEventListener('submit', handleTodoSubmit); + } +} + +// 사진 업로드 섀정 +function setupPhotoUpload() { + const cameraInput = document.getElementById('cameraInput'); + const galleryInput = document.getElementById('galleryInput'); + + if (cameraInput) { + cameraInput.addEventListener('change', handlePhotoUpload); + } + + if (galleryInput) { + galleryInput.addEventListener('change', handlePhotoUpload); + } +} + +// 필터 섀정 +function setupFilters() { + // 필터 탭 큎늭 읎벀튞는 HTML에서 onclick윌로 처늬 +} + +// Todo 제출 처늬 +async function handleTodoSubmit(event) { + event.preventDefault(); + + const content = document.getElementById('todoContent').value.trim(); + if (!content) { + alert('할음 낎용을 입력핎죌섞요.'); + return; + } + + try { + showLoading(true); + + const todoData = { + content: content, + photo: currentPhoto, + status: 'draft', + created_at: new Date().toISOString() + }; + + // 임시 저장 (백엔드 구현 전까지) + const newTodo = { + id: Date.now(), + ...todoData, + user_id: currentUser?.id || 1 + }; + + todos.unshift(newTodo); + + // 싀제 API 혞출 (백엔드 구현 후 사용) + /* + const newTodo = await TodoAPI.createTodo(todoData); + todos.unshift(newTodo); + */ + + // 폌 쎈Ʞ화 및 목록 업데읎튞 + clearForm(); + loadRegisteredItems(); + updateItemCounts(); + + // 성공 메시지 + showToast('항목읎 등록되었습니닀!', 'success'); + + } catch (error) { + console.error('할음 추가 싀팚:', error); + alert(error.message || '할음 추가에 싀팚했습니닀.'); + } finally { + showLoading(false); + } +} + +// 사진 업로드 처늬 +async function handlePhotoUpload(event) { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + + try { + showLoading(true); + + // 읎믞지 압축 + const compressedImage = await ImageUtils.compressImage(file, { + maxWidth: 800, + maxHeight: 600, + quality: 0.8 + }); + + currentPhoto = compressedImage; + + // 믞늬볎Ʞ 표시 + const previewContainer = document.getElementById('photoPreview'); + const previewImage = document.getElementById('previewImage'); + + if (previewContainer && previewImage) { + previewImage.src = compressedImage; + previewContainer.classList.remove('hidden'); + } + + } catch (error) { + console.error('읎믞지 처늬 싀팚:', error); + alert('읎믞지 처늬에 싀팚했습니닀.'); + } finally { + showLoading(false); + } +} + +// 칎메띌 ì—Žêž° +function openCamera() { + const cameraInput = document.getElementById('cameraInput'); + if (cameraInput) { + cameraInput.click(); + } +} + +// 가러늬 ì—Žêž° +function openGallery() { + const galleryInput = document.getElementById('galleryInput'); + if (galleryInput) { + galleryInput.click(); + } +} + +// 사진 제거 +function removePhoto() { + currentPhoto = null; + + const previewContainer = document.getElementById('photoPreview'); + const previewImage = document.getElementById('previewImage'); + + if (previewContainer) { + previewContainer.classList.add('hidden'); + } + + if (previewImage) { + previewImage.src = ''; + } + + // 파음 입력 쎈Ʞ화 + const cameraInput = document.getElementById('cameraInput'); + const galleryInput = document.getElementById('galleryInput'); + + if (cameraInput) cameraInput.value = ''; + if (galleryInput) galleryInput.value = ''; +} + +// 폌 쎈Ʞ화 +function clearForm() { + const todoForm = document.getElementById('todoForm'); + if (todoForm) { + todoForm.reset(); + } + + removePhoto(); +} + +// Todo 목록 로드 +async function loadTodos() { + try { + // 임시 데읎터 (백엔드 구현 전까지) + if (todos.length === 0) { + todos = [ + { + id: 1, + content: '프로젝튞 묞서 검토', + status: 'active', + photo: null, + created_at: new Date(Date.now() - 86400000).toISOString(), + user_id: 1 + }, + { + id: 2, + content: '회의 쀀비', + status: 'completed', + photo: null, + created_at: new Date(Date.now() - 172800000).toISOString(), + user_id: 1 + } + ]; + } + + // 싀제 API 혞출 (백엔드 구현 후 사용) + /* + todos = await TodoAPI.getTodos(currentFilter); + */ + + renderTodos(); + + } catch (error) { + console.error('할음 목록 로드 싀팚:', error); + showToast('할음 목록을 불러였는데 싀팚했습니닀.', 'error'); + } +} + +// Todo 목록 렌더링 +function renderTodos() { + const todoList = document.getElementById('todoList'); + const emptyState = document.getElementById('emptyState'); + + if (!todoList || !emptyState) return; + + // 필터링 + const filteredTodos = todos.filter(todo => { + if (currentFilter === 'all') return true; + if (currentFilter === 'active') return ['draft', 'scheduled', 'active', 'delayed'].includes(todo.status); + if (currentFilter === 'completed') return todo.status === 'completed'; + return todo.status === currentFilter; + }); + + // 빈 상태 처늬 + if (filteredTodos.length === 0) { + todoList.innerHTML = ''; + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + + // Todo 항목 렌더링 + todoList.innerHTML = filteredTodos.map(todo => ` +
+
+ + + + + ${todo.photo ? ` +
+ 첚부 사진 +
+ ` : ''} + + +
+

${todo.content}

+
+ + ${getStatusText(todo.status)} + + ${formatDate(todo.created_at)} +
+
+ + +
+ ${todo.status !== 'completed' ? ` + + ` : ''} + +
+
+
+ `).join(''); +} + +// Todo 상태 토Ꞁ +async function toggleTodo(id) { + try { + const todo = todos.find(t => t.id === id); + if (!todo) return; + + const newStatus = todo.status === 'completed' ? 'active' : 'completed'; + + // 임시 업데읎튞 + todo.status = newStatus; + + // 싀제 API 혞출 (백엔드 구현 후 사용) + /* + await TodoAPI.updateTodo(id, { status: newStatus }); + */ + + renderTodos(); + showToast(newStatus === 'completed' ? '할음을 완료했습니닀!' : '할음을 닀시 활성화했습니닀!', 'success'); + + } catch (error) { + console.error('할음 상태 변겜 싀팚:', error); + showToast('상태 변겜에 싀팚했습니닀.', 'error'); + } +} + +// Todo 삭제 +async function deleteTodo(id) { + if (!confirm('정말로 읎 할음을 삭제하시겠습니까?')) return; + + try { + // 임시 삭제 + todos = todos.filter(t => t.id !== id); + + // 싀제 API 혞출 (백엔드 구현 후 사용) + /* + await TodoAPI.deleteTodo(id); + */ + + renderTodos(); + showToast('할음읎 삭제되었습니닀.', 'success'); + + } catch (error) { + console.error('할음 삭제 싀팚:', error); + showToast('삭제에 싀팚했습니닀.', 'error'); + } +} + +// Todo 펞집 (향후 구현) +function editTodo(id) { + // TODO: 펞집 몚달 또는 읞띌읞 펞집 구현 + console.log('펞집 Ʞ능 구현 예정:', id); +} + +// 필터 변겜 +function filterTodos(filter) { + currentFilter = filter; + + // 탭 활성화 상태 변겜 + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.classList.remove('active', 'bg-white', 'text-blue-600'); + tab.classList.add('text-gray-600'); + }); + + event.target.classList.add('active', 'bg-white', 'text-blue-600'); + event.target.classList.remove('text-gray-600'); + + renderTodos(); +} + +// 상태 아읎윘 반환 +function getStatusIcon(status) { + const icons = { + draft: 'fa-edit', + scheduled: 'fa-calendar', + active: 'fa-play', + completed: 'fa-check', + delayed: 'fa-clock' + }; + return icons[status] || 'fa-circle'; +} + +// 상태 텍슀튞 반환 +function getStatusText(status) { + const texts = { + draft: '검토 필요', + scheduled: '예정됚', + active: '진행쀑', + completed: '완료됚', + delayed: '지연됚' + }; + return texts[status] || '알 수 없음'; +} + +// 날짜 포맷팅 +function formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = now - date; + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return '였늘'; + if (diffDays === 1) return 'ì–Žì œ'; + if (diffDays < 7) return `${diffDays}음 전`; + + return date.toLocaleDateString('ko-KR'); +} + +// 토슀튞 메시지 표시 +function showToast(message, type = 'info') { + // 간닚한 alert윌로 대첎 (향후 토슀튞 UI 구현) + console.log(`[${type.toUpperCase()}] ${message}`); + + if (type === 'error') { + alert(message); + } +} + +// 페읎지 읎동 핚수 +function goToPage(pageType) { + const pages = { + 'todo': 'todo.html', + 'calendar': 'calendar.html', + 'checklist': 'checklist.html' + }; + + if (pages[pageType]) { + window.location.href = pages[pageType]; + } else { + console.error('Unknown page type:', pageType); + } +} + +// 대시볎드로 읎동 +function goToDashboard() { + window.location.href = 'dashboard.html'; +} + +// 분류 섌터로 읎동 +function goToClassify() { + window.location.href = 'classify.html'; +} + +// 항목 등록 후 읞덱슀 업데읎튞 +function updateItemCounts() { + // TODO: API에서 각 분류별 항목 수륌 가젞와서 업데읎튞 + // 임시로 하드윔딩된 값 사용 + const todoCount = document.getElementById('todoCount'); + const calendarCount = document.getElementById('calendarCount'); + const checklistCount = document.getElementById('checklistCount'); + + if (todoCount) todoCount.textContent = '2개'; + if (calendarCount) calendarCount.textContent = '3개'; + if (checklistCount) checklistCount.textContent = '5개'; +} + +// 등록된 항목듀 로드 +function loadRegisteredItems() { + // 임시 데읎터 (싀제로는 API에서 가젞옎) + const sampleItems = [ + { + id: 1, + content: '프로젝튞 묞서 정늬', + photo_url: null, + category: null, + created_at: '2024-01-15' + }, + { + id: 2, + content: '회의 자료 쀀비', + photo_url: null, + category: 'todo', + created_at: '2024-01-16' + }, + { + id: 3, + content: '월말 볎고서 작성', + photo_url: null, + category: 'calendar', + created_at: '2024-01-17' + } + ]; + + renderRegisteredItems(sampleItems); +} + +// 등록된 항목듀 렌더링 +function renderRegisteredItems(items) { + const itemsList = document.getElementById('itemsList'); + const emptyState = document.getElementById('emptyState'); + + if (!itemsList || !emptyState) return; + + if (!items || items.length === 0) { + itemsList.innerHTML = ''; + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + + itemsList.innerHTML = items.map(item => ` +
+
+ + ${item.photo_url ? ` +
+ 첚부 사진 +
+ ` : ''} + + +
+

${item.content}

+
+ + 등록: ${formatDate(item.created_at)} + + ${item.category ? ` + + ${getCategoryText(item.category)} + + ` : ` + + 믞분류 + + `} +
+
+ + +
+ +
+
+
+ `).join(''); +} + +// 분류 몚달 표시 +function showClassificationModal(itemId) { + // TODO: 분류 선택 몚달 구현 + console.log('분류 몚달 표시:', itemId); + + // 임시로 confirm윌로 분류 선택 + const choice = prompt('분류륌 선택하섞요:\n1. Todo (시작 날짜)\n2. 캘늰더 (마감 Ʞ한)\n3. 첎크늬슀튞 (Ʞ한 없음)\n\n번혞륌 입력하섞요:'); + + if (choice) { + const categories = { + '1': 'todo', + '2': 'calendar', + '3': 'checklist' + }; + + const category = categories[choice]; + if (category) { + classifyItem(itemId, category); + } + } +} + +// 항목 분류 +function classifyItem(itemId, category) { + // TODO: API 혞출하여 항목 분류 업데읎튞 + console.log('항목 분류:', itemId, category); + + // 분류 후 핎당 페읎지로 읎동 + goToPage(category); +} + +// 분류별 색상 +function getCategoryColor(category) { + const colors = { + 'todo': 'bg-blue-100 text-blue-800', + 'calendar': 'bg-orange-100 text-orange-800', + 'checklist': 'bg-green-100 text-green-800' + }; + return colors[category] || 'bg-gray-100 text-gray-800'; +} + +// 분류별 텍슀튞 +function getCategoryText(category) { + const texts = { + 'todo': 'Todo', + 'calendar': '캘늰더', + 'checklist': '첎크늬슀튞' + }; + return texts[category] || '믞분류'; +} + +// 전역윌로 사용 가능하도록 export +window.loadTodos = loadTodos; +window.openCamera = openCamera; +window.openGallery = openGallery; +window.removePhoto = removePhoto; +window.clearForm = clearForm; +window.toggleTodo = toggleTodo; +window.deleteTodo = deleteTodo; +window.editTodo = editTodo; +window.filterTodos = filterTodos; +window.goToPage = goToPage; +window.goToDashboard = goToDashboard; +window.goToClassify = goToClassify; +window.showClassificationModal = showClassificationModal; +window.updateItemCounts = updateItemCounts; diff --git a/frontend/todo.html b/frontend/todo.html new file mode 100644 index 0000000..6b86c77 --- /dev/null +++ b/frontend/todo.html @@ -0,0 +1,310 @@ + + + + + + Todo - 시작 날짜가 있는 음듀 + + + + + +
+ +
+
+
+
+ + +

Todo

+ 시작 날짜가 있는 음듀 +
+ +
+ + + +
+
+
+
+ + +
+ +
+
+ +

Todo ꎀ늬

+
+

+ 시작 날짜가 정핎진 음듀을 ꎀ늬합니닀. ì–žì œ 시작할지 계획을 섞우고 싀행핎볎섞요. +

+
+
+
📅 시작 예정
+
아직 시작하지 않은 음듀
+
+
+
🔥 진행 쀑
+
현재 작업 쀑읞 음듀
+
+
+
✅ 완료
+
완료된 음듀
+
+
+
+ + +
+
+
+ + + + +
+ +
+ + +
+
+
+ + +
+
+

+ Todo 목록 +

+
+ +
+ +
+ +
+ +

아직 시작 날짜가 섀정된 음읎 없습니닀.

+

메읞 페읎지에서 항목을 등록하고 시작 날짜륌 섀정핎볎섞요!

+ +
+
+
+
+ + + + + + diff --git a/generate_icons.py b/generate_icons.py new file mode 100644 index 0000000..09ff89a --- /dev/null +++ b/generate_icons.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +PWA 아읎윘 생성 슀크늜튞 +원볞 읎믞지륌 닀양한 크Ʞ의 아읎윘윌로 변환 +""" + +from PIL import Image, ImageDraw, ImageFilter +import os + +def create_rounded_icon(image, size, corner_radius=None): + """둥귌 몚서늬 아읎윘 생성""" + if corner_radius is None: + corner_radius = size // 8 # Ʞ볞값: 크Ʞ의 1/8 + + # 읎믞지 늬사읎슈 + img = image.resize((size, size), Image.Resampling.LANCZOS) + + # 마슀크 생성 (둥귌 사각형) + mask = Image.new('L', (size, size), 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle([0, 0, size, size], corner_radius, fill=255) + + # 마슀크 적용 + result = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + result.paste(img, (0, 0)) + result.putalpha(mask) + + return result + +def main(): + # 원볞 읎믞지 로드 + source_image = "DSCF0333.RAF_compressed.JPEG" + if not os.path.exists(source_image): + print(f"❌ 원볞 읎믞지륌 찟을 수 없습니닀: {source_image}") + return + + try: + with Image.open(source_image) as img: + # RGB로 변환 (RGBA가 아닌 겜우) + if img.mode != 'RGB': + img = img.convert('RGB') + + # 정사각형윌로 크롭 (쀑앙 Ʞ쀀) + width, height = img.size + size = min(width, height) + left = (width - size) // 2 + top = (height - size) // 2 + img = img.crop((left, top, left + size, top + size)) + + # 아읎윘 크Ʞ 목록 + icon_sizes = [ + (72, "icon-72x72.png"), + (96, "icon-96x96.png"), + (128, "icon-128x128.png"), + (144, "icon-144x144.png"), + (152, "icon-152x152.png"), + (192, "icon-192x192.png"), + (384, "icon-384x384.png"), + (512, "icon-512x512.png") + ] + + # Apple Touch Icon (둥귌 몚서늬 없음) + apple_sizes = [ + (180, "apple-touch-icon.png"), + (167, "apple-touch-icon-ipad.png") + ] + + # 아읎윘 디렉토늬 생성 + icons_dir = "frontend/static/icons" + os.makedirs(icons_dir, exist_ok=True) + + print("🎚 PWA 아읎윘 생성 쀑...") + + # PWA 아읎윘 생성 (둥귌 몚서늬) + for size, filename in icon_sizes: + icon = create_rounded_icon(img, size) + icon_path = os.path.join(icons_dir, filename) + icon.save(icon_path, "PNG", optimize=True) + print(f"✅ {filename} ({size}x{size})") + + # Apple Touch Icon 생성 (둥귌 몚서늬 없음) + for size, filename in apple_sizes: + icon = img.resize((size, size), Image.Resampling.LANCZOS) + icon_path = os.path.join(icons_dir, filename) + icon.save(icon_path, "PNG", optimize=True) + print(f"✅ {filename} ({size}x{size})") + + # 파비윘 생성 + favicon_sizes = [(16, 16), (32, 32), (48, 48)] + favicon_images = [] + + for size, _ in favicon_sizes: + favicon = img.resize((size, size), Image.Resampling.LANCZOS) + favicon_images.append(favicon) + + # 멀티 사읎슈 favicon.ico 생성 + favicon_path = "frontend/favicon.ico" + favicon_images[0].save( + favicon_path, + format='ICO', + sizes=[(16, 16), (32, 32), (48, 48)], + append_images=favicon_images[1:] + ) + print(f"✅ favicon.ico (16x16, 32x32, 48x48)") + + print(f"\n🎉 쎝 {len(icon_sizes) + len(apple_sizes) + 1}개의 아읎윘읎 생성되었습니닀!") + print(f"📁 아읎윘 위치: {icons_dir}/") + + except Exception as e: + print(f"❌ 아읎윘 생성 싀팚: {e}") + +if __name__ == "__main__": + main()