diff --git a/README.md b/README.md index ed08e06..06dd221 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,193 @@ # ๐Ÿ“ฑ Todo Project -๊ฐ„๋‹จํ•˜๊ณ  ํšจ์œจ์ ์ธ ํ• ์ผ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +**๋ฉ”๋ชจ โ†’ ์ˆ˜์‹ ํ•จ โ†’ Todo ๊ด€๋ฆฌ**์˜ ๊ฐ„๋‹จํ•˜๊ณ  ์ง๊ด€์ ์ธ ์›Œํฌํ”Œ๋กœ์šฐ + +## ๐ŸŽฏ ํ•ต์‹ฌ ์›Œํฌํ”Œ๋กœ์šฐ + +``` +๐Ÿ“ ๋ฉ”๋ชจ ์ž‘์„ฑ โ†’ ๐Ÿ“ฅ ์ˆ˜์‹ ํ•จ ํ™•์ธ โ†’ โœ… Todo ๋ณ€ํ™˜ โ†’ ๐Ÿ“‹ Todo ๊ด€๋ฆฌ +``` + +### 3๊ฐœ ํŽ˜์ด์ง€๋กœ ์™„์„ฑ๋˜๋Š” ์‹ฌํ”Œํ•œ ๊ตฌ์กฐ: +1. **๐Ÿ“ ์ƒˆ ๋ฉ”๋ชจ** (`/upload.html`) - ๋น ๋ฅธ ๋ฉ”๋ชจ ์ž‘์„ฑ +2. **๐Ÿ“ฅ ์ˆ˜์‹ ํ•จ** (`/inbox.html`) - ๋ฉ”๋ชจ ํ™•์ธ & Todo ๋ณ€ํ™˜ +3. **๐Ÿ“‹ Todo ๋ชฉ๋ก** (`/todo-list.html`) - ์˜ค๋Š˜ ํ•  ์ผ ๊ด€๋ฆฌ ## โœจ ์ฃผ์š” ๊ธฐ๋Šฅ -- ๐Ÿ“Š **๋ฐ˜์‘ํ˜• ๋Œ€์‹œ๋ณด๋“œ**: ๋ฐ์Šคํฌํ†ฑ/๋ชจ๋ฐ”์ผ ์ตœ์ ํ™” -- ๐Ÿ“ฅ **์Šค๋งˆํŠธ ๋ถ„๋ฅ˜**: AI ๊ธฐ๋ฐ˜ ์ž๋™ ๋ถ„๋ฅ˜ ์ œ์•ˆ -- ๐Ÿ“ท **์ด๋ฏธ์ง€ ์—…๋กœ๋“œ**: ์‚ฌ์ง„๊ณผ ํ•จ๊ป˜ ๋ฉ”๋ชจ ๊ด€๋ฆฌ -- ๐Ÿท๏ธ **3๊ฐ€์ง€ ๋ถ„๋ฅ˜**: Todo, ์บ˜๋ฆฐ๋”, ์ฒดํฌ๋ฆฌ์ŠคํŠธ -- ๐Ÿ“ฑ **PWA ์ง€์›**: ํ™ˆํ™”๋ฉด ์ถ”๊ฐ€ ๊ฐ€๋Šฅ -- ๐Ÿ”„ **์‹œ๋†€๋กœ์ง€ ์—ฐ๋™**: ๋ฉ”์ผํ”Œ๋Ÿฌ์Šค ์ž๋™ ์—ฐ๋™ +- ๐Ÿ–Š๏ธ **๋น ๋ฅธ ๋ฉ”๋ชจ ์ž‘์„ฑ**: ํ…์ŠคํŠธ + ์ด๋ฏธ์ง€ (์ตœ๋Œ€ 5์žฅ) +- ๐Ÿ“… **์‹œ์ž‘์ผ ๊ธฐ๋ฐ˜ Todo**: ํ•ด์•ผ ํ•  ์‹œ์ ์ด ๋œ Todo๋งŒ ํ‘œ์‹œ +- ๐Ÿท๏ธ **์Šค๋งˆํŠธ ์ƒํƒœ ๊ด€๋ฆฌ**: ์˜ค๋Š˜ ์‹œ์ž‘ / ์ง„ํ–‰ ์ค‘ ์ž๋™ ๊ตฌ๋ถ„ +- ๐Ÿ“ฑ **๋ชจ๋ฐ”์ผ ์ตœ์ ํ™”**: ์นด๋ฉ”๋ผ/๊ฐค๋Ÿฌ๋ฆฌ ์—…๋กœ๋“œ ์ง€์› +- ๐Ÿ”„ **์ง€์—ฐ ๊ด€๋ฆฌ**: +3์ผ, +5์ผ, ๋‚ ์งœ ์„ ํƒ ์—ฐ์žฅ +- ๐ŸŽจ **๋นˆํ‹ฐ์ง€ UI**: ์–‘ํ”ผ์ง€ ํ…Œ๋งˆ์˜ ์•„๋ฆ„๋‹ค์šด ์ธํ„ฐํŽ˜์ด์Šค ## ๐Ÿš€ ๋น ๋ฅธ ์‹œ์ž‘ +```bash +# ํ”„๋กœ์ ํŠธ ํด๋ก  +git clone https://git.hyungi.net/hyungi/Todo-Project.git +cd Todo-Project + +# Docker๋กœ ์‹คํ–‰ +docker-compose up -d + +# ์ ‘์† +open http://localhost:4000 +``` + +**๊ธฐ๋ณธ ๊ณ„์ •**: `hyungi` / `admin` + +## ๐Ÿ—๏ธ ๊ธฐ์ˆ  ์Šคํƒ + +### Frontend +- **HTML5/CSS3/JavaScript**: ๋ฐ”๋‹๋ผ JS๋กœ ๊ฐ€๋ฒผ์šด ๊ตฌํ˜„ +- **Tailwind CSS**: ๋น ๋ฅธ ์Šคํƒ€์ผ๋ง +- **PWA**: ํ™ˆํ™”๋ฉด ์ถ”๊ฐ€ ์ง€์› +- **Nginx**: ์ •์  ํŒŒ์ผ ์„œ๋น™ & API ํ”„๋ก์‹œ + +### Backend +- **FastAPI**: ๊ณ ์„ฑ๋Šฅ Python API +- **SQLAlchemy**: ๋น„๋™๊ธฐ ORM +- **PostgreSQL**: ์•ˆ์ •์ ์ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค +- **Pydantic**: ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + +### Infrastructure +- **Docker Compose**: ์ปจํ…Œ์ด๋„ˆ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ +- **Nginx**: ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ +- **Volume**: ๋ฐ์ดํ„ฐ ์˜์†์„ฑ + +## ๐Ÿ“Š ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์กฐ + +### ๐Ÿ“‹ todos ํ…Œ์ด๋ธ” +```sql +Column | Type | ์„ค๋ช… +-------------|--------------------------|------------------ +id | uuid | ๊ณ ์œ  ID +user_id | uuid | ์‚ฌ์šฉ์ž ID +title | varchar(200) | ์ œ๋ชฉ (๋ฉ”๋ชจ๋Š” ์„ ํƒ์‚ฌํ•ญ) +description | text | ๋‚ด์šฉ (ํ•„์ˆ˜) +category | enum | MEMO | TODO +status | enum | pending | completed +start_date | timestamp | Todo ์‹œ์ž‘์ผ +image_urls | text | ์ด๋ฏธ์ง€ URLs (JSON) +created_at | timestamp | ์ƒ์„ฑ์ผ +updated_at | timestamp | ์ˆ˜์ •์ผ +completed_at | timestamp | ์™„๋ฃŒ์ผ +``` + +### ๐Ÿš€ ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ธ๋ฑ์Šค +- `idx_todos_workflow`: ๋ณตํ•ฉ ์›Œํฌํ”Œ๋กœ์šฐ ์กฐํšŒ ์ตœ์ ํ™” +- `idx_todos_start_date`: ์‹œ์ž‘์ผ ๊ธฐ์ค€ ์กฐํšŒ +- `idx_todos_category_status`: ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ƒํƒœ ์กฐํšŒ + +## ๐Ÿ”„ API ์—”๋“œํฌ์ธํŠธ + +### ์ธ์ฆ +- `POST /api/auth/login` - ๋กœ๊ทธ์ธ +- `GET /api/auth/me` - ์‚ฌ์šฉ์ž ์ •๋ณด + +### Todo/๋ฉ”๋ชจ ๊ด€๋ฆฌ +- `POST /api/todos` - ๋ฉ”๋ชจ/Todo ์ƒ์„ฑ +- `GET /api/todos?category=memo` - ๋ฉ”๋ชจ ๋ชฉ๋ก (์ˆ˜์‹ ํ•จ) +- `GET /api/todos?category=todo` - Todo ๋ชฉ๋ก +- `PUT /api/todos/{id}` - Todo ์ˆ˜์ • (์นดํ…Œ๊ณ ๋ฆฌ ๋ณ€ํ™˜, ์ƒํƒœ ๋ณ€๊ฒฝ) +- `POST /api/todos/upload-image` - ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + +## ๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ์ง€์› + +### PWA ๊ธฐ๋Šฅ +- ํ™ˆํ™”๋ฉด ์ถ”๊ฐ€ ๊ฐ€๋Šฅ +- ์˜คํ”„๋ผ์ธ ์ง€์› (์˜ˆ์ •) +- ํ‘ธ์‹œ ์•Œ๋ฆผ (์˜ˆ์ •) + +### ๋ชจ๋ฐ”์ผ ์ตœ์ ํ™” +- ํ„ฐ์น˜ ์นœํ™”์  UI +- ์นด๋ฉ”๋ผ/๊ฐค๋Ÿฌ๋ฆฌ ์ ‘๊ทผ +- ํ‚ค๋ณด๋“œ ๋Œ€์‘ ์Šคํฌ๋กค +- ์ด๋ฏธ์ง€ ์ž๋™ ์••์ถ• + +## ๐ŸŽจ UI/UX ํŠน์ง• + +### ๋นˆํ‹ฐ์ง€ ์–‘ํ”ผ์ง€ ํ…Œ๋งˆ +- ๋”ฐ๋œปํ•œ ์„ธํ”ผ์•„ ์ƒ‰์ƒ +- ์†๊ธ€์”จ ๋А๋‚Œ์˜ ํฐํŠธ +- ๊ทธ๋ฆผ์ž์™€ ํ…Œ๋‘๋ฆฌ ํšจ๊ณผ +- ์ง๊ด€์ ์ธ ์•„์ด์ฝ˜ + +### ๋ฐ˜์‘ํ˜• ๋””์ž์ธ +- ๋ฐ์Šคํฌํ†ฑ: ๋„“์€ ๋ ˆ์ด์•„์›ƒ +- ๋ชจ๋ฐ”์ผ: ์„ธ๋กœ ์ตœ์ ํ™” +- ํ„ฐ์น˜ ์ œ์Šค์ฒ˜ ์ง€์› + +## ๐Ÿ”ง ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์„ค์ • + +### ๋กœ์ปฌ ๊ฐœ๋ฐœ +```bash +# ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ +cd backend +pip install -e . +uvicorn src.main:app --reload --port 9000 + +# ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ +cd frontend +python -m http.server 8000 +``` + +### ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +```env +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค +DATABASE_URL=postgresql+asyncpg://todo_user:todo_password@localhost:5432/todo_db + +# JWT ์„ค์ • +SECRET_KEY=your-secret-key +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# ํŒŒ์ผ ์—…๋กœ๋“œ +UPLOAD_DIR=/data/uploads +MAX_FILE_SIZE=5242880 # 5MB +``` + +## ๐Ÿ“ˆ ์„ฑ๋Šฅ ์ตœ์ ํ™” + +### ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค +- ๋ณตํ•ฉ ์ธ๋ฑ์Šค๋กœ ์ฟผ๋ฆฌ ์ตœ์ ํ™” +- ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ถ„๋ฆฌ๋กœ ํšจ์œจ์  ์กฐํšŒ +- ์ด๋ฏธ์ง€ URL JSON ์ €์žฅ์œผ๋กœ ์ •๊ทœํ™” ์ตœ์†Œํ™” + +### ํ”„๋ก ํŠธ์—”๋“œ +- ๋ฐ”๋‹๋ผ JS๋กœ ๋ฒˆ๋“ค ํฌ๊ธฐ ์ตœ์†Œํ™” +- ์ด๋ฏธ์ง€ ํด๋ผ์ด์–ธํŠธ ์••์ถ• +- ๋ถˆํ•„์š”ํ•œ ๋กœ๊น… ์ œ๊ฑฐ +- Nginx ์ •์  ํŒŒ์ผ ์บ์‹ฑ + +## ๐Ÿš€ ๋ฐฐํฌ + +### Docker Compose (๊ถŒ์žฅ) ```bash docker-compose up -d ``` -์ ‘์†: http://localhost:4000 +### ์‹œ๋†€๋กœ์ง€ NAS +์ž์„ธํ•œ ์„ค์น˜ ๊ฐ€์ด๋“œ: [SYNOLOGY_INSTALL.md](SYNOLOGY_INSTALL.md) -## ๐Ÿ“‹ ์š”๊ตฌ์‚ฌํ•ญ +## ๐Ÿ“– ์ถ”๊ฐ€ ๋ฌธ์„œ -- Docker & Docker Compose -- Python 3.11+ -- PostgreSQL 15+ +- [๐Ÿ“š ์ข…ํ•ฉ ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ](COMPREHENSIVE_GUIDE.md) - ์ƒ์„ธํ•œ ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ +- [๐Ÿ  ์‹œ๋†€๋กœ์ง€ ์„ค์น˜](SYNOLOGY_INSTALL.md) - NAS ์„ค์น˜ ๋ฐฉ๋ฒ• -## ๐Ÿ“– ์ž์„ธํ•œ ๊ฐ€์ด๋“œ +## ๐Ÿค ๊ธฐ์—ฌํ•˜๊ธฐ -- [์ข…ํ•ฉ ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ](COMPREHENSIVE_GUIDE.md) -- [์‹œ๋†€๋กœ์ง€ ์„ค์น˜ ๊ฐ€์ด๋“œ](SYNOLOGY_INSTALL.md) +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## ๐Ÿ“„ ๋ผ์ด์„ ์Šค + +์ด ํ”„๋กœ์ ํŠธ๋Š” MIT ๋ผ์ด์„ ์Šค ํ•˜์— ๋ฐฐํฌ๋ฉ๋‹ˆ๋‹ค. + +## ๐Ÿ“ž ๋ฌธ์˜ + +ํ”„๋กœ์ ํŠธ ๋งํฌ: [https://git.hyungi.net/hyungi/Todo-Project](https://git.hyungi.net/hyungi/Todo-Project) diff --git a/SYNOLOGY_DEPLOYMENT_GUIDE.md b/SYNOLOGY_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..baf9f59 --- /dev/null +++ b/SYNOLOGY_DEPLOYMENT_GUIDE.md @@ -0,0 +1,326 @@ +# ๐Ÿš€ Todo-Project ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ๊ฐ€์ด๋“œ + +## ๐Ÿ“‹ ๋ชฉ์ฐจ +1. [์‚ฌ์ „ ์ค€๋น„](#์‚ฌ์ „-์ค€๋น„) +2. [์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์„ค์ •](#์‹œ๋†€๋กœ์ง€-ํ™˜๊ฒฝ-์„ค์ •) +3. [ํ”„๋กœ์ ํŠธ ๋ฐฐํฌ](#ํ”„๋กœ์ ํŠธ-๋ฐฐํฌ) +4. [ํ™˜๊ฒฝ ์„ค์ •](#ํ™˜๊ฒฝ-์„ค์ •) +5. [๋ฐฐํฌ ์‹คํ–‰](#๋ฐฐํฌ-์‹คํ–‰) +6. [์ ‘์† ๋ฐ ํ™•์ธ](#์ ‘์†-๋ฐ-ํ™•์ธ) +7. [๋ฌธ์ œ ํ•ด๊ฒฐ](#๋ฌธ์ œ-ํ•ด๊ฒฐ) +8. [์œ ์ง€๋ณด์ˆ˜](#์œ ์ง€๋ณด์ˆ˜) + +--- + +## ๐Ÿ› ๏ธ ์‚ฌ์ „ ์ค€๋น„ + +### ์‹œ๋†€๋กœ์ง€ ์š”๊ตฌ์‚ฌํ•ญ +- **DSM 7.0 ์ด์ƒ** +- **Docker ํŒจํ‚ค์ง€ ์„ค์น˜** +- **Container Manager ์„ค์น˜** (DSM 7.2+) ๋˜๋Š” **Docker ํŒจํ‚ค์ง€** (DSM 7.1 ์ดํ•˜) +- **์ตœ์†Œ 2GB RAM** (๊ถŒ์žฅ: 4GB ์ด์ƒ) +- **์ตœ์†Œ 5GB ์ €์žฅ๊ณต๊ฐ„** + +### ํ•„์š”ํ•œ ํฌํŠธ +- **4000**: ํ”„๋ก ํŠธ์—”๋“œ (์›น ์ธํ„ฐํŽ˜์ด์Šค) +- **9000**: ๋ฐฑ์—”๋“œ API +- **5432**: PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค + +--- + +## ๐Ÿ—๏ธ ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ์„ค์ • + +### 1. ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ + +SSH ๋˜๋Š” File Station์„ ํ†ตํ•ด ๋‹ค์Œ ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”: + +```bash +# ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ €์žฅ์†Œ (volume1 - ๋น ๋ฅธ ์•ก์„ธ์Šค) +sudo mkdir -p /volume1/todo-project/uploads +sudo chmod 755 /volume1/todo-project/uploads + +# ์„ค์ • ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ €์žฅ์†Œ (volume3) +sudo mkdir -p /volume3/docker/todo-project/config +sudo mkdir -p /volume3/docker/todo-project/postgres +sudo mkdir -p /volume3/docker/todo-project/app + +# ๊ถŒํ•œ ์„ค์ • +sudo chown -R 1000:1000 /volume1/todo-project +sudo chown -R 999:999 /volume3/docker/todo-project/postgres +sudo chown -R 1000:1000 /volume3/docker/todo-project/config +``` + +### 2. ๋ฐฉํ™”๋ฒฝ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ) + +DSM > ์ œ์–ดํŒ > ๋ณด์•ˆ > ๋ฐฉํ™”๋ฒฝ์—์„œ ๋‹ค์Œ ํฌํŠธ๋ฅผ ํ—ˆ์šฉํ•˜์„ธ์š”: +- **4000/TCP** (Todo-Project ์›น ์ธํ„ฐํŽ˜์ด์Šค) +- **9000/TCP** (API ์„œ๋ฒ„) + +--- + +## ๐Ÿ“ฆ ํ”„๋กœ์ ํŠธ ๋ฐฐํฌ + +### ๋ฐฉ๋ฒ• 1: Git Clone (๊ถŒ์žฅ) + +```bash +# ์‹œ๋†€๋กœ์ง€์— SSH ์ ‘์† ํ›„ +cd /volume3/docker/todo-project/app +git clone https://github.com/your-username/Todo-Project.git . + +# ๋˜๋Š” ํŠน์ • ๋ธŒ๋žœ์น˜ +git clone -b main https://github.com/your-username/Todo-Project.git . +``` + +### ๋ฐฉ๋ฒ• 2: ํŒŒ์ผ ์—…๋กœ๋“œ + +1. **๋กœ์ปฌ์—์„œ ํ”„๋กœ์ ํŠธ ์••์ถ•**: + ```bash + tar -czf todo-project.tar.gz --exclude='.git' --exclude='node_modules' --exclude='__pycache__' . + ``` + +2. **์‹œ๋†€๋กœ์ง€๋กœ ์—…๋กœ๋“œ**: + - File Station์„ ํ†ตํ•ด `/volume3/docker/todo-project/app/`์— ์—…๋กœ๋“œ + - ์••์ถ• ํ•ด์ œ: `tar -xzf todo-project.tar.gz` + +--- + +## โš™๏ธ ํ™˜๊ฒฝ ์„ค์ • + +### 1. ํ™˜๊ฒฝ ํŒŒ์ผ ์„ค์ • + +```bash +cd /volume3/docker/todo-project/app +cp env.synology.example .env +``` + +### 2. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ˆ˜์ • + +`.env` ํŒŒ์ผ์„ ํŽธ์ง‘ํ•˜์—ฌ ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ์— ๋งž๊ฒŒ ์„ค์ •: + +```bash +# ํ•„์ˆ˜ ์„ค์ • (๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝ!) +SECRET_KEY=your-very-long-and-random-secret-key-for-production +POSTGRES_PASSWORD=your-secure-database-password-123 + +# ํฌํŠธ ์„ค์ • (ํ•„์š”์‹œ ๋ณ€๊ฒฝ) +FRONTEND_PORT=4000 +BACKEND_PORT=9000 +DATABASE_PORT=5432 + +# ์‹œ๋†€๋กœ์ง€ ๋ณผ๋ฅจ ๊ฒฝ๋กœ (๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ ๊ถŒ์žฅ) +SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads +SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config +SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres + +# CORS ์„ค์ • (์‹œ๋†€๋กœ์ง€ IP๋กœ ๋ณ€๊ฒฝ) +CORS_ORIGINS=["http://192.168.1.100:4000", "http://localhost:4000"] + +# ํ”„๋กœ๋•์…˜ ์„ค์ • +DEBUG=false +``` + +### 3. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋ณต์‚ฌ + +```bash +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ ๋ณต์‚ฌ +cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/ +``` + +--- + +## ๐Ÿš€ ๋ฐฐํฌ ์‹คํ–‰ + +### 1. Docker Compose ์‹คํ–‰ + +```bash +cd /volume3/docker/todo-project/app + +# ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ +docker-compose up -d --build + +# ๋กœ๊ทธ ํ™•์ธ +docker-compose logs -f +``` + +### 2. ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ + +```bash +# ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +docker-compose ps + +# ๊ฐœ๋ณ„ ์„œ๋น„์Šค ๋กœ๊ทธ ํ™•์ธ +docker-compose logs backend +docker-compose logs frontend +docker-compose logs database +``` + +### 3. ํ—ฌ์Šค์ฒดํฌ ํ™•์ธ + +```bash +# ๋ฐฑ์—”๋“œ API ํ™•์ธ +curl http://localhost:9000/health + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ™•์ธ +docker-compose exec database pg_isready -U todo_user -d todo_db +``` + +--- + +## ๐ŸŒ ์ ‘์† ๋ฐ ํ™•์ธ + +### 1. ์›น ์ธํ„ฐํŽ˜์ด์Šค ์ ‘์† + +๋ธŒ๋ผ์šฐ์ €์—์„œ ๋‹ค์Œ ์ฃผ์†Œ๋กœ ์ ‘์†: +- **๋กœ์ปฌ**: `http://์‹œ๋†€๋กœ์ง€IP:4000` +- **์˜ˆ์‹œ**: `http://192.168.1.100:4000` + +### 2. ์ดˆ๊ธฐ ์„ค์ • + +1. **๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ**: ์ตœ์ดˆ ์ ‘์† ์‹œ ๊ด€๋ฆฌ์ž ๊ณ„์ •์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค +2. **๋กœ๊ทธ์ธ**: ์ƒ์„ฑํ•œ ๊ณ„์ •์œผ๋กœ ๋กœ๊ทธ์ธ +3. **๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ**: ๋ฉ”๋ชจ ์ž‘์„ฑ, ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๋“ฑ ๊ธฐ๋ณธ ๊ธฐ๋Šฅ ํ™•์ธ + +### 3. ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ) + +DSM > ์ œ์–ดํŒ > ๋กœ๊ทธ์ธ ํฌํ„ธ > ๊ณ ๊ธ‰ > ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ์—์„œ: + +``` +์†Œ์Šค: +- ํ”„๋กœํ† ์ฝœ: HTTPS +- ํ˜ธ์ŠคํŠธ ์ด๋ฆ„: your-domain.synology.me +- ํฌํŠธ: 443 +- ๊ฒฝ๋กœ: /todo + +๋Œ€์ƒ: +- ํ”„๋กœํ† ์ฝœ: HTTP +- ํ˜ธ์ŠคํŠธ ์ด๋ฆ„: localhost +- ํฌํŠธ: 4000 +``` + +--- + +## ๐Ÿ”ง ๋ฌธ์ œ ํ•ด๊ฒฐ + +### ์ผ๋ฐ˜์ ์ธ ๋ฌธ์ œ๋“ค + +#### 1. ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ + +```bash +# ๋กœ๊ทธ ํ™•์ธ +docker-compose logs + +# ๊ฐœ๋ณ„ ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ +docker-compose restart backend +docker-compose restart database +``` + +#### 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์˜ค๋ฅ˜ + +```bash +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +docker-compose exec database pg_isready -U todo_user + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํ™•์ธ +docker-compose exec backend env | grep DATABASE_URL +``` + +#### 3. ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์‹คํŒจ + +```bash +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ๊ถŒํ•œ ํ™•์ธ +ls -la /volume1/todo-project/uploads/ + +# ๊ถŒํ•œ ์ˆ˜์ • +sudo chown -R 1000:1000 /volume1/todo-project/uploads/ +sudo chmod -R 755 /volume1/todo-project/uploads/ +``` + +#### 4. CORS ์˜ค๋ฅ˜ + +`.env` ํŒŒ์ผ์—์„œ `CORS_ORIGINS`์— ์‹œ๋†€๋กœ์ง€ IP ์ถ”๊ฐ€: +```bash +CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me"] +``` + +### ๋กœ๊ทธ ํ™•์ธ ๋ช…๋ น์–ด + +```bash +# ์ „์ฒด ๋กœ๊ทธ +docker-compose logs -f + +# ํŠน์ • ์„œ๋น„์Šค ๋กœ๊ทธ +docker-compose logs -f backend +docker-compose logs -f database + +# ์ตœ๊ทผ ๋กœ๊ทธ๋งŒ +docker-compose logs --tail=50 backend +``` + +--- + +## ๐Ÿ”„ ์œ ์ง€๋ณด์ˆ˜ + +### ์—…๋ฐ์ดํŠธ + +```bash +cd /volume3/docker/todo-project/app + +# Git์œผ๋กœ ์ตœ์‹  ์ฝ”๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ +git pull origin main + +# ์ปจํ…Œ์ด๋„ˆ ์žฌ๋นŒ๋“œ ๋ฐ ์žฌ์‹œ์ž‘ +docker-compose down +docker-compose up -d --build +``` + +### ๋ฐฑ์—… + +```bash +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… +docker-compose exec database pg_dump -U todo_user todo_db > backup_$(date +%Y%m%d).sql + +# ์—…๋กœ๋“œ๋œ ์ด๋ฏธ์ง€ ๋ฐฑ์—… +tar -czf uploads_backup_$(date +%Y%m%d).tar.gz /volume1/todo-project/uploads/ +``` + +### ๋ชจ๋‹ˆํ„ฐ๋ง + +```bash +# ์ปจํ…Œ์ด๋„ˆ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ +docker stats + +# ๋””์Šคํฌ ์‚ฌ์šฉ๋Ÿ‰ +df -h /volume1/todo-project/ +df -h /volume3/docker/todo-project/ +``` + +--- + +## ๐Ÿ“ž ์ง€์› + +๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋‹ค์Œ์„ ํ™•์ธํ•˜์„ธ์š”: + +1. **๋กœ๊ทธ ํŒŒ์ผ**: `docker-compose logs` +2. **ํฌํŠธ ์ถฉ๋Œ**: `netstat -tulpn | grep :4000` +3. **๋””์Šคํฌ ๊ณต๊ฐ„**: `df -h` +4. **๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰**: `free -m` + +--- + +## ๐ŸŽฏ ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ + +### 1. ๋ณผ๋ฅจ ๋ฐฐ์น˜ ์ตœ์ ํ™” +- **์ด๋ฏธ์ง€ ์ €์žฅ์†Œ**: volume1 (SSD ๊ถŒ์žฅ) - ๋น ๋ฅธ ์•ก์„ธ์Šค +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: volume3 (HDD ๊ฐ€๋Šฅ) - ๋Œ€์šฉ๋Ÿ‰ ์ €์žฅ + +### 2. ๋ฉ”๋ชจ๋ฆฌ ์„ค์ • +- ์ตœ์†Œ 2GB RAM ํ• ๋‹น +- PostgreSQL shared_buffers ์กฐ์ • + +### 3. ๋„คํŠธ์›Œํฌ ์ตœ์ ํ™” +- ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ์‚ฌ์šฉ์œผ๋กœ HTTPS ์ ์šฉ +- CDN ์‚ฌ์šฉ ๊ณ ๋ ค (์ •์  ํŒŒ์ผ) + +--- + +**๐ŸŽ‰ ์ถ•ํ•˜ํ•ฉ๋‹ˆ๋‹ค! Todo-Project๊ฐ€ ์‹œ๋†€๋กœ์ง€์— ์„ฑ๊ณต์ ์œผ๋กœ ๋ฐฐํฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!** diff --git a/SYNOLOGY_INSTALL.md b/SYNOLOGY_INSTALL.md deleted file mode 100644 index 4a035dd..0000000 --- a/SYNOLOGY_INSTALL.md +++ /dev/null @@ -1,285 +0,0 @@ -# ๐Ÿ  ์‹œ๋†€๋กœ์ง€ NAS ์„ค์น˜ ๊ฐ€์ด๋“œ - -## ๐Ÿ“‹ ์‚ฌ์ „ ์ค€๋น„์‚ฌํ•ญ - -### 1. ์‹œ๋†€๋กœ์ง€ NAS ์š”๊ตฌ์‚ฌํ•ญ -- **DSM 7.0 ์ด์ƒ** -- **Container Manager** ํŒจํ‚ค์ง€ ์„ค์น˜ -- **Git Server** ํŒจํ‚ค์ง€ ์„ค์น˜ (์„ ํƒ์‚ฌํ•ญ) -- **์ตœ์†Œ 2GB RAM** ๊ถŒ์žฅ - -### 2. ํ•„์š”ํ•œ ํฌํŠธ -- **4000**: ํ”„๋ก ํŠธ์—”๋“œ (Nginx) -- **8000**: ๋ฐฑ์—”๋“œ API (FastAPI) -- **5432**: PostgreSQL (๋‚ด๋ถ€ ํ†ต์‹ ) - -## ๐Ÿš€ ์„ค์น˜ ๋ฐฉ๋ฒ• - -### ๋ฐฉ๋ฒ• 1: SSH๋ฅผ ํ†ตํ•œ ์„ค์น˜ (๊ถŒ์žฅ) - -#### 1๋‹จ๊ณ„: SSH ์ ‘์† -```bash -ssh admin@[์‹œ๋†€๋กœ์ง€_IP์ฃผ์†Œ] -``` - -#### 2๋‹จ๊ณ„: ํ”„๋กœ์ ํŠธ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ -```bash -sudo mkdir -p /volume1/docker/todo-project -cd /volume1/docker/todo-project -``` - -#### 3๋‹จ๊ณ„: Git ํด๋ก  -```bash -sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git . -``` - -#### 4๋‹จ๊ณ„: ํ™˜๊ฒฝ ์„ค์ • -```bash -# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ ์ƒ์„ฑ -sudo cp .env.example .env - -# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŽธ์ง‘ (ํ•„์š”์‹œ) -sudo nano .env -``` - -#### 5๋‹จ๊ณ„: Docker ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ -```bash -sudo docker-compose up -d -``` - -#### 6๋‹จ๊ณ„: ์„ค์น˜ ํ™•์ธ -```bash -# ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ -sudo docker-compose ps - -# ๋กœ๊ทธ ํ™•์ธ -sudo docker-compose logs -f -``` - -### ๋ฐฉ๋ฒ• 2: Container Manager GUI ์‚ฌ์šฉ - -#### 1๋‹จ๊ณ„: Container Manager ์—ด๊ธฐ -- DSM โ†’ **Package Center** โ†’ **Container Manager** ์„ค์น˜/์‹คํ–‰ - -#### 2๋‹จ๊ณ„: ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ -- **Project** โ†’ **Create** -- **Project name**: `todo-project` -- **Path**: `/docker/todo-project` - -#### 3๋‹จ๊ณ„: ์†Œ์Šค ์„ค์ • -- **Source**: `Git Repository` -- **Repository URL**: `https://git.hyungi.net/hyungi/Todo-Project.git` -- **Branch**: `main` - -#### 4๋‹จ๊ณ„: ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ -- **Build and run** ํด๋ฆญ -- ์ž๋™์œผ๋กœ `docker-compose.yml` ํŒŒ์ผ์„ ์ฝ์–ด์„œ ์‹คํ–‰ - -## ๐Ÿ”ง ์„ค์ • ๋ฐ ์ตœ์ ํ™” - -### 1. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • (.env) -```bash -# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • -POSTGRES_DB=todo_project -POSTGRES_USER=todo_user -POSTGRES_PASSWORD=your_secure_password_here - -# JWT ์„ค์ • -SECRET_KEY=your_jwt_secret_key_here -ACCESS_TOKEN_EXPIRE_MINUTES=30 - -# ์‹œ๋†€๋กœ์ง€ ์—ฐ๋™ ์„ค์ • -SYNOLOGY_DSM_HOST=localhost -SYNOLOGY_DSM_PORT=5000 -SYNOLOGY_DSM_USERNAME=your_username -SYNOLOGY_DSM_PASSWORD=your_password - -# ๋ฉ”์ผ ์„ค์ • (MailPlus ์—ฐ๋™์šฉ) -MAIL_SERVER=localhost -MAIL_PORT=587 -MAIL_USERNAME=your_mail_username -MAIL_PASSWORD=your_mail_password -MAIL_FROM=noreply@yourdomain.com -``` - -### 2. ๋ณผ๋ฅจ ๋งคํ•‘ ํ™•์ธ -```yaml -volumes: - - /volume1/docker/todo-project/data:/data - - todo_uploads:/data/uploads -``` - -### 3. ํฌํŠธ ํฌ์›Œ๋”ฉ ์„ค์ • -- **์ œ์–ดํŒ** โ†’ **์™ธ๋ถ€ ์•ก์„ธ์Šค** โ†’ **๋ผ์šฐํ„ฐ ๊ตฌ์„ฑ** -- ํฌํŠธ 4000์„ ์™ธ๋ถ€์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๋„๋ก ์„ค์ • - -## ๐ŸŒ ์ ‘์† ๋ฐฉ๋ฒ• - -### ๋‚ด๋ถ€ ๋„คํŠธ์›Œํฌ -``` -http://[์‹œ๋†€๋กœ์ง€_IP]:4000 -``` - -### ์™ธ๋ถ€ ์ ‘์† (DDNS ์„ค์ • ์‹œ) -``` -http://[your-synology-ddns].synology.me:4000 -``` - -## ๐Ÿ”’ ๋ณด์•ˆ ์„ค์ • - -### 1. HTTPS ์„ค์ • (Let's Encrypt) -```bash -# ์ธ์ฆ์„œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ -sudo mkdir -p /volume1/docker/todo-project/ssl - -# docker-compose.yml์— SSL ๋ณผ๋ฅจ ์ถ”๊ฐ€ -volumes: - - /volume1/ssl:/etc/ssl/certs -``` - -### 2. ๋ฐฉํ™”๋ฒฝ ์„ค์ • -- **์ œ์–ดํŒ** โ†’ **๋ณด์•ˆ** โ†’ **๋ฐฉํ™”๋ฒฝ** -- ํ•„์š”ํ•œ ํฌํŠธ๋งŒ ์—ด๊ธฐ: 4000, 8000 - -### 3. ์‚ฌ์šฉ์ž ๊ถŒํ•œ ์„ค์ • -```bash -# Docker ๊ทธ๋ฃน์— ์‚ฌ์šฉ์ž ์ถ”๊ฐ€ -sudo usermod -aG docker $USER -``` - -## ๐Ÿ“Š ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ ์œ ์ง€๋ณด์ˆ˜ - -### 1. ๋กœ๊ทธ ํ™•์ธ -```bash -# ์ „์ฒด ๋กœ๊ทธ -sudo docker-compose logs - -# ํŠน์ • ์„œ๋น„์Šค ๋กœ๊ทธ -sudo docker-compose logs frontend -sudo docker-compose logs backend -sudo docker-compose logs db -``` - -### 2. ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ -```bash -# ์‹คํ–‰ ์ค‘์ธ ์ปจํ…Œ์ด๋„ˆ -sudo docker-compose ps - -# ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ -sudo docker stats -``` - -### 3. ์—…๋ฐ์ดํŠธ -```bash -# ์ตœ์‹  ์ฝ”๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ -sudo git pull origin main - -# ์ปจํ…Œ์ด๋„ˆ ์žฌ๋นŒ๋“œ -sudo docker-compose down -sudo docker-compose up -d --build -``` - -### 4. ๋ฐฑ์—… -```bash -# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… -sudo docker-compose exec db pg_dump -U todo_user todo_project > backup.sql - -# ์—…๋กœ๋“œ ํŒŒ์ผ ๋ฐฑ์—… -sudo tar -czf uploads_backup.tar.gz /volume1/docker/todo-project/data/uploads -``` - -## ๐Ÿ”ง ๋ฌธ์ œ ํ•ด๊ฒฐ - -### 1. ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ -```bash -# ๋กœ๊ทธ ํ™•์ธ -sudo docker-compose logs - -# ํฌํŠธ ์ถฉ๋Œ ํ™•์ธ -sudo netstat -tulpn | grep :4000 -``` - -### 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์˜ค๋ฅ˜ -```bash -# PostgreSQL ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ -sudo docker-compose exec db psql -U todo_user -d todo_project -c "SELECT 1;" -``` - -### 3. ๊ถŒํ•œ ๋ฌธ์ œ -```bash -# ํŒŒ์ผ ๊ถŒํ•œ ์ˆ˜์ • -sudo chown -R 1000:1000 /volume1/docker/todo-project/data -``` - -### 4. ๋ฉ”๋ชจ๋ฆฌ ๋ถ€์กฑ -```bash -# ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ -free -h - -# Docker ๋ฉ”๋ชจ๋ฆฌ ์ œํ•œ ์„ค์ • (docker-compose.yml) -services: - backend: - mem_limit: 512m -``` - -## ๐Ÿ“ฑ PWA ์„ค์ • - -### 1. HTTPS ํ•„์ˆ˜ -- PWA ๊ธฐ๋Šฅ์„ ์œ„ํ•ด์„œ๋Š” HTTPS ์—ฐ๊ฒฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค -- Let's Encrypt ์ธ์ฆ์„œ ์„ค์ • ๊ถŒ์žฅ - -### 2. ํ™ˆํ™”๋ฉด ์ถ”๊ฐ€ -- ๋ชจ๋ฐ”์ผ์—์„œ Safari/Chrome์œผ๋กœ ์ ‘์† -- "ํ™ˆํ™”๋ฉด์— ์ถ”๊ฐ€" ์„ ํƒ -- ๋„ค์ดํ‹ฐ๋ธŒ ์•ฑ์ฒ˜๋Ÿผ ์‚ฌ์šฉ ๊ฐ€๋Šฅ - -## ๐ŸŽฏ ์„ฑ๋Šฅ ์ตœ์ ํ™” - -### 1. Nginx ์บ์‹ฑ ์„ค์ • -```nginx -location /static/ { - expires 1y; - add_header Cache-Control "public, immutable"; -} -``` - -### 2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ตœ์ ํ™” -```sql --- ์ธ๋ฑ์Šค ์ƒ์„ฑ -CREATE INDEX idx_todos_created_at ON todos(created_at); -CREATE INDEX idx_todos_status ON todos(status); -``` - -### 3. ์ด๋ฏธ์ง€ ์ตœ์ ํ™” -- ์—…๋กœ๋“œ๋œ ์ด๋ฏธ์ง€๋Š” ์ž๋™์œผ๋กœ ์••์ถ•๋ฉ๋‹ˆ๋‹ค -- ์ตœ๋Œ€ 1920x1920 ํ•ด์ƒ๋„๋กœ ๋ฆฌ์‚ฌ์ด์ฆˆ -- JPEG ํ’ˆ์งˆ 85%๋กœ ์ตœ์ ํ™” - -## ๐Ÿ“ž ์ง€์› - -๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋‹ค์Œ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”: -1. **๋กœ๊ทธ ํŒŒ์ผ**: `sudo docker-compose logs` -2. **์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค**: `htop` ๋˜๋Š” DSM ๋ฆฌ์†Œ์Šค ๋ชจ๋‹ˆํ„ฐ -3. **๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ**: ํฌํŠธ ์ ‘๊ทผ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ -4. **๊ถŒํ•œ ์„ค์ •**: ํŒŒ์ผ ๋ฐ ๋””๋ ‰ํ† ๋ฆฌ ๊ถŒํ•œ - ---- - -## ๐Ÿš€ ๋น ๋ฅธ ์‹œ์ž‘ ๋ช…๋ น์–ด - -```bash -# ์ „์ฒด ์„ค์น˜ (ํ•œ ๋ฒˆ์— ์‹คํ–‰) -ssh admin@[์‹œ๋†€๋กœ์ง€_IP] -sudo mkdir -p /volume1/docker/todo-project -cd /volume1/docker/todo-project -sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git . -sudo cp .env.example .env -sudo docker-compose up -d - -# ์ ‘์† ํ™•์ธ -curl http://localhost:4000 -``` - -์„ค์น˜ ์™„๋ฃŒ ํ›„ `http://[์‹œ๋†€๋กœ์ง€_IP]:4000`์œผ๋กœ ์ ‘์†ํ•˜์—ฌ Todo Project๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”! ๐ŸŽ‰ - - diff --git a/backend/Dockerfile b/backend/Dockerfile index 9accb5d..cec7bbe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜ RUN apt-get update && apt-get install -y \ gcc \ + curl \ && rm -rf /var/lib/apt/lists/* # Python ์˜์กด์„ฑ ์„ค์น˜ diff --git a/backend/migrations/003_optimize_for_workflow.sql b/backend/migrations/003_optimize_for_workflow.sql new file mode 100644 index 0000000..de1df92 --- /dev/null +++ b/backend/migrations/003_optimize_for_workflow.sql @@ -0,0 +1,38 @@ +-- ์ƒˆ๋กœ์šด ์›Œํฌํ”Œ๋กœ์šฐ์— ๋งž๊ฒŒ DB ๊ตฌ์กฐ ์ตœ์ ํ™” + +-- 1. due_date๋ฅผ start_date๋กœ ๋ณ€๊ฒฝ +ALTER TABLE todos RENAME COLUMN due_date TO start_date; + +-- 2. tags ์ปฌ๋Ÿผ ์ œ๊ฑฐ (์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ) +ALTER TABLE todos DROP COLUMN IF EXISTS tags; + +-- 3. category ๊ธฐ๋ณธ๊ฐ’ ๋ณ€๊ฒฝ ๋ฐ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ์ •๋ฆฌ +-- ๊ธฐ์กด 'checklist' ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ 'memo'๋กœ ๋ณ€๊ฒฝ +UPDATE todos SET category = 'memo' WHERE category = 'checklist'; + +-- ๊ธฐ์กด 'calendar' ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ 'todo'๋กœ ๋ณ€๊ฒฝ +UPDATE todos SET category = 'todo' WHERE category = 'calendar'; + +-- 4. title์„ nullable๋กœ ๋ณ€๊ฒฝ (๋ฉ”๋ชจ์˜ ๊ฒฝ์šฐ ์„ ํƒ์‚ฌํ•ญ) +ALTER TABLE todos ALTER COLUMN title DROP NOT NULL; + +-- 5. description์„ NOT NULL๋กœ ๋ณ€๊ฒฝ (๋‚ด์šฉ์€ ํ•„์ˆ˜) +UPDATE todos SET description = COALESCE(title, '๋‚ด์šฉ ์—†์Œ') WHERE description IS NULL OR description = ''; +ALTER TABLE todos ALTER COLUMN description SET NOT NULL; + +-- 6. category ๊ธฐ๋ณธ๊ฐ’์„ 'memo'๋กœ ์„ค์ • +ALTER TABLE todos ALTER COLUMN category SET DEFAULT 'memo'; + +-- 7. ๋ถˆํ•„์š”ํ•œ ์ธ๋ฑ์Šค ์ •๋ฆฌ ๋ฐ ์ƒˆ๋กœ์šด ์ธ๋ฑ์Šค ์ถ”๊ฐ€ +-- ๊ธฐ์กด due_date ์ธ๋ฑ์Šค ์ œ๊ฑฐ (์ปฌ๋Ÿผ๋ช…์ด ๋ณ€๊ฒฝ๋จ) +DROP INDEX IF EXISTS idx_todos_due_date; + +-- ์ƒˆ๋กœ์šด ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_todos_start_date ON todos(start_date); +CREATE INDEX IF NOT EXISTS idx_todos_category_status ON todos(category, status); +CREATE INDEX IF NOT EXISTS idx_todos_user_category ON todos(user_id, category); + +-- 8. ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ๋ณตํ•ฉ ์ธ๋ฑ์Šค +CREATE INDEX IF NOT EXISTS idx_todos_workflow ON todos(user_id, category, status, start_date); + +COMMIT; diff --git a/backend/src/api/routes/calendar.py b/backend/src/api/routes/calendar.py index 675e22e..7dc394a 100644 --- a/backend/src/api/routes/calendar.py +++ b/backend/src/api/routes/calendar.py @@ -1,7 +1,8 @@ """ ๊ฐ„๋‹จํ•œ ์บ˜๋ฆฐ๋” API ๋ผ์šฐํ„ฐ """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException +from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import List @@ -32,10 +33,10 @@ async def get_calendar_todos( query = select(Todo).where( and_( Todo.user_id == current_user.id, - Todo.due_date >= start_date, - Todo.due_date <= end_date + Todo.start_date >= start_date, + Todo.start_date <= end_date ) - ).order_by(Todo.due_date.asc()) + ).order_by(Todo.start_date.asc()) result = await db.execute(query) todos = result.scalars().all() @@ -62,7 +63,7 @@ async def get_today_todos( query = select(Todo).where( and_( Todo.user_id == current_user.id, - Todo.due_date == today + Todo.start_date == today ) ).order_by(Todo.created_at.desc()) diff --git a/backend/src/api/routes/setup.py b/backend/src/api/routes/setup.py new file mode 100644 index 0000000..a3b861c --- /dev/null +++ b/backend/src/api/routes/setup.py @@ -0,0 +1,121 @@ +""" +์ดˆ๊ธฐ ์„ค์ • ๊ด€๋ จ API ๋ผ์šฐํ„ฐ +- ์ตœ์ดˆ ๊ด€๋ฆฌ์ž ๊ณ„์ • ์„ค์ • +- ์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” ์ƒํƒœ ํ™•์ธ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel, Field + +from ...core.database import get_db +from ...core.security import get_password_hash +from ...models.user import User +from ...schemas.auth import CreateUserRequest + + +router = APIRouter() + + +class InitialSetupRequest(BaseModel): + """์ดˆ๊ธฐ ์„ค์ • ์š”์ฒญ""" + admin_username: str = Field(..., min_length=3, max_length=50, description="๊ด€๋ฆฌ์ž ์‚ฌ์šฉ์ž๋ช…") + admin_email: str = Field(..., description="๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ") + admin_password: str = Field(..., min_length=6, description="๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ") + admin_full_name: str = Field(default="Administrator", description="๊ด€๋ฆฌ์ž ์ด๋ฆ„") + + +class SetupStatusResponse(BaseModel): + """์„ค์ • ์ƒํƒœ ์‘๋‹ต""" + is_setup_required: bool + user_count: int + + +@router.get("/status", response_model=SetupStatusResponse) +async def get_setup_status(db: AsyncSession = Depends(get_db)): + """์‹œ์Šคํ…œ ์ดˆ๊ธฐ ์„ค์ • ํ•„์š” ์—ฌ๋ถ€ ํ™•์ธ""" + try: + # ์‚ฌ์šฉ์ž ์ˆ˜ ํ™•์ธ + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 + + return SetupStatusResponse( + is_setup_required=user_count == 0, + user_count=user_count + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="์„ค์ • ์ƒํƒœ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + + +@router.post("/initialize") +async def initialize_system( + setup_data: InitialSetupRequest, + db: AsyncSession = Depends(get_db) +): + """์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” ๋ฐ ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ""" + try: + # ์ด๋ฏธ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + result = await db.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 + + if user_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="์‹œ์Šคํ…œ์ด ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + + # ์‚ฌ์šฉ์ž๋ช… ์ค‘๋ณต ํ™•์ธ + existing_user = await db.execute( + select(User).where(User.username == setup_data.admin_username) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž๋ช…์ž…๋‹ˆ๋‹ค." + ) + + # ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ + existing_email = await db.execute( + select(User).where(User.email == setup_data.admin_email) + ) + if existing_email.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค." + ) + + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + admin_user = User( + username=setup_data.admin_username, + email=setup_data.admin_email, + hashed_password=get_password_hash(setup_data.admin_password), + full_name=setup_data.admin_full_name, + is_active=True, + is_admin=True + ) + + db.add(admin_user) + await db.commit() + await db.refresh(admin_user) + + return { + "message": "์‹œ์Šคํ…œ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + "admin_user": { + "id": str(admin_user.id), + "username": admin_user.username, + "email": admin_user.email, + "full_name": admin_user.full_name + } + } + + except HTTPException: + raise + except Exception as e: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ) diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py index f750ea0..3c0b861 100644 --- a/backend/src/api/routes/todos.py +++ b/backend/src/api/routes/todos.py @@ -1,7 +1,7 @@ """ ๊ฐ„๋‹จํ•œ Todo API ๋ผ์šฐํ„ฐ """ -from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import List, Optional @@ -14,11 +14,54 @@ from ...models.user import User from ...models.todo import Todo, TodoStatus from ...schemas.todo import TodoCreate, TodoUpdate, TodoResponse from ..dependencies import get_current_active_user +from ...services.file_service import save_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="์ด๋ฏธ์ง€ ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) + + # ํŒŒ์ผ ํฌ๊ธฐ ๊ฒ€์ฆ (10MB ์ œํ•œ) + content = await image.read() + if len(content) > 10 * 1024 * 1024: # 10MB + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ํŒŒ์ผ ํฌ๊ธฐ๋Š” 10MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + ) + + # ์ด๋ฏธ์ง€ ์ €์žฅ + file_url = save_image(content, image.filename) + if not file_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="์ด๋ฏธ์ง€ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + + return {"file_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="์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." + ) + + # ============================================================================ # Todo CRUD API # ============================================================================ @@ -34,15 +77,17 @@ async def create_todo( logger.info(f"Todo ์ƒ์„ฑ ์š”์ฒญ - ์‚ฌ์šฉ์ž: {current_user.username}") logger.info(f"์š”์ฒญ ๋ฐ์ดํ„ฐ: {todo_data.dict()}") - # ๋‚ ์งœ ๋ฌธ์ž์—ด ํŒŒ์‹ฑ (ํ•œ๊ตญ ์‹œ๊ฐ„ ํ˜•์‹) - parsed_due_date = None - if todo_data.due_date: + # ์‹œ์ž‘ ๋‚ ์งœ ๋ฌธ์ž์—ด ํŒŒ์‹ฑ (Todo์ผ ๋•Œ๋งŒ) + parsed_start_date = None + if todo_data.start_date and todo_data.category.value == "todo": try: - # "2025-09-22T00:00:00+09:00" ํ˜•์‹ ํŒŒ์‹ฑ - parsed_due_date = datetime.fromisoformat(todo_data.due_date) - logger.info(f"ํŒŒ์‹ฑ๋œ ๋‚ ์งœ: {parsed_due_date}") + # "2025-09-22" ํ˜•์‹ ํŒŒ์‹ฑ ํ›„ ํ•œ๊ตญ ์‹œ๊ฐ„์œผ๋กœ ๋ณ€ํ™˜ + from datetime import date + date_obj = datetime.strptime(todo_data.start_date, "%Y-%m-%d").date() + parsed_start_date = datetime.combine(date_obj, datetime.min.time()) + logger.info(f"ํŒŒ์‹ฑ๋œ ์‹œ์ž‘ ๋‚ ์งœ: {parsed_start_date}") except ValueError: - logger.warning(f"Invalid date format: {todo_data.due_date}") + logger.warning(f"Invalid date format: {todo_data.start_date}") # ์ด๋ฏธ์ง€ URLs JSON ๋ณ€ํ™˜ import json @@ -56,9 +101,10 @@ async def create_todo( title=todo_data.title, description=todo_data.description, category=todo_data.category, - due_date=parsed_due_date, + start_date=parsed_start_date, image_urls=image_urls_json, - tags=todo_data.tags + board_id=todo_data.board_id, + is_board_header=todo_data.is_board_header or False ) db.add(new_todo) @@ -83,10 +129,11 @@ async def create_todo( "status": new_todo.status, "created_at": new_todo.created_at, "updated_at": new_todo.updated_at, - "due_date": new_todo.due_date.isoformat() if new_todo.due_date else None, + "start_date": new_todo.start_date.strftime("%Y-%m-%d") if new_todo.start_date else None, "completed_at": new_todo.completed_at, "image_urls": image_urls_list, - "tags": new_todo.tags + "board_id": str(new_todo.board_id) if new_todo.board_id else None, + "is_board_header": new_todo.is_board_header } return response_data @@ -146,10 +193,11 @@ async def get_todos( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header }) return response_data @@ -201,10 +249,11 @@ async def get_todo( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags, + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header } return response_data @@ -246,7 +295,7 @@ async def update_todo( import json update_data = todo_data.dict(exclude_unset=True) for field, value in update_data.items(): - if field == 'due_date' and value: + if field == 'start_date' and value: # ๋‚ ์งœ ๋ฌธ์ž์—ด ํŒŒ์‹ฑ (ํ•œ๊ตญ ์‹œ๊ฐ„ ํ˜•์‹) try: parsed_date = datetime.fromisoformat(value) @@ -285,10 +334,11 @@ async def update_todo( "status": todo.status, "created_at": todo.created_at, "updated_at": todo.updated_at, - "due_date": todo.due_date.isoformat() if todo.due_date else None, + "start_date": todo.start_date.isoformat() if todo.start_date else None, "completed_at": todo.completed_at, "image_urls": image_urls_list, - "tags": todo.tags, + "board_id": str(todo.board_id) if todo.board_id else None, + "is_board_header": todo.is_board_header } return response_data diff --git a/backend/src/core/config.py b/backend/src/core/config.py index ed4f263..e626a06 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -24,8 +24,8 @@ class Settings(BaseSettings): 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"] + # CORS ์„ค์ • (ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ) + ALLOWED_HOSTS: List[str] = ["localhost", "127.0.0.1"] ALLOWED_ORIGINS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"] # ์„œ๋ฒ„ ์„ค์ • diff --git a/backend/src/core/database.py b/backend/src/core/database.py index a2e5dd4..f62fd49 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -68,28 +68,16 @@ async def init_db() -> None: async def create_admin_user() -> None: - """๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ (์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ)""" + """๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ (์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ) - ์ดˆ๊ธฐ ์„ค์ • API๋กœ ๋Œ€์ฒด๋จ""" from ..models.user import User - from .security import get_password_hash - from sqlalchemy import select + from sqlalchemy import select, func async with AsyncSessionLocal() as session: - # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์กด์žฌ ํ™•์ธ - result = await session.execute( - select(User).where(User.username == settings.ADMIN_USERNAME) - ) - admin_user = result.scalar_one_or_none() + # ์‚ฌ์šฉ์ž ์ˆ˜ ํ™•์ธ + result = await session.execute(select(func.count(User.id))) + user_count = result.scalar() or 0 - if not admin_user: - # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ - admin_user = User( - username=settings.ADMIN_USERNAME, - 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_USERNAME}") + if user_count == 0: + print("์ดˆ๊ธฐ ์„ค์ •์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. /api/setup/status ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.") + else: + print(f"์‹œ์Šคํ…œ์— {user_count}๋ช…์˜ ์‚ฌ์šฉ์ž๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.") diff --git a/backend/src/integrations/__init__.py b/backend/src/integrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/integrations/calendar/__init__.py b/backend/src/integrations/calendar/__init__.py deleted file mode 100644 index ffac1ff..0000000 --- a/backend/src/integrations/calendar/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -์บ˜๋ฆฐ๋” ํ†ตํ•ฉ ๋ชจ๋“ˆ -- ๋‹ค์ค‘ ์บ˜๋ฆฐ๋” ์ œ๊ณต์ž ์ง€์› (์‹œ๋†€๋กœ์ง€, ์• ํ”Œ, ๊ตฌ๊ธ€ ๋“ฑ) -- ๊ฐ„๊ฒฐํ•œ 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 deleted file mode 100644 index e235787..0000000 --- a/backend/src/integrations/calendar/apple.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -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 deleted file mode 100644 index c574c70..0000000 --- a/backend/src/integrations/calendar/base.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -์บ˜๋ฆฐ๋” ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ ์ถ”์ƒํ™” -""" -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 deleted file mode 100644 index edd89e9..0000000 --- a/backend/src/integrations/calendar/router.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -์บ˜๋ฆฐ๋” ๋ผ์šฐํ„ฐ - ๋‹ค์ค‘ ์บ˜๋ฆฐ๋” ์ œ๊ณต์ž ์ค‘์•™ ๊ด€๋ฆฌ -- ์„œ๋น„์Šค ํด๋ž˜์Šค ๊ธฐ์ค€: ์ตœ๋Œ€ 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 deleted file mode 100644 index bf6ace1..0000000 --- a/backend/src/integrations/calendar/synology.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -์‹œ๋†€๋กœ์ง€ ์บ˜๋ฆฐ๋” ์„œ๋น„์Šค ๊ตฌํ˜„ -""" -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 index 8ad53a5..7fbfa33 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -6,7 +6,9 @@ from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles import logging +import os from .core.config import settings from .api.routes import auth, todos, calendar @@ -46,15 +48,35 @@ app.add_middleware( async def validation_exception_handler(request: Request, exc: RequestValidationError): logger.error(f"Validation ์˜ค๋ฅ˜ - URL: {request.url}") logger.error(f"Validation ์˜ค๋ฅ˜ ์ƒ์„ธ: {exc.errors()}") + + # JSON ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ์—๋Ÿฌ ๋ณ€ํ™˜ + serializable_errors = [] + for error in exc.errors(): + serializable_error = {} + for key, value in error.items(): + if isinstance(value, bytes): + serializable_error[key] = value.decode('utf-8') + else: + serializable_error[key] = str(value) if not isinstance(value, (str, int, float, bool, list, dict, type(None))) else value + serializable_errors.append(serializable_error) + return JSONResponse( status_code=422, content={ "detail": "์š”์ฒญ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์‹คํŒจ", - "errors": exc.errors() + "errors": serializable_errors } ) +# ์ •์  ํŒŒ์ผ ์„œ๋น™ (์—…๋กœ๋“œ๋œ ์ด๋ฏธ์ง€) +UPLOAD_DIR = "/app/uploads" +if not os.path.exists(UPLOAD_DIR): + os.makedirs(UPLOAD_DIR) +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") + # ๋ผ์šฐํ„ฐ ๋“ฑ๋ก +from .api.routes import setup +app.include_router(setup.router, prefix="/api/setup", tags=["setup"]) 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"]) @@ -87,6 +109,47 @@ async def create_sample_data(): return +async def wait_for_database(): + """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ๋Œ€๊ธฐ""" + import asyncio + import asyncpg + from urllib.parse import urlparse + + # DATABASE_URL ํŒŒ์‹ฑ + parsed_url = urlparse(settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")) + + max_retries = 30 # ์ตœ๋Œ€ 30๋ฒˆ ์‹œ๋„ (30์ดˆ) + retry_count = 0 + + while retry_count < max_retries: + try: + logger.info(f"๐Ÿ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์‹œ๋„ {retry_count + 1}/{max_retries}") + + # asyncpg๋กœ ์ง์ ‘ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ + conn = await asyncpg.connect( + host=parsed_url.hostname, + port=parsed_url.port or 5432, + user=parsed_url.username, + password=parsed_url.password, + database=parsed_url.path.lstrip('/'), + timeout=5 + ) + await conn.close() + + logger.info("โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ฑ๊ณต!") + return True + + except Exception as e: + retry_count += 1 + logger.warning(f"โŒ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์‹คํŒจ ({retry_count}/{max_retries}): {e}") + + if retry_count < max_retries: + await asyncio.sleep(1) + else: + logger.error("๐Ÿ’ฅ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ดˆ๊ณผ") + raise Exception("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + + @app.on_event("startup") async def startup_event(): """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘ ์‹œ ์ดˆ๊ธฐํ™”""" @@ -94,6 +157,9 @@ async def startup_event(): logger.info(f"๐Ÿ“Š ํ™˜๊ฒฝ: {settings.ENVIRONMENT}") logger.info(f"๐Ÿ”— ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: {settings.DATABASE_URL}") + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ๋Œ€๊ธฐ + await wait_for_database() + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” from .core.database import init_db await init_db() diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py index 3fc6c98..398cb33 100644 --- a/backend/src/models/todo.py +++ b/backend/src/models/todo.py @@ -13,9 +13,9 @@ from ..core.database import Base class TodoCategory(str, enum.Enum): """Todo ์นดํ…Œ๊ณ ๋ฆฌ""" - TODO = "todo" - CALENDAR = "calendar" - CHECKLIST = "checklist" + MEMO = "memo" # ์ˆ˜์‹ ํ•จ์˜ ๋ฉ”๋ชจ (upload.html์—์„œ ์ƒ์„ฑ) + TODO = "todo" # Todo ๋ชฉ๋ก (inbox.html์—์„œ ๋ณ€ํ™˜๋œ ๊ฒƒ) + BOARD = "board" # ๋ณด๋“œ (ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ) class TodoStatus(str, enum.Enum): @@ -35,20 +35,23 @@ class Todo(Base): user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # ๊ธฐ๋ณธ ์ •๋ณด - title = Column(String(200), nullable=False) # ํ• ์ผ ์ œ๋ชฉ - description = Column(Text, nullable=True) # ํ• ์ผ ์„ค๋ช… - category = Column(Enum(TodoCategory), nullable=True, default=None) + title = Column(String(200), nullable=True) # ์ œ๋ชฉ (๋ฉ”๋ชจ์˜ ๊ฒฝ์šฐ ์„ ํƒ์‚ฌํ•ญ) + description = Column(Text, nullable=False) # ๋‚ด์šฉ (๋ฉ”๋ชจ/Todo ๋ชจ๋‘ ํ•„์ˆ˜) + category = Column(Enum(TodoCategory), nullable=False, default=TodoCategory.MEMO) status = Column(Enum(TodoStatus), nullable=False, default=TodoStatus.PENDING) # ์‹œ๊ฐ„ ๊ด€๋ฆฌ 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) - due_date = Column(DateTime(timezone=True), nullable=True) # ๋งˆ๊ฐ์ผ + start_date = Column(DateTime(timezone=True), nullable=True) # Todo ์‹œ์ž‘์ผ (category=todo์ผ ๋•Œ๋งŒ ์‚ฌ์šฉ) completed_at = Column(DateTime(timezone=True), nullable=True) # ์ถ”๊ฐ€ ์ •๋ณด image_urls = Column(Text, nullable=True) # ์ฒจ๋ถ€ ์ด๋ฏธ์ง€ URLs (JSON ๋ฐฐ์—ด ํ˜•ํƒœ, ์ตœ๋Œ€ 5๊ฐœ) - tags = Column(String(500), nullable=True) # ํƒœ๊ทธ (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„) + + # ๋ณด๋“œ ๊ด€๋ จ (category=board์ผ ๋•Œ ์‚ฌ์šฉ) + board_id = Column(UUID(as_uuid=True), nullable=True) # ๋ณด๋“œ ID (์ฒซ ๋ฒˆ์งธ ๋ฉ”๋ชจ๊ฐ€ ๋ณด๋“œ ์ƒ์„ฑ, ๋‚˜๋จธ์ง€๋Š” ํ•˜์œ„ ๋ฉ”๋ชจ) + is_board_header = Column(Boolean, default=False) # ๋ณด๋“œ์˜ ํ—ค๋”(์ œ๋ชฉ) ์—ฌ๋ถ€ # ๊ด€๊ณ„ user = relationship("User", back_populates="todos") diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py index 0ddf249..dbc5b1b 100644 --- a/backend/src/schemas/todo.py +++ b/backend/src/schemas/todo.py @@ -9,9 +9,9 @@ from enum import Enum class TodoCategoryEnum(str, Enum): - TODO = "todo" - CALENDAR = "calendar" - CHECKLIST = "checklist" + MEMO = "memo" # ์ˆ˜์‹ ํ•จ์˜ ๋ฉ”๋ชจ + TODO = "todo" # Todo ๋ชฉ๋ก + BOARD = "board" # ๋ณด๋“œ (ํ”„๋กœ์ ํŠธ ๊ด€๋ฆฌ) class TodoStatusEnum(str, Enum): @@ -23,59 +23,48 @@ class TodoStatusEnum(str, Enum): class TodoBase(BaseModel): - title: str = Field(..., min_length=1, max_length=200) - description: Optional[str] = Field(None, max_length=2000) - category: Optional[TodoCategoryEnum] = None + title: Optional[str] = Field(None, max_length=200) # ๋ฉ”๋ชจ์˜ ๊ฒฝ์šฐ ์„ ํƒ์‚ฌํ•ญ + description: str = Field(..., min_length=1, max_length=2000) # ๋‚ด์šฉ์€ ํ•„์ˆ˜ + category: TodoCategoryEnum = Field(default=TodoCategoryEnum.MEMO) class TodoCreate(TodoBase): - """Todo ์ƒ์„ฑ""" - due_date: Optional[str] = None # ๋ฌธ์ž์—ด๋กœ ๋ณ€๊ฒฝ (ํ•œ๊ตญ ์‹œ๊ฐ„ ํ˜•์‹) + """Todo/๋ฉ”๋ชจ ์ƒ์„ฑ""" + start_date: Optional[str] = None # Todo ์‹œ์ž‘์ผ (category=todo์ผ ๋•Œ๋งŒ ์‚ฌ์šฉ) image_urls: Optional[List[str]] = Field(None, max_items=5, description="์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€ URL") - tags: Optional[str] = Field(None, max_length=500) + board_id: Optional[str] = None # ๋ณด๋“œ ID (๋ณด๋“œ ํ•˜์œ„ ๋ฉ”๋ชจ์ผ ๋•Œ) + is_board_header: Optional[bool] = False # ๋ณด๋“œ ํ—ค๋” ์—ฌ๋ถ€ class TodoUpdate(BaseModel): - """Todo ์ˆ˜์ •""" - title: Optional[str] = Field(None, min_length=1, max_length=200) - description: Optional[str] = Field(None, max_length=2000) + """Todo/๋ฉ”๋ชจ ์ˆ˜์ •""" + title: Optional[str] = Field(None, max_length=200) + description: Optional[str] = Field(None, min_length=1, max_length=2000) category: Optional[TodoCategoryEnum] = None status: Optional[TodoStatusEnum] = None - due_date: Optional[str] = None # ๋ฌธ์ž์—ด๋กœ ๋ณ€๊ฒฝ (ํ•œ๊ตญ ์‹œ๊ฐ„ ํ˜•์‹) + start_date: Optional[str] = None # Todo ์‹œ์ž‘์ผ image_urls: Optional[List[str]] = Field(None, max_items=5, description="์ตœ๋Œ€ 5๊ฐœ์˜ ์ด๋ฏธ์ง€ URL") - tags: Optional[str] = Field(None, max_length=500) + board_id: Optional[str] = None # ๋ณด๋“œ ID + is_board_header: Optional[bool] = None # ๋ณด๋“œ ํ—ค๋” ์—ฌ๋ถ€ class TodoResponse(BaseModel): id: UUID user_id: UUID - title: str - description: Optional[str] = None - category: Optional[TodoCategoryEnum] = None + title: Optional[str] = None + description: str + category: TodoCategoryEnum status: TodoStatusEnum created_at: datetime updated_at: datetime - due_date: Optional[str] = None # ๋ฌธ์ž์—ด๋กœ ๋ณ€๊ฒฝ (ํ•œ๊ตญ ์‹œ๊ฐ„ ํ˜•์‹) + start_date: Optional[str] = None # Todo ์‹œ์ž‘์ผ completed_at: Optional[datetime] = None image_urls: Optional[List[str]] = None - tags: Optional[str] = None + board_id: Optional[str] = None # ๋ณด๋“œ ID + is_board_header: Optional[bool] = None # ๋ณด๋“œ ํ—ค๋” ์—ฌ๋ถ€ class Config: from_attributes = True -class TodoStats(BaseModel): - """Todo ํ†ต๊ณ„""" - total_count: int - pending_count: int - in_progress_count: int - completed_count: int - completion_rate: float # ์™„๋ฃŒ์œจ (%) - - -class TodoDashboard(BaseModel): - """Todo ๋Œ€์‹œ๋ณด๋“œ""" - stats: TodoStats - today_todos: List[TodoResponse] - overdue_todos: List[TodoResponse] - upcoming_todos: List[TodoResponse] +# ๊ฐ„๋‹จํ•œ ์›Œํฌํ”Œ๋กœ์šฐ์— ๋งž๊ฒŒ ํ†ต๊ณ„ ๊ธฐ๋Šฅ ์ œ๊ฑฐ diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py index 386b2f6..c851509 100644 --- a/backend/src/services/__init__.py +++ b/backend/src/services/__init__.py @@ -2,11 +2,4 @@ ์„œ๋น„์Šค ๋ ˆ์ด์–ด ๋ชจ๋“ˆ """ -from .todo_service import TodoService -from .calendar_sync_service import CalendarSyncService, get_calendar_sync_service - -__all__ = [ - "TodoService", - "CalendarSyncService", - "get_calendar_sync_service" -] +__all__ = [] diff --git a/backend/src/services/calendar_sync_service.py b/backend/src/services/calendar_sync_service.py deleted file mode 100644 index e4efdd3..0000000 --- a/backend/src/services/calendar_sync_service.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -์บ˜๋ฆฐ๋” ๋™๊ธฐํ™” ์„œ๋น„์Šค -- ์„œ๋น„์Šค ํด๋ž˜์Šค ๊ธฐ์ค€: ์ตœ๋Œ€ 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 index 487adf9..bc3b470 100644 --- a/backend/src/services/file_service.py +++ b/backend/src/services/file_service.py @@ -13,6 +13,47 @@ def ensure_upload_dir(): if not os.path.exists(UPLOAD_DIR): os.makedirs(UPLOAD_DIR) +def save_image(image_data: bytes, filename: str = None) -> Optional[str]: + """์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์ผ๋กœ ์ €์žฅํ•˜๊ณ  ๊ฒฝ๋กœ ๋ฐ˜ํ™˜""" + try: + ensure_upload_dir() + + # ํŒŒ์ผ๋ช… ์ƒ์„ฑ + if not filename: + filename = f"{uuid.uuid4()}.jpg" + else: + # ํ™•์žฅ์ž ํ™•์ธ ๋ฐ ๋ณ€๊ฒฝ + name, ext = os.path.splitext(filename) + filename = f"{name}_{uuid.uuid4()}.jpg" + + filepath = os.path.join(UPLOAD_DIR, filename) + + # ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๋ฐ ์ €์žฅ + image = Image.open(io.BytesIO(image_data)) + + # RGBA๋ฅผ RGB๋กœ ๋ณ€ํ™˜ (JPEG๋Š” ํˆฌ๋ช…๋„ ์ง€์› ์•ˆํ•จ) + if image.mode in ('RGBA', 'LA'): + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + image = background + elif image.mode != 'RGB': + image = image.convert('RGB') + + # ์ด๋ฏธ์ง€ ํฌ๊ธฐ ์กฐ์ • (์ตœ๋Œ€ 1920px) + max_size = 1920 + if image.width > max_size or image.height > max_size: + image.thumbnail((max_size, 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 save_base64_image(base64_string: str) -> Optional[str]: """Base64 ์ด๋ฏธ์ง€๋ฅผ ํŒŒ์ผ๋กœ ์ €์žฅํ•˜๊ณ  ๊ฒฝ๋กœ ๋ฐ˜ํ™˜""" try: diff --git a/backend/src/services/todo_service.py b/backend/src/services/todo_service.py deleted file mode 100644 index 72ffc5c..0000000 --- a/backend/src/services/todo_service.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -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/deploy-synology.sh b/deploy-synology.sh new file mode 100755 index 0000000..681b6a1 --- /dev/null +++ b/deploy-synology.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# ============================================================================= +# Todo-Project ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ +# ============================================================================= + +set -e # ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์Šคํฌ๋ฆฝํŠธ ์ค‘๋‹จ + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ๋กœ๊ทธ ํ•จ์ˆ˜ +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ํ™•์ธ +check_synology_environment() { + log_info "์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ํ™•์ธ ์ค‘..." + + # Docker ์„ค์น˜ ํ™•์ธ + if ! command -v docker &> /dev/null; then + log_error "Docker๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. Container Manager ๋˜๋Š” Docker ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•˜์„ธ์š”." + exit 1 + fi + + # Docker Compose ํ™•์ธ + if ! command -v docker-compose &> /dev/null; then + log_error "Docker Compose๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + exit 1 + fi + + log_success "Docker ํ™˜๊ฒฝ ํ™•์ธ ์™„๋ฃŒ" +} + +# ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +create_directories() { + log_info "์‹œ๋†€๋กœ์ง€ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ ์ค‘..." + + # ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ €์žฅ์†Œ (volume1) + sudo mkdir -p /volume1/todo-project/uploads + sudo chmod 755 /volume1/todo-project/uploads + sudo chown -R 1000:1000 /volume1/todo-project + + # ์„ค์ • ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ €์žฅ์†Œ (volume3) + sudo mkdir -p /volume3/docker/todo-project/config/migrations + sudo mkdir -p /volume3/docker/todo-project/postgres + sudo chown -R 999:999 /volume3/docker/todo-project/postgres + sudo chown -R 1000:1000 /volume3/docker/todo-project/config + + log_success "๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ ์™„๋ฃŒ" +} + +# ํ™˜๊ฒฝ ํŒŒ์ผ ์„ค์ • +setup_environment() { + log_info "ํ™˜๊ฒฝ ํŒŒ์ผ ์„ค์ • ์ค‘..." + + if [ ! -f .env ]; then + if [ -f env.synology.example ]; then + cp env.synology.example .env + log_warning ".env ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜ ์„ค์ •์„ ์ˆ˜์ •ํ•˜์„ธ์š”:" + log_warning "- SECRET_KEY" + log_warning "- POSTGRES_PASSWORD" + log_warning "- CORS_ORIGINS (์‹œ๋†€๋กœ์ง€ IP ์ถ”๊ฐ€)" + echo + read -p "ํ™˜๊ฒฝ ์„ค์ •์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๊นŒ? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_error "ํ™˜๊ฒฝ ์„ค์ •์„ ์™„๋ฃŒํ•œ ํ›„ ๋‹ค์‹œ ์‹คํ–‰ํ•˜์„ธ์š”." + exit 1 + fi + else + log_error "env.synology.example ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค." + exit 1 + fi + else + log_success "๊ธฐ์กด .env ํŒŒ์ผ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค." + fi +} + +# ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋ณต์‚ฌ +copy_migrations() { + log_info "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋ณต์‚ฌ ์ค‘..." + + if [ -d backend/migrations ]; then + sudo cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/ + log_success "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋ณต์‚ฌ ์™„๋ฃŒ" + else + log_warning "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค." + fi +} + +# Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ๋ฐฐํฌ +deploy_application() { + log_info "Todo-Project ๋ฐฐํฌ ์ค‘..." + + # ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€ ๋ฐ ์ œ๊ฑฐ + if docker-compose ps -q | grep -q .; then + log_info "๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€ ์ค‘..." + docker-compose down + fi + + # ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ + log_info "Docker ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ ์ค‘..." + docker-compose up -d --build + + # ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ ๋Œ€๊ธฐ + log_info "์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ ๋Œ€๊ธฐ ์ค‘..." + sleep 30 + + # ํ—ฌ์Šค์ฒดํฌ + log_info "์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ ์ค‘..." + if curl -f http://localhost:9000/health &> /dev/null; then + log_success "๋ฐฑ์—”๋“œ ์„œ๋น„์Šค ์ •์ƒ ์ž‘๋™" + else + log_error "๋ฐฑ์—”๋“œ ์„œ๋น„์Šค ์˜ค๋ฅ˜" + docker-compose logs backend + exit 1 + fi +} + +# ๋ฐฐํฌ ์™„๋ฃŒ ์•ˆ๋‚ด +show_completion_info() { + log_success "๐ŸŽ‰ Todo-Project ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ์™„๋ฃŒ!" + echo + echo "=== ์ ‘์† ์ •๋ณด ===" + echo "์›น ์ธํ„ฐํŽ˜์ด์Šค: http://$(hostname -I | awk '{print $1}'):${FRONTEND_PORT:-4000}" + echo "API ์„œ๋ฒ„: http://$(hostname -I | awk '{print $1}'):${BACKEND_PORT:-9000}" + echo + echo "=== ๋‹ค์Œ ๋‹จ๊ณ„ ===" + echo "1. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ ‘์†ํ•˜์—ฌ ๊ด€๋ฆฌ์ž ๊ณ„์ •์„ ์ƒ์„ฑํ•˜์„ธ์š”" + echo "2. ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ)" + echo "3. ๋ฐฉํ™”๋ฒฝ ์„ค์ • ํ™•์ธ" + echo + echo "=== ์œ ์šฉํ•œ ๋ช…๋ น์–ด ===" + echo "๋กœ๊ทธ ํ™•์ธ: docker-compose logs -f" + echo "์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ: docker-compose ps" + echo "์„œ๋น„์Šค ์žฌ์‹œ์ž‘: docker-compose restart" + echo "์™„์ „ ์žฌ๋ฐฐํฌ: docker-compose down && docker-compose up -d --build" +} + +# ๋ฉ”์ธ ์‹คํ–‰ ํ•จ์ˆ˜ +main() { + echo "=== Todo-Project ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ===" + echo + + # ๋ฃจํŠธ ๊ถŒํ•œ ํ™•์ธ + if [ "$EUID" -ne 0 ]; then + log_error "์ด ์Šคํฌ๋ฆฝํŠธ๋Š” root ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. sudo๋กœ ์‹คํ–‰ํ•˜์„ธ์š”." + exit 1 + fi + + # ๋‹จ๊ณ„๋ณ„ ์‹คํ–‰ + check_synology_environment + create_directories + setup_environment + copy_migrations + deploy_application + show_completion_info +} + +# ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ +main "$@" diff --git a/docker-compose.yml b/docker-compose.yml index c742e2e..05b21dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,56 +1,84 @@ -version: '3.8' - services: frontend: build: context: ./frontend dockerfile: Dockerfile ports: - - "4000:80" + - "${FRONTEND_PORT:-4000}:80" depends_on: - backend - environment: - - API_BASE_URL=http://localhost:9000/api - volumes: - - ./frontend/static:/usr/share/nginx/html/static restart: unless-stopped + networks: + - todo-network backend: build: context: ./backend dockerfile: Dockerfile ports: - - "9000:9000" + - "${BACKEND_PORT:-9000}:9000" depends_on: - - database + database: + condition: service_healthy environment: - DATABASE_URL=postgresql+asyncpg://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} + - DEBUG=${DEBUG:-false} + - CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:4000", "http://127.0.0.1:4000"]} + # Synology MailPlus ํ†ตํ•ฉ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ) + - SYNOLOGY_MAIL_SERVER=${SYNOLOGY_MAIL_SERVER:-} + - SYNOLOGY_MAIL_USERNAME=${SYNOLOGY_MAIL_USERNAME:-} + - SYNOLOGY_MAIL_PASSWORD=${SYNOLOGY_MAIL_PASSWORD:-} + - ENABLE_MAIL_MONITORING=${ENABLE_MAIL_MONITORING:-false} + - MAIL_CHECK_INTERVAL=${MAIL_CHECK_INTERVAL:-300} + - TODO_KEYWORDS=${TODO_KEYWORDS:-todo,ํ• ์ผ,task} + - ATTACHMENTS_PATH=/data/uploads volumes: + # ์‹œ๋†€๋กœ์ง€ ๋ณผ๋ฅจ ๋งคํ•‘ + - ${SYNOLOGY_UPLOADS_PATH:-/volume1/todo-project/uploads}:/data/uploads + - ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}:/app/config + # ๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ (์‹œ๋†€๋กœ์ง€์—์„œ๋Š” ์ œ๊ฑฐ) - ./backend/src:/app/src - - ./backend/uploads:/app/uploads - - todo_uploads:/data/uploads restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - todo-network database: image: postgres:15-alpine ports: - - "5434:5432" + - "${DATABASE_PORT:-5432}:5432" environment: - POSTGRES_USER=${POSTGRES_USER:-todo_user} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-todo_password} - POSTGRES_DB=${POSTGRES_DB:-todo_db} volumes: + # ์‹œ๋†€๋กœ์ง€ ๋ณผ๋ฅจ ๋งคํ•‘ + - ${SYNOLOGY_DB_PATH:-/volume3/docker/todo-project/postgres}:/var/lib/postgresql/data + - ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}/migrations:/docker-entrypoint-initdb.d + # ๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ (์‹œ๋†€๋กœ์ง€์—์„œ๋Š” ์ œ๊ฑฐ) - postgres_data:/var/lib/postgresql/data - - ./database/init:/docker-entrypoint-initdb.d + - ./backend/migrations:/docker-entrypoint-initdb.d restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-todo_user} -d ${POSTGRES_DB:-todo_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - todo-network +networks: + todo-network: + driver: bridge + +# ๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ ๋ณผ๋ฅจ (์‹œ๋†€๋กœ์ง€์—์„œ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ) volumes: postgres_data: - todo_uploads: + todo_uploads: \ No newline at end of file diff --git a/env.synology.example b/env.synology.example new file mode 100644 index 0000000..55e2309 --- /dev/null +++ b/env.synology.example @@ -0,0 +1,46 @@ +# ============================================================================= +# Todo-Project ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ํ™˜๊ฒฝ ์„ค์ • +# ============================================================================= +# ์ด ํŒŒ์ผ์„ .env๋กœ ๋ณต์‚ฌํ•˜์—ฌ ์‚ฌ์šฉํ•˜์„ธ์š”: cp env.synology.example .env + +# --- ํ•„์ˆ˜ ์„ค์ • (๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝํ•˜์„ธ์š”!) --- +SECRET_KEY=YOUR_VERY_LONG_AND_RANDOM_SECRET_KEY_FOR_SYNOLOGY_PRODUCTION +POSTGRES_PASSWORD=YOUR_SECURE_DATABASE_PASSWORD + +# --- ํฌํŠธ ์„ค์ • --- +FRONTEND_PORT=4000 +BACKEND_PORT=9000 +DATABASE_PORT=5432 + +# --- ์‹œ๋†€๋กœ์ง€ ๋ณผ๋ฅจ ๊ฒฝ๋กœ ์„ค์ • --- +# ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์ €์žฅ์†Œ (volume1) +SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads + +# ์„ค์ • ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ €์žฅ์†Œ (volume3) +SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config +SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres + +# --- ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • --- +DEBUG=false +POSTGRES_USER=todo_user +POSTGRES_DB=todo_db + +# --- CORS ์„ค์ • (์‹œ๋†€๋กœ์ง€ IP/๋„๋ฉ”์ธ์— ๋งž๊ฒŒ ์ˆ˜์ •) --- +# ์˜ˆ์‹œ: CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me:4000"] +CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"] + +# --- Synology MailPlus ํ†ตํ•ฉ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ) --- +SYNOLOGY_MAIL_SERVER= +SYNOLOGY_MAIL_USERNAME= +SYNOLOGY_MAIL_PASSWORD= +ENABLE_MAIL_MONITORING=false +MAIL_CHECK_INTERVAL=300 +TODO_KEYWORDS=todo,ํ• ์ผ,task + +# ============================================================================= +# ์‹œ๋†€๋กœ์ง€ ๋ฐฐํฌ ์ „ ์ฒดํฌ๋ฆฌ์ŠคํŠธ: +# 1. SECRET_KEY์™€ POSTGRES_PASSWORD๋ฅผ ์•ˆ์ „ํ•œ ๊ฐ’์œผ๋กœ ๋ณ€๊ฒฝ +# 2. CORS_ORIGINS์— ์‹œ๋†€๋กœ์ง€ IP/๋„๋ฉ”์ธ ์ถ”๊ฐ€ +# 3. ๋ณผ๋ฅจ ๊ฒฝ๋กœ๊ฐ€ ์‹ค์ œ ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ +# 4. ํฌํŠธ๊ฐ€ ์‹œ๋†€๋กœ์ง€์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ์ง€ ํ™•์ธ +# ============================================================================= \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8562e2b..2261d98 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,8 +4,8 @@ FROM nginx:alpine # ์ •์  ํŒŒ์ผ๋“ค์„ nginx ์›น ๋ฃจํŠธ๋กœ ๋ณต์‚ฌ COPY . /usr/share/nginx/html/ -# nginx ์„ค์ • ํŒŒ์ผ ๋ณต์‚ฌ (์žˆ๋Š” ๊ฒฝ์šฐ) -# COPY nginx.conf /etc/nginx/nginx.conf +# nginx ์„ค์ • ํŒŒ์ผ ๋ณต์‚ฌ +COPY nginx.conf /etc/nginx/conf.d/default.conf # ํฌํŠธ 80 ๋…ธ์ถœ EXPOSE 80 diff --git a/frontend/archive.html b/frontend/archive.html new file mode 100644 index 0000000..b5c19a2 --- /dev/null +++ b/frontend/archive.html @@ -0,0 +1,574 @@ + + + + + + ์•„์นด์ด๋ธŒ - Todo Project + + + + + + + + + +
+
+ +
+
+ + +
+ +
+
+
+ + +
+ +
+ + + + + +
+
+
+ + +
+ +
+ + +
+ + + + + + + diff --git a/frontend/board.html b/frontend/board.html new file mode 100644 index 0000000..9afe110 --- /dev/null +++ b/frontend/board.html @@ -0,0 +1,1141 @@ + + + + + + ๋ณด๋“œ - Todo Project + + + + + + + + + +
+
+ +
+
+ + +
+ +
+ +
+ + +
+ + + + + + + + + + diff --git a/frontend/calendar.html b/frontend/calendar.html deleted file mode 100644 index 07c0313..0000000 --- a/frontend/calendar.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - - ์บ˜๋ฆฐ๋” - ๋งˆ๊ฐ ๊ธฐํ•œ์ด ์žˆ๋Š” ์ผ๋“ค - - - - - -
- -
-
-
-
- - -

