From f068c55e2ba3614041b4f1f30d762e2a9276784d Mon Sep 17 00:00:00 2001 From: "dshan@yes24.com" Date: Tue, 12 May 2026 23:52:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20FastAPI=20=EA=B8=B0=EB=B0=98=20SNS=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B5=AC=EC=B6=95,=20openapi.yam?= =?UTF-8?q?l=20=EA=B8=B0=EB=B0=98=20API=20=EA=B5=AC=ED=98=84,=20FileRespon?= =?UTF-8?q?se=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EA=B0=9C=EB=B0=9C=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 5 + openapi.yaml | 315 ++++++++++++++++++++++++++++++++++++++ python/__init__.py | 1 + python/compare_openapi.py | 34 ++++ python/database.py | 44 ++++++ python/main.py | 308 +++++++++++++++++++++++++++++++++++++ python/models.py | 39 +++++ python/sns_api.db | Bin 0 -> 28672 bytes 8 files changed, 746 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 openapi.yaml create mode 100644 python/__init__.py create mode 100644 python/compare_openapi.py create mode 100644 python/database.py create mode 100644 python/main.py create mode 100644 python/models.py create mode 100644 python/sns_api.db diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2232822 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.autoApprove": true, + "chat.agent.maxRequests": 100, + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..ef80f56 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,315 @@ +openapi: 3.0.1 +info: + title: Simple Social Media API + version: 1.0.0 + description: | + 간단한 소셜 미디어 애플리케이션의 백엔드 API입니다. 게시물, 댓글, 좋아요 기능을 제공합니다. + 모든 엔드포인트는 `/api`로 시작합니다. + contact: + name: Contoso + url: https://contoso.com +servers: + - url: http://localhost:8080/api + description: Local development server +paths: + /posts: + get: + summary: 게시물 목록 조회 + description: 모든 게시물 목록을 반환합니다. + responses: + '200': + description: 게시물 목록 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + post: + summary: 게시물 생성 + description: 새 게시물을 생성합니다. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostCreateRequest' + responses: + '201': + description: 게시물 생성됨 + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + description: 잘못된 요청 + /posts/{postId}: + get: + summary: 단일 게시물 조회 + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '200': + description: 게시물 상세 + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '404': + description: 게시물을 찾을 수 없음 + patch: + summary: 게시물 업데이트 + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostUpdateRequest' + responses: + '200': + description: 게시물 수정됨 + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + description: 잘못된 요청 + '404': + description: 게시물을 찾을 수 없음 + delete: + summary: 게시물 삭제 + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '204': + description: 게시물 삭제됨 + '404': + description: 게시물을 찾을 수 없음 + /posts/{postId}/comments: + get: + summary: 게시물의 댓글 목록 조회 + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '200': + description: 댓글 목록 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Comment' + '404': + description: 게시물을 찾을 수 없음 + post: + summary: 댓글 생성 + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommentCreateRequest' + responses: + '201': + description: 댓글 생성됨 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: 잘못된 요청 + '404': + description: 게시물을 찾을 수 없음 + /posts/{postId}/comments/{commentId}: + get: + summary: 특정 댓글 조회 + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + responses: + '200': + description: 댓글 상세 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '404': + description: 댓글을 찾을 수 없음 + patch: + summary: 댓글 업데이트 + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommentUpdateRequest' + responses: + '200': + description: 댓글 수정됨 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: 잘못된 요청 + '404': + description: 댓글을 찾을 수 없음 + delete: + summary: 댓글 삭제 + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + responses: + '204': + description: 댓글 삭제됨 + '404': + description: 댓글을 찾을 수 없음 + /posts/{postId}/likes: + post: + summary: 게시물 좋아요 + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LikeRequest' + responses: + '201': + description: 좋아요 성공 + '400': + description: 잘못된 요청 + '404': + description: 게시물을 찾을 수 없음 + delete: + summary: 게시물 좋아요 취소 + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '204': + description: 좋아요 취소됨 + '404': + description: 게시물을 찾을 수 없음 +components: + parameters: + PostId: + name: postId + in: path + required: true + schema: + type: string + description: 게시물 ID + CommentId: + name: commentId + in: path + required: true + schema: + type: string + description: 댓글 ID + schemas: + Post: + type: object + properties: + id: + type: string + username: + type: string + content: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + likes: + type: integer + comments: + type: integer + required: + - id + - username + - content + - createdAt + - updatedAt + - likes + - comments + PostCreateRequest: + type: object + properties: + username: + type: string + content: + type: string + required: + - username + - content + PostUpdateRequest: + type: object + properties: + username: + type: string + content: + type: string + required: + - username + - content + Comment: + type: object + properties: + id: + type: string + postId: + type: string + username: + type: string + content: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - postId + - username + - content + - createdAt + - updatedAt + CommentCreateRequest: + type: object + properties: + username: + type: string + content: + type: string + required: + - username + - content + CommentUpdateRequest: + type: object + properties: + username: + type: string + content: + type: string + required: + - username + - content + LikeRequest: + type: object + properties: + username: + type: string + required: + - username diff --git a/python/__init__.py b/python/__init__.py new file mode 100644 index 0000000..d9eeb37 --- /dev/null +++ b/python/__init__.py @@ -0,0 +1 @@ +# 패키지 인식용 빈 파일 diff --git a/python/compare_openapi.py b/python/compare_openapi.py new file mode 100644 index 0000000..f848a64 --- /dev/null +++ b/python/compare_openapi.py @@ -0,0 +1,34 @@ +import requests +import yaml +import sys +from pathlib import Path + +def main(): + # FastAPI 서버에서 openapi.yaml 다운로드 + url = "http://127.0.0.1:8000/openapi.yaml" + response = requests.get(url) + if response.status_code != 200: + print(f"[ERROR] 서버에서 openapi.yaml을 가져오지 못했습니다: {response.status_code}") + sys.exit(1) + server_yaml = yaml.safe_load(response.text) + + # 로컬 openapi.yaml 읽기 + local_path = Path(__file__).parent.parent / "openapi.yaml" + with open(local_path, "r", encoding="utf-8") as f: + local_yaml = yaml.safe_load(f) + + # 비교 + if server_yaml == local_yaml: + print("✅ 서버의 openapi.yaml과 로컬 openapi.yaml이 완전히 일치합니다.") + else: + print("❌ 서버의 openapi.yaml과 로컬 openapi.yaml이 다릅니다.") + # 차이점 출력 (간단 비교) + import difflib + server_lines = yaml.dump(server_yaml, sort_keys=True).splitlines() + local_lines = yaml.dump(local_yaml, sort_keys=True).splitlines() + diff = difflib.unified_diff(local_lines, server_lines, fromfile='local openapi.yaml', tofile='server openapi.yaml', lineterm='') + for line in diff: + print(line) + +if __name__ == "__main__": + main() diff --git a/python/database.py b/python/database.py new file mode 100644 index 0000000..8b02ba9 --- /dev/null +++ b/python/database.py @@ -0,0 +1,44 @@ +import aiosqlite +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), 'sns_api.db') + +POSTS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + content TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + likes INTEGER NOT NULL DEFAULT 0, + comments INTEGER NOT NULL DEFAULT 0 +); +""" + +COMMENTS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + postId TEXT NOT NULL, + username TEXT NOT NULL, + content TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + FOREIGN KEY(postId) REFERENCES posts(id) ON DELETE CASCADE +); +""" + +LIKES_SCHEMA = """ +CREATE TABLE IF NOT EXISTS likes ( + postId TEXT NOT NULL, + username TEXT NOT NULL, + PRIMARY KEY(postId, username), + FOREIGN KEY(postId) REFERENCES posts(id) ON DELETE CASCADE +); +""" + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(POSTS_SCHEMA) + await db.execute(COMMENTS_SCHEMA) + await db.execute(LIKES_SCHEMA) + await db.commit() diff --git a/python/main.py b/python/main.py new file mode 100644 index 0000000..0b089c9 --- /dev/null +++ b/python/main.py @@ -0,0 +1,308 @@ +from fastapi import FastAPI, HTTPException, Path, status +from fastapi.middleware.cors import CORSMiddleware +import uvicorn +import yaml +import os +import uuid +import aiosqlite +from fastapi.responses import HTMLResponse, FileResponse +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) +from database import init_db, DB_PATH +from models import Post, PostCreateRequest, PostUpdateRequest, Comment, CommentCreateRequest, CommentUpdateRequest, LikeRequest + + + + +# 댓글 목록 조회 +@app.get("/api/posts/{postId}/comments", response_model=list[Comment]) +async def list_comments(postId: str = Path(...)): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT id FROM posts WHERE id = ?", (postId,)) as cursor: + post = await cursor.fetchone() + if not post: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + async with db.execute("SELECT * FROM comments WHERE postId = ? ORDER BY createdAt ASC", (postId,)) as cursor: + rows = await cursor.fetchall() + return [Comment(**dict(row)) for row in rows] + +# 댓글 생성 +@app.post("/api/posts/{postId}/comments", response_model=Comment, status_code=status.HTTP_201_CREATED) +async def create_comment(req: CommentCreateRequest, postId: str = Path(...)): + comment_id = str(uuid.uuid4()) + now = now_iso() + comment = Comment( + id=comment_id, + postId=postId, + username=req.username, + content=req.content, + createdAt=now, + updatedAt=now + ) + import os + print(f"[DEBUG][create_comment] 진입: postId={postId}, DB_PATH={DB_PATH}, exists={os.path.exists(DB_PATH)}", flush=True) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("PRAGMA journal_mode=WAL;") + db.row_factory = aiosqlite.Row + print(f"[DEBUG][create_comment] 쿼리: SELECT id FROM posts WHERE id = '{postId}'", flush=True) + async with db.execute("SELECT id FROM posts WHERE id = ?", (postId,)) as cursor: + post = await cursor.fetchone() + print(f"[DEBUG][create_comment] 쿼리 결과: postId={postId}, row={post}", flush=True) + if not post: + print(f"[ERROR][create_comment] postId={postId}로 posts 테이블에서 조회 실패", flush=True) + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + await db.execute( + """ + INSERT INTO comments (id, postId, username, content, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + (comment.id, comment.postId, comment.username, comment.content, comment.createdAt, comment.updatedAt) + ) + await db.execute("UPDATE posts SET comments = comments + 1 WHERE id = ?", (postId,)) + await db.commit() + return comment + +# 특정 댓글 조회 +@app.get("/api/posts/{postId}/comments/{commentId}", response_model=Comment) +async def get_comment(postId: str, commentId: str): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM comments WHERE id = ? AND postId = ?", (commentId, postId)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="댓글을 찾을 수 없음") + return Comment(**dict(row)) + +# 댓글 업데이트 +@app.patch("/api/posts/{postId}/comments/{commentId}", response_model=Comment) +async def update_comment(postId: str, commentId: str, req: CommentUpdateRequest): + now = now_iso() + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM comments WHERE id = ? AND postId = ?", (commentId, postId)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="댓글을 찾을 수 없음") + await db.execute( + """ + UPDATE comments SET username = ?, content = ?, updatedAt = ? WHERE id = ? AND postId = ? + """, + (req.username, req.content, now, commentId, postId) + ) + await db.commit() + async with db.execute("SELECT * FROM comments WHERE id = ? AND postId = ?", (commentId, postId)) as cursor: + updated = await cursor.fetchone() + return Comment(**dict(updated)) + +# 댓글 삭제 +@app.delete("/api/posts/{postId}/comments/{commentId}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_comment(postId: str, commentId: str): + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute("SELECT * FROM comments WHERE id = ? AND postId = ?", (commentId, postId)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="댓글을 찾을 수 없음") + await db.execute("DELETE FROM comments WHERE id = ? AND postId = ?", (commentId, postId)) + await db.execute("UPDATE posts SET comments = comments - 1 WHERE id = ? AND comments > 0", (postId,)) + await db.commit() + return None + +# 게시물 좋아요 +@app.post("/api/posts/{postId}/likes", status_code=status.HTTP_201_CREATED) +async def like_post(postId: str, req: LikeRequest): + async with aiosqlite.connect(DB_PATH) as db: + post = await db.execute_fetchone("SELECT id FROM posts WHERE id = ?", (postId,)) + if not post: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + # 이미 좋아요 했는지 확인 + exists = await db.execute_fetchone("SELECT 1 FROM likes WHERE postId = ? AND username = ?", (postId, req.username)) + if exists: + raise HTTPException(status_code=400, detail="이미 좋아요를 눌렀습니다.") + await db.execute("INSERT INTO likes (postId, username) VALUES (?, ?)", (postId, req.username)) + await db.execute("UPDATE posts SET likes = likes + 1 WHERE id = ?", (postId,)) + await db.commit() + return {"message": "좋아요 성공"} + +# 게시물 좋아요 취소 +@app.delete("/api/posts/{postId}/likes", status_code=status.HTTP_204_NO_CONTENT) +async def unlike_post(postId: str, username: str): + async with aiosqlite.connect(DB_PATH) as db: + post = await db.execute_fetchone("SELECT id FROM posts WHERE id = ?", (postId,)) + if not post: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + exists = await db.execute_fetchone("SELECT 1 FROM likes WHERE postId = ? AND username = ?", (postId, username)) + if not exists: + raise HTTPException(status_code=404, detail="좋아요를 찾을 수 없음") + await db.execute("DELETE FROM likes WHERE postId = ? AND username = ?", (postId, username)) + await db.execute("UPDATE posts SET likes = likes - 1 WHERE id = ? AND likes > 0", (postId,)) + await db.commit() + return None + + + + + +# CORS: 모든 도메인 허용 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- Swagger UI 및 OpenAPI 문서 커스텀 엔드포인트 --- +OPENAPI_YAML_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "openapi.yaml")) + +@app.get("/openapi.yaml", include_in_schema=False) +async def get_openapi_yaml(): + return FileResponse(OPENAPI_YAML_PATH, media_type="text/yaml") + + +@app.get("/", include_in_schema=False) +async def swagger_ui(): + # Swagger UI HTML을 커스텀하여 서버 주소를 동적으로 현재 origin + '/api'로 설정 + html = """ + + + + + Simple Social Media API - Swagger UI + + + + +
+ + + + + + """ + return HTMLResponse(content=html, status_code=200) + + +@app.on_event("startup") +async def on_startup(): + await init_db() + + +# 게시물 목록 조회 +@app.get("/api/posts", response_model=list[Post]) +async def list_posts(): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + rows = await db.execute_fetchall("SELECT * FROM posts ORDER BY createdAt DESC") + return [Post(**dict(row)) for row in rows] + +# 게시물 생성 +@app.post("/api/posts", response_model=Post, status_code=status.HTTP_201_CREATED) +async def create_post(req: PostCreateRequest): + import os + print(f"[DEBUG][create_post] DB_PATH: {DB_PATH}, exists: {os.path.exists(DB_PATH)}", flush=True) + post_id = str(uuid.uuid4()) + now = now_iso() + post = Post( + id=post_id, + username=req.username, + content=req.content, + createdAt=now, + updatedAt=now, + likes=0, + comments=0 + ) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + """ + INSERT INTO posts (id, username, content, createdAt, updatedAt, likes, comments) + VALUES (?, ?, ?, ?, ?, 0, 0) + """, + (post.id, post.username, post.content, post.createdAt, post.updatedAt) + ) + await db.commit() + return post + +# 단일 게시물 조회 +@app.get("/api/posts/{postId}", response_model=Post) +async def get_post(postId: str = Path(...)): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM posts WHERE id = ?", (postId,)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + print(f"[DEBUG][get_post] DB_PATH: {DB_PATH}, exists: {os.path.exists(DB_PATH)}", flush=True) + print(f"[DEBUG][get_post] SELECT * FROM posts WHERE id = {postId}", flush=True) + print(f"[DEBUG][get_post] row: {row}", flush=True) + data = dict(row) + required_fields = {"id", "username", "content", "createdAt", "updatedAt", "likes", "comments"} + missing = required_fields - data.keys() + if missing: + raise HTTPException(status_code=500, detail=f"DB 데이터에 필수 필드 누락: {missing}") + try: + return Post(**data) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Post 모델 변환 오류: {e}") + +# 게시물 업데이트 +@app.patch("/api/posts/{postId}", response_model=Post) +async def update_post(postId: str, req: PostUpdateRequest): + now = now_iso() + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM posts WHERE id = ?", (postId,)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + await db.execute( + """ + UPDATE posts SET username = ?, content = ?, updatedAt = ? WHERE id = ? + """, + (req.username, req.content, now, postId) + ) + await db.commit() + async with db.execute("SELECT * FROM posts WHERE id = ?", (postId,)) as cursor: + updated = await cursor.fetchone() + return Post(**dict(updated)) + +# 게시물 삭제 +@app.delete("/api/posts/{postId}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post(postId: str): + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute("SELECT * FROM posts WHERE id = ?", (postId,)) as cursor: + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="게시물을 찾을 수 없음") + await db.execute("DELETE FROM posts WHERE id = ?", (postId,)) + await db.commit() + return None + +# ISO8601 타임스탬프 생성 함수 +from datetime import datetime, timezone +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True) diff --git a/python/models.py b/python/models.py new file mode 100644 index 0000000..6e395d7 --- /dev/null +++ b/python/models.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class Post(BaseModel): + id: str + username: str + content: str + createdAt: str + updatedAt: str + likes: int + comments: int + +class PostCreateRequest(BaseModel): + username: str + content: str + +class PostUpdateRequest(BaseModel): + username: str + content: str + +class Comment(BaseModel): + id: str + postId: str + username: str + content: str + createdAt: str + updatedAt: str + +class CommentCreateRequest(BaseModel): + username: str + content: str + +class CommentUpdateRequest(BaseModel): + username: str + content: str + +class LikeRequest(BaseModel): + username: str diff --git a/python/sns_api.db b/python/sns_api.db new file mode 100644 index 0000000000000000000000000000000000000000..ddff3e51a8ef1635f6c8c115d2e421827a9b8f51 GIT binary patch literal 28672 zcmeI&UuYaf90%~d&|cPcP1CAsnN~~sm{c3Z^o3d%9@{=_v#K@T`=nC) zODoOqY1Q9re^jniKcC;LtRD6T*bf3A00JNY0w4eaAOHd&@RSP-ovG=1y?cp9rMA=TIC`h~O3TrcUHZbTt{#_q{lQA0cbqpn`r5i$Hd?K-Nz3JaxVz(S zhgof}wO3Y~?Th-W&c%iCC!E#O{)^+cpI>h~E6Zz-by?Kg&UvTptSvbglH>Lly1}Bp zzNVjZT8>J$)V#3NJm<_VE|yC5m+A$ryAy<0`tNS3Usjv$e2*3`ABLQ+2;j_W68=qFDTPcSCCjxtQkA(P`cq{daf zvet2yo%TPqtZM6gbEDPK@%YrHW7QAusOoRx-|8#c42v`6jk>YAS}yy75rdl+ zvO*Ff#)U+lWD-&CGeby|M^WHzb^Y++-i^`qZy#R2r$6}V#^}@A`*(k%m|6&%h|mre zHa2W#HaIsVF<-#g#`viAVX>UXpoSDA2rLwFNf48SAvfS0g_huwx+ZnG%ozjQ25nHn zOiO0QIM6;UPNy*}LYa(YghDDk6@wAv(n!5DqmWW+;=reQVsM*bo0ttsEkZJ4WVH{A zr8Gv!Wki@SQD6ot1`Y)B0vsVzM8q(uFsK{mi(yC`iw2bjmHeOvw-2@ti&JR~;riUN zJT?44Fs0#oNK!LGmM>%^Ow%MX*GW-_iT@ggFc_hkF%Gm3E5-80>7}?tvFm!4DFkA~ z^buoNm8i#k6!=W}a03$@48r~(+ZHo!#CZ%gZ0cc(JU>#kDx`%Z3ncO*q3VOHIwpLaXSetMX5{*V-tGrS1;RF! z4JNrkNoJq}?Zam$RG{@Z5NB4PN|m56a$TjwB@$AYh%yT^PCRb;-Z;?c=i8%i_VoQ5 zw;tTPsvq%{wroml(`*n%EHi^Ct9@82s8q{ws!;eMuuP1CAi#*ZTz!|Yr)rvpYQHZD zCTyJQQJm4&cgJUK^u?X*6KC0&*hDl8RY_SVE~|Z3UCd{yH?`W^)n98LSO2X2SYy?j zwYl2;+Wn{fIuGLm0T2KI5C8!X009sH0T2KI5I8x3g~^RVo=LZvxMQAav2xro&&1Vq z+%eD8QYr43XYyw%?wDtKrWkk3GjB2(cg!=NG4cH5#!Q|mhWP%!`lo-b_SVUrb2u^x zfB*=900@8p2!H?xfB*=900@A<@e0KE|C8VUaJT<>q2bd(00ck)1V8`;KmY_l00ck) z1VG>f1=9QfCl~<^4FVtl0w4eaAOHd&00JNY0w4eaAaKkA>HYs>J`8+32!H?xfB*=9 U00@8p2!H?xfB*=9zzGWc4KH2lKmY&$ literal 0 HcmV?d00001