์บ˜๋ฆฐ๋”

- ๋งˆ๊ฐ ๊ธฐํ•œ์ด ์žˆ๋Š” ์ผ๋“ค -
- -
- - - -
-
-
-
- - -
- -
-
- -

์บ˜๋ฆฐ๋” ๊ด€๋ฆฌ

-
-

- ๋งˆ๊ฐ ๊ธฐํ•œ์ด ์žˆ๋Š” ์ผ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์šฐ์„ ์ˆœ์œ„์— ๋”ฐ๋ผ ๊ณ„ํš์ ์œผ๋กœ ์ง„ํ–‰ํ•ด๋ณด์„ธ์š”. -

-
-
-
๐Ÿšจ ๊ธด๊ธ‰
-
3์ผ ์ด๋‚ด ๋งˆ๊ฐ
-
-
-
โš ๏ธ ์ฃผ์˜
-
1์ฃผ์ผ ์ด๋‚ด ๋งˆ๊ฐ
-
-
-
๐Ÿ“… ์—ฌ์œ 
-
1์ฃผ์ผ ์ด์ƒ ๋‚จ์Œ
-
-
-
- - -
-
-
- - - - - -
- -
- - -
-
-
- - -
-
-

- ๋งˆ๊ฐ ๊ธฐํ•œ๋ณ„ ๋ชฉ๋ก -

-
- -
- -
- -
- -

์•„์ง ๋งˆ๊ฐ ๊ธฐํ•œ์ด ์„ค์ •๋œ ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.

-

๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ํ•ญ๋ชฉ์„ ๋“ฑ๋กํ•˜๊ณ  ๋งˆ๊ฐ ๊ธฐํ•œ์„ ์„ค์ •ํ•ด๋ณด์„ธ์š”!

- -
-
-
-
- - - - - - - - diff --git a/frontend/checklist.html b/frontend/checklist.html deleted file mode 100644 index 7e05218..0000000 --- a/frontend/checklist.html +++ /dev/null @@ -1,604 +0,0 @@ - - - - - - ์ฒดํฌ๋ฆฌ์ŠคํŠธ - ๊ธฐํ•œ ์—†๋Š” ์ผ๋“ค - - - - - -
- -
-
-
-
- - -

์ฒดํฌ๋ฆฌ์ŠคํŠธ

- ๊ธฐํ•œ ์—†๋Š” ์ผ๋“ค -
- -
- - - -
-
-
-
- - -
- -
-
- -

์ฒดํฌ๋ฆฌ์ŠคํŠธ ๊ด€๋ฆฌ

-
-

- ๊ธฐํ•œ์ด ์—†๋Š” ์ผ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์–ธ์ œ๋“  ํ•  ์ˆ˜ ์žˆ๋Š” ์ผ๋“ค์„ ์ฒดํฌํ•ด๋‚˜๊ฐ€์„ธ์š”. -

-
-
-
๐Ÿ“ ํ•  ์ผ
-
์•„์ง ์™„๋ฃŒํ•˜์ง€ ์•Š์€ ์ผ๋“ค
-
-
-
โœ… ์™„๋ฃŒ
-
์™„๋ฃŒํ•œ ์ผ๋“ค
-
-
-
๐Ÿ“Š ์ง„ํ–‰๋ฅ 
-
0% ์™„๋ฃŒ
-
-
-
- - -
-
-

- ์ „์ฒด ์ง„ํ–‰๋ฅ  -

-
- 0 / 0 ์™„๋ฃŒ -
-
-
-
-
-
- - -
-
-
- - - -
- -
- - - -
-
-
- - -
-
-

- ์ฒดํฌ๋ฆฌ์ŠคํŠธ ๋ชฉ๋ก -

-
- -
- -
- -
- -

์•„์ง ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.

-

๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ๊ธฐํ•œ ์—†๋Š” ํ•ญ๋ชฉ์„ ๋“ฑ๋กํ•ด๋ณด์„ธ์š”!

- -
-
-
-
- - - - - - - - diff --git a/frontend/classify.html b/frontend/classify.html deleted file mode 100644 index 61994eb..0000000 --- a/frontend/classify.html +++ /dev/null @@ -1,623 +0,0 @@ - - - - - - INDEX - Todo Project - - - - - -
- -
-
-
-
- - -

INDEX

- 0 -
- -
- - - - -
-
-
-
- - -
- -
- -
-
-
- -
-
-

๋ถ„๋ฅ˜ ๋Œ€๊ธฐ

-

0

-
-
-
- -
-
-
- -
-
-

Todo ์ด๋™

-

0

-
-
-
- -
-
-
- -
-
-

์บ˜๋ฆฐ๋” ์ด๋™

-

0

-
-
-
- -
-
-
- -
-
-

์ฒดํฌ๋ฆฌ์ŠคํŠธ ์ด๋™

-

0

-
-
-
-
- - -
-
-
- - - - -
- -
- - - -
-
-
- - -
- -
- - - -
-
- - - - - - - - - diff --git a/frontend/dashboard.html b/frontend/dashboard.html deleted file mode 100644 index ce4c486..0000000 --- a/frontend/dashboard.html +++ /dev/null @@ -1,2325 +0,0 @@ - - - - - - ๋Œ€์‹œ๋ณด๋“œ - Todo Project - - - - - -
- -
-
-
-
- - -

๋Œ€์‹œ๋ณด๋“œ

- -
- -
- - - - - - -
- - - -
- - - -
-
-
-
- - -
- -
- - -
-
-
-
- -
-
-

์ „์ฒด Todo

-

0

-
-
-
- -
-
-
- -
-
-

์™„๋ฃŒ๋จ

-

0

-
-
-
- -
-
-
- -
-
-

์ง„ํ–‰ ์ค‘

-

0

-
-
-
- -
-
-
- -
-
-

์˜ค๋Š˜ ํ•  ์ผ

-

0

-
-
-
- - -
-
-
-
- -
-
-

์ฒดํฌ๋ฆฌ์ŠคํŠธ

-

0

-
-
- - -
-
-
- - -
- -
-
-
- -

- -
- -
-
-
- Todo -
-
-
- ์บ˜๋ฆฐ๋” -
-
-
- ์ฒดํฌ๋ฆฌ์ŠคํŠธ -
-
-
-
- - -
-
- -
์ผ
-
์›”
-
ํ™”
-
์ˆ˜
-
๋ชฉ
-
๊ธˆ
-
ํ† 
- - -
-
-
- - -
- -
-
-
-
- -
-
-

์ฒดํฌ๋ฆฌ์ŠคํŠธ

-

์—…๋กœ๋“œ๋œ ํ•ญ๋ชฉ๋“ค โ€ข Todo๋‚˜ ์บ˜๋ฆฐ๋”๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ

-
-
-
-
์ง„ํ–‰๋ฅ 
-
-
-
-
- 0/0 -
-
-
-
- - -
-
- -
- -

์•„์ง ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.

-

์œ„์˜ ์—…๋กœ๋“œ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•ด์„œ ์ƒˆ๋กœ์šด ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”!

-

์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ์€ ์—ฌ๊ธฐ์„œ Todo๋‚˜ ์บ˜๋ฆฐ๋”๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

-
-
-
-
-
- - -
- -
-
-

-

-
-
- - -
-

- ์˜ค๋Š˜์˜ ์ผ์ • -

-
- -
-
- - -
-
-

- ์ฒดํฌ๋ฆฌ์ŠคํŠธ -

-
- 0/0 -
-
- -
- -
-
-
-
-
- - - - - - - - - - - - - - diff --git a/frontend/inbox.html b/frontend/inbox.html new file mode 100644 index 0000000..92dfc22 --- /dev/null +++ b/frontend/inbox.html @@ -0,0 +1,798 @@ + + + + + + ์ˆ˜์‹ ํ•จ - Todo Project + + + + + + + + + +
+
+ +
+
+ + +
+
+
+ +
+ + +
+
+ + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index e44af12..6380789 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,57 +33,77 @@ + + + + +
+
+ +
+
+ + +
+ +
+
+
+

+ + +

+

์˜ค๋Š˜ ํ•ด์•ผ ํ•  ์ผ๋“ค

+
+
+
+ + +
+
+ +
+ + +
+
+ + + + + + + + + + diff --git a/frontend/todo.html b/frontend/todo.html deleted file mode 100644 index be222ab..0000000 --- a/frontend/todo.html +++ /dev/null @@ -1,353 +0,0 @@ - - - - - - Todo - ์‹œ์ž‘ ๋‚ ์งœ๊ฐ€ ์žˆ๋Š” ์ผ๋“ค - - - - - -
- -
-
-
-
- - -

Todo

- ์‹œ์ž‘ ๋‚ ์งœ๊ฐ€ ์žˆ๋Š” ์ผ๋“ค -
- -
- - - -
-
-
-
- - -
- -
-
- -

Todo ๊ด€๋ฆฌ

-
-

- ์‹œ์ž‘ ๋‚ ์งœ๊ฐ€ ์ •ํ•ด์ง„ ์ผ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์–ธ์ œ ์‹œ์ž‘ํ• ์ง€ ๊ณ„ํš์„ ์„ธ์šฐ๊ณ  ์‹คํ–‰ํ•ด๋ณด์„ธ์š”. -

-
-
-
๐Ÿ“… ์‹œ์ž‘ ์˜ˆ์ •
-
์•„์ง ์‹œ์ž‘ํ•˜์ง€ ์•Š์€ ์ผ๋“ค
-
-
-
๐Ÿ”ฅ ์ง„ํ–‰ ์ค‘
-
ํ˜„์žฌ ์ž‘์—… ์ค‘์ธ ์ผ๋“ค
-
-
-
โœ… ์™„๋ฃŒ
-
์™„๋ฃŒ๋œ ์ผ๋“ค
-
-
-
- - -
-
-
- - - - -
- -
- - -
-
-
- - -
-
-

- Todo ๋ชฉ๋ก -

-
- -
- -
- -
- -

์•„์ง ์‹œ์ž‘ ๋‚ ์งœ๊ฐ€ ์„ค์ •๋œ ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.

-

๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ํ•ญ๋ชฉ์„ ๋“ฑ๋กํ•˜๊ณ  ์‹œ์ž‘ ๋‚ ์งœ๋ฅผ ์„ค์ •ํ•ด๋ณด์„ธ์š”!

- -
-
-
-
- - - - - - - - diff --git a/frontend/upload.html b/frontend/upload.html new file mode 100644 index 0000000..5c75292 --- /dev/null +++ b/frontend/upload.html @@ -0,0 +1,853 @@ + + + + + + ์ƒˆ ๋ฉ”๋ชจ - Todo Project + + + + + + + + + + + + + + +
+
+ +
+
+ + +
+ +
+
+
+

+ + ์ตœ๊ทผ ์ €์žฅ๋œ ๋ฉ”๋ชจ +

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