diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d30d44d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,93 @@ +name: Scoopadive Deploy to Lightsail + +on: + push: + branches: + - deploy + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + submodules: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/scoopadive:latest + build-args: | + BUILDKIT_INLINE_CACHE=1 + no-cache: true + + - name: SSH and deploy using docker-compose + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.LIGHTSAIL_HOST }} + username: ubuntu + key: ${{ secrets.LIGHTSAIL_SSH_KEY }} + + script: | + echo "==> 이동: /home/ubuntu/Backend" + cd /home/ubuntu/Backend + + echo "==> 환경변수 파일 생성 (.env)" + cat < .env + GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_REDIRECT=${{ secrets.GOOGLE_REDIRECT }} + GOOGLE_CALLBACK_URI=${{ secrets.GOOGLE_CALLBACK_URI }} + GOOGLE_SECRET=${{ secrets.GOOGLE_SECRET }} + + AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_STORAGE_BUCKET_NAME=${{ secrets.AWS_STORAGE_BUCKET_NAME }} + AWS_S3_REGION_NAME=${{ secrets.AWS_S3_REGION_NAME }} + AWS_S3_SIGNATURE_VERSION=${{ secrets.AWS_S3_SIGNATURE_VERSION }} + + WP_CLIENT_ID=${{ secrets.WP_CLIENT_ID }} + WP_CLIENT_SECRET=${{ secrets.WP_CLIENT_SECRET }} + WP_REDIRECT_URI=${{ secrets.WP_REDIRECT_URI }} + WP_REDIRECT_URI_SWAGGER=${{ secrets.WP_REDIRECT_URI_SWAGGER }} + + DB_NAME=${{ secrets.DB_NAME }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + + SECRET_KEY=${{ secrets.SECRET_KEY }} + + OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + + FISHIAL_CLIENT_ID=${{ secrets.FISHIAL_CLIENT_ID }} + FISHIAL_CLIENT_SECRET=${{ secrets.FISHIAL_CLIENT_SECRET }} + + EOF + + echo "==> 최신 이미지 Pull" + sudo docker-compose pull web + + echo "==> 기존 컨테이너 중지 및 제거" + sudo docker-compose down + + echo "==> 최신 이미지 기반 컨테이너 다시 실행" + sudo docker-compose up -d + + echo "==> 마이그레이션 실행" + sudo docker-compose exec -T web python manage.py migrate + + echo "✅ 배포 완료!" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2baa505 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +db.sqlite3 +.env +secrets.json + +# Python 관련 캐시 파일들 +__pycache__/ +*.pyc +*.pyo + +# Django Migrations — init 파일은 포함하고, 나머지만 제외 +**/migrations/*.pyc +**/migrations/*.pyo +**/migrations/__pycache__/ +!**/migrations/__init__.py + +# Visualized Graphviz Files +*.dot +*.png +swagger.html diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..c3f502a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..71a1a75 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,22 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a6218fe --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d4f9225 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/scoopadive-backend.iml b/.idea/scoopadive-backend.iml new file mode 100644 index 0000000..e9b2db3 --- /dev/null +++ b/.idea/scoopadive-backend.iml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..9fa0558 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d0fa28 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /scoopadive + +# ----------------------------- +# 시스템 패키지 설치 +# ----------------------------- +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + locales \ + && rm -rf /var/lib/apt/lists/* + +# ----------------------------- +# UTF-8 locale 설정 +# ----------------------------- +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +# ----------------------------- +COPY requirements.txt /scoopadive/ +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . /scoopadive/ + +EXPOSE 8000 + +CMD ["gunicorn", "scoopadive.wsgi:application", "-k","uvicorn.workers.UvicornWorker", "--workers", "1","--bind", "0.0.0.0:8000"] diff --git a/README.md b/README.md index 781894a..c14486b 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# Backend +# System Architecture + +scoopadive_stack_readme diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..a73c7a5 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,10 @@ + +from django.urls import path, include +from .views import GoogleLoginView, GoogleCallbackView + +app_name = "accounts" + +urlpatterns = [ + path('google/login/', GoogleLoginView.as_view(), name='google_login'), + path('google/callback/', GoogleCallbackView.as_view(), name='google_callback'), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..d91b82a --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,116 @@ +import requests +from django.http import JsonResponse +from django.shortcuts import redirect +from rest_framework.views import APIView +from rest_framework import permissions +from rest_framework_simplejwt.tokens import RefreshToken +from allauth.socialaccount.models import SocialAccount +from auths.models import User +from scoopadive.settings import GOOGLE_REDIRECT, GOOGLE_CLIENT_ID, GOOGLE_CALLBACK_URI, GOOGLE_SECRET + +FRONTEND_URL = "https://scoopadive.com" # 메인 페이지 URL + + +# -------------------------- +# 1. 구글 로그인 시작 +# -------------------------- +class GoogleLoginView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + state = "swagger" if request.GET.get("swagger") == "1" else "" + + auth_url = ( + f"{GOOGLE_REDIRECT}?response_type=code" + f"&client_id={GOOGLE_CLIENT_ID}" + f"&redirect_uri={GOOGLE_CALLBACK_URI}" + f"&scope=email%20profile%20openid" + f"&access_type=offline" + f"&prompt=consent" + ) + if state: + auth_url += f"&state={state}" # 👈 여기 추가 + return redirect(auth_url) + + + +# -------------------------- +# 2. 구글 OAuth 콜백 +# -------------------------- +class GoogleCallbackView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + + if not code: + return redirect(f"{FRONTEND_URL}/login?error=auth_code_missing") + + # 1️⃣ 구글 토큰 요청 + token_data = { + "code": code, + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_SECRET, + "redirect_uri": GOOGLE_CALLBACK_URI, + "grant_type": "authorization_code", + } + token_req = requests.post("https://oauth2.googleapis.com/token", data=token_data) + if token_req.status_code != 200: + return redirect(f"{FRONTEND_URL}/login?error=token_request_failed") + + access_token = token_req.json().get("access_token") + if not access_token: + return redirect(f"{FRONTEND_URL}/login?error=no_access_token") + + # 2️⃣ 구글 유저 정보 가져오기 + user_info_req = requests.get( + "https://www.googleapis.com/oauth2/v1/userinfo", + params={"access_token": access_token}, + ) + if user_info_req.status_code != 200: + return redirect(f"{FRONTEND_URL}/login?error=userinfo_request_failed") + + user_info = user_info_req.json() + email = user_info.get("email") + username = user_info.get("name") or email.split("@")[0] + uid = user_info.get("id") + if not email: + return redirect(f"{FRONTEND_URL}/login?error=no_email") + + # 3️⃣ User 생성 or 가져오기 + user, _ = User.objects.get_or_create( + email=email, + defaults={"username": username, "is_active": True} + ) + + # 4️⃣ SocialAccount 연결 + SocialAccount.objects.get_or_create( + provider="google", + uid=uid, + defaults={"user": user, "extra_data": user_info} + ) + + # 5️⃣ JWT 발급 + refresh = RefreshToken.for_user(user) + access_token_str = str(refresh.access_token) + + # 6️⃣ Swagger 모드면 JSON 반환 + if state == "swagger": # 👈 Swagger 모드일 때만 JSON + return JsonResponse({ + "access": access_token_str, + "refresh": str(refresh), + "email": email, + "username": username, + "id": user.id, + }) + + # 7️⃣ 기본은 프론트엔드로 redirect + frontend_redirect_url = ( + f"{FRONTEND_URL}/oauth2/redirect?" + f"token={access_token_str}" + f"&email={email}" + f"&name={username}" + f"&id={user.id}" + ) + return redirect(frontend_redirect_url) diff --git a/auths/__init__.py b/auths/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auths/admin.py b/auths/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/auths/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/auths/apps.py b/auths/apps.py new file mode 100644 index 0000000..c3db2e7 --- /dev/null +++ b/auths/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auths" diff --git a/auths/choices.py b/auths/choices.py new file mode 100644 index 0000000..c50cb18 --- /dev/null +++ b/auths/choices.py @@ -0,0 +1,45 @@ +License_Choices = ( + ('PADI Scuba Diver','PADI Scuba Diver'), + ('PADI Open Water Diver', 'PADI Open Water Diver'), + ('PADI Advanced Open Water Diver', 'PADI Advanced Open Water Diver'), + ('PADI Adventure Diver', 'PADI Adventure Diver'), + ('PADI Rescue Diver', 'PADI Rescue Diver'), + ('Emergency First Response (EFR)', 'Emergency First Response (EFR)'), + ('PADI Master Scuba Diver', 'PADI Master Scuba Diver'), + ('Deep Diver', 'Deep Diver'), + ('Night Diver', 'Night Diver'), + ('Wreck Diver', 'Wreck Diver'), + ('Underwater Navigation', 'Underwater Navigation'), + ('Peak Performance Buoyancy', 'Peak Performance Buoyancy'), + ('Enriched Air Diver (Nitrox)', 'Enriched Air Diver (Nitrox)'), + ('Dry Suit Diver', 'Dry Suit Diver'), + ('Search and Recovery Diver', 'Search and Recovery Diver'), + ('Drift Diver', 'Drift Diver'), + ('Altitude Diver', 'Altitude Diver'), + ('Boat Diver', 'Boat Diver'), + ('Sidemount Diver', 'Sidemount Diver'), + ('Digital Underwater Photographer', 'Digital Underwater Photographer'), + ('Underwater Naturalist', 'Underwater Naturalist'), + ('Multilevel Diver', 'Multilevel Diver'), + ('Fish Identification', 'Fish Identification'), + ('Ice Diver', 'Ice Diver'), + ('Cavern Diver', 'Cavern Diver'), + ('Self-Reliant Diver (for experienced divers)', 'Self-Reliant Diver (for experienced divers)'), + ('PADI Divemaster', 'PADI Divemaster'), + ('PADI Assistant Instructor', 'PADI Assistant Instructor'), + ('PADI Open Water Scuba Instructor (OWSI)', 'PADI Open Water Scuba Instructor (OWSI)'), + ('PADI Specialty Instructor', 'PADI Specialty Instructor'), + ('PADI Master Scuba Diver Trainer (MSDT)', 'PADI Master Scuba Diver Trainer (MSDT)'), + ('PADI IDC Staff Instructor', 'PADI IDC Staff Instructor'), + ('PADI Master Instructor', 'PADI Master Instructor'), + ('PADI Course Director', 'PADI Course Director'), + ('Tec 40', 'Tec 40'), + ('Tec 45', 'Tec 45'), + ('Tec 50', 'Tec 50'), + ('Tec Trimix 65', 'Tec Trimix 65'), + ('Tec Trimix Diver', 'Tec Trimix Diver'), + ('Tec Sidemount Diver', 'Tec Sidemount Diver'), + ('Tec Gas Blender', 'Tec Gas Blender'), + ('PADI Rebreather Diver', 'PADI Rebreather Diver'), + ('Advanced Rebreather Diver', 'Advanced Rebreather Diver') +) diff --git a/auths/migrations/0001_initial.py b/auths/migrations/0001_initial.py new file mode 100644 index 0000000..817dc9e --- /dev/null +++ b/auths/migrations/0001_initial.py @@ -0,0 +1,107 @@ +# Generated by Django 5.0.6 on 2025-07-09 10:40 + +import auths.models +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "email", + models.EmailField( + help_text="EMAIL ID.", + max_length=64, + unique=True, + verbose_name="email id", + ), + ), + ("username", models.CharField(max_length=30)), + ("country", models.CharField(max_length=20)), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + }, + managers=[ + ("objects", auths.models.UserManager()), + ], + ), + ] diff --git a/auths/migrations/0002_user_introduction_user_license_user_profile_image.py b/auths/migrations/0002_user_introduction_user_license_user_profile_image.py new file mode 100644 index 0000000..e7f1427 --- /dev/null +++ b/auths/migrations/0002_user_introduction_user_license_user_profile_image.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.6 on 2025-07-09 12:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auths", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="introduction", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="license", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="profile_image", + field=models.ImageField(blank=True, null=True, upload_to="profile_image"), + ), + ] diff --git a/auths/migrations/0003_user_dive_count_alter_user_license.py b/auths/migrations/0003_user_dive_count_alter_user_license.py new file mode 100644 index 0000000..d04753f --- /dev/null +++ b/auths/migrations/0003_user_dive_count_alter_user_license.py @@ -0,0 +1,89 @@ +# Generated by Django 5.0.6 on 2025-07-22 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auths", "0002_user_introduction_user_license_user_profile_image"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="dive_count", + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name="user", + name="license", + field=models.CharField( + blank=True, + choices=[ + ("PADI Scuba Diver", "PADI Scuba Diver"), + ("PADI Open Water Diver", "PADI Open Water Diver"), + ( + "PADI Advanced Open Water Diver", + "PADI Advanced Open Water Diver", + ), + ("PADI Adventure Diver", "PADI Adventure Diver"), + ("PADI Rescue Diver", "PADI Rescue Diver"), + ( + "Emergency First Response (EFR)", + "Emergency First Response (EFR)", + ), + ("PADI Master Scuba Diver", "PADI Master Scuba Diver"), + ("Deep Diver", "Deep Diver"), + ("Night Diver", "Night Diver"), + ("Wreck Diver", "Wreck Diver"), + ("Underwater Navigation", "Underwater Navigation"), + ("Peak Performance Buoyancy", "Peak Performance Buoyancy"), + ("Enriched Air Diver (Nitrox)", "Enriched Air Diver (Nitrox)"), + ("Dry Suit Diver", "Dry Suit Diver"), + ("Search and Recovery Diver", "Search and Recovery Diver"), + ("Drift Diver", "Drift Diver"), + ("Altitude Diver", "Altitude Diver"), + ("Boat Diver", "Boat Diver"), + ("Sidemount Diver", "Sidemount Diver"), + ( + "Digital Underwater Photographer", + "Digital Underwater Photographer", + ), + ("Underwater Naturalist", "Underwater Naturalist"), + ("Multilevel Diver", "Multilevel Diver"), + ("Fish Identification", "Fish Identification"), + ("Ice Diver", "Ice Diver"), + ("Cavern Diver", "Cavern Diver"), + ( + "Self-Reliant Diver (for experienced divers)", + "Self-Reliant Diver (for experienced divers)", + ), + ("PADI Divemaster", "PADI Divemaster"), + ("PADI Assistant Instructor", "PADI Assistant Instructor"), + ( + "PADI Open Water Scuba Instructor (OWSI)", + "PADI Open Water Scuba Instructor (OWSI)", + ), + ("PADI Specialty Instructor", "PADI Specialty Instructor"), + ( + "PADI Master Scuba Diver Trainer (MSDT)", + "PADI Master Scuba Diver Trainer (MSDT)", + ), + ("PADI IDC Staff Instructor", "PADI IDC Staff Instructor"), + ("PADI Master Instructor", "PADI Master Instructor"), + ("PADI Course Director", "PADI Course Director"), + ("Tec 40", "Tec 40"), + ("Tec 45", "Tec 45"), + ("Tec 50", "Tec 50"), + ("Tec Trimix 65", "Tec Trimix 65"), + ("Tec Trimix Diver", "Tec Trimix Diver"), + ("Tec Sidemount Diver", "Tec Sidemount Diver"), + ("Tec Gas Blender", "Tec Gas Blender"), + ("PADI Rebreather Diver", "PADI Rebreather Diver"), + ("Advanced Rebreather Diver", "Advanced Rebreather Diver"), + ], + max_length=100, + null=True, + ), + ), + ] diff --git a/auths/migrations/0004_user_birthday_user_budget_max_user_budget_min_and_more.py b/auths/migrations/0004_user_birthday_user_budget_max_user_budget_min_and_more.py new file mode 100644 index 0000000..7c62c1f --- /dev/null +++ b/auths/migrations/0004_user_birthday_user_budget_max_user_budget_min_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.0.6 on 2025-09-30 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auths", "0003_user_dive_count_alter_user_license"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="birthday", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="budget_max", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + migrations.AddField( + model_name="user", + name="budget_min", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + migrations.AddField( + model_name="user", + name="gender", + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name="user", + name="hobbies", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name="user", + name="last_dive_date", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="preferred_activities", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name="user", + name="preferred_atmosphere", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name="user", + name="preferred_depth_range", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + migrations.AddField( + model_name="user", + name="preferred_diving", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name="user", + name="residence", + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/auths/migrations/0005_remove_user_birthday_remove_user_budget_max_and_more.py b/auths/migrations/0005_remove_user_birthday_remove_user_budget_max_and_more.py new file mode 100644 index 0000000..9a77b1e --- /dev/null +++ b/auths/migrations/0005_remove_user_birthday_remove_user_budget_max_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.6 on 2025-09-30 11:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("auths", "0004_user_birthday_user_budget_max_user_budget_min_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="birthday", + ), + migrations.RemoveField( + model_name="user", + name="budget_max", + ), + migrations.RemoveField( + model_name="user", + name="budget_min", + ), + migrations.RemoveField( + model_name="user", + name="gender", + ), + migrations.RemoveField( + model_name="user", + name="hobbies", + ), + migrations.RemoveField( + model_name="user", + name="last_dive_date", + ), + migrations.RemoveField( + model_name="user", + name="preferred_activities", + ), + migrations.RemoveField( + model_name="user", + name="preferred_atmosphere", + ), + migrations.RemoveField( + model_name="user", + name="preferred_depth_range", + ), + migrations.RemoveField( + model_name="user", + name="preferred_diving", + ), + migrations.RemoveField( + model_name="user", + name="residence", + ), + ] diff --git a/auths/migrations/__init__.py b/auths/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auths/models.py b/auths/models.py new file mode 100644 index 0000000..5b5df71 --- /dev/null +++ b/auths/models.py @@ -0,0 +1,73 @@ +from django.db import models +from django.contrib.auth.models import BaseUserManager, AbstractBaseUser, PermissionsMixin +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from auths.choices import License_Choices + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + if not email: + raise ValueError('The given email must be set') + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self.create_user(email, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + profile_image = models.ImageField(upload_to='profile_image', null=True, blank=True) + username = models.CharField(max_length=30) + email = models.EmailField(verbose_name=_('email id'), max_length=64, unique=True, help_text='EMAIL ID.') + country = models.CharField(max_length=20) + + is_staff = models.BooleanField( + _('staff status'), + default=False, + help_text=_('Designates whether the user can log into this admin site.'), + ) + is_active = models.BooleanField( + _('active'), + default=True, + help_text=_( + 'Designates whether this user should be treated as active. ' + 'Unselect this instead of deleting accounts.' + ), + ) + date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + license = models.CharField(null=True, blank=True, choices=License_Choices, max_length=100) + dive_count = models.IntegerField(default=0) + introduction = models.TextField(null=True, blank=True) + objects = UserManager() + EMAIL_FIELD = 'email' + USERNAME_FIELD = 'email' + + class Meta: + verbose_name = _('user') + verbose_name_plural = _('users') + + def __str__(self): + return self.username + + def get_short_name(self): + return self.email + diff --git a/auths/serializers.py b/auths/serializers.py new file mode 100644 index 0000000..a24652c --- /dev/null +++ b/auths/serializers.py @@ -0,0 +1,40 @@ +# auths/serializers.py +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +User = get_user_model() + +# 회원가입 시리얼라이저 +class UserCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8, required=True) + + class Meta: + model = User + fields = ('id', 'email', 'username', 'password', 'country') + + def create(self, validated_data): + return User.objects.create_user(**validated_data) + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + # 추가 클레임 넣기 가능 (예: username) + token['username'] = user.username + return token + + def validate(self, attrs): + data = super().validate(attrs) + + # 로그인 응답에 이메일, username 추가 + data.update({ + 'id': self.user.id, + 'email': self.user.email, + 'username': self.user.username, + 'country': self.user.country, + }) + return data + +class LogoutSerializer(serializers.Serializer): + refresh = serializers.CharField(help_text="Refresh token to be blacklisted.") diff --git a/auths/tests.py b/auths/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/auths/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/auths/urls.py b/auths/urls.py new file mode 100644 index 0000000..11b1d78 --- /dev/null +++ b/auths/urls.py @@ -0,0 +1,13 @@ +# auths/urls.py +from django.urls import path, include +from .views import UserSignupView, CustomTokenObtainPairView, LogoutView +from rest_framework_simplejwt.views import TokenRefreshView + +app_name = "auths" +urlpatterns = [ + path('signup/', UserSignupView.as_view(), name='signup'), + path('signin/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), + + path('logout/', LogoutView.as_view(), name='logout'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), +] diff --git a/auths/views.py b/auths/views.py new file mode 100644 index 0000000..0164d1a --- /dev/null +++ b/auths/views.py @@ -0,0 +1,41 @@ +# auths/views.py +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status, permissions +from rest_framework.response import Response +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.exceptions import TokenError + +from .serializers import UserCreateSerializer, LogoutSerializer +from .serializers import CustomTokenObtainPairSerializer # 후술 +from rest_framework.views import APIView + + +# 회원가입 뷰 +class UserSignupView(generics.CreateAPIView): + serializer_class = UserCreateSerializer + permission_classes = [permissions.AllowAny] + + +# JWT 토큰 발급 뷰 (simplejwt 기본 TokenObtainPairView 상속 + 커스터마이징) +class CustomTokenObtainPairView(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer + + +# 로그아웃 뷰 (블랙리스트 처리) +class LogoutView(APIView): + permission_classes = [permissions.IsAuthenticated] + + @swagger_auto_schema(request_body=LogoutSerializer) + def post(self, request): + try: + refresh_token = request.data["refresh"] + token = RefreshToken(refresh_token) + token.blacklist() + return Response({"detail": "Logout successful."}, status=status.HTTP_205_RESET_CONTENT) + except KeyError: + return Response({"detail": "Refresh token required."}, status=status.HTTP_400_BAD_REQUEST) + except TokenError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + diff --git a/django-rest-framework b/django-rest-framework new file mode 160000 index 0000000..985dd73 --- /dev/null +++ b/django-rest-framework @@ -0,0 +1 @@ +Subproject commit 985dd732e058644f6e875de76205a392ce4241dd diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b548f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: '3.8' + +services: + + web: + image: junnie082/scoopadive:latest + container_name: scoopadive_web + command: > + sh -c " + python manage.py collectstatic --noinput && + python manage.py migrate && + gunicorn scoopadive.asgi:application -k uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 + " + env_file: + - .env + ports: + - "8000:8000" + volumes: + - static_volume:/scoopadive/staticfiles + depends_on: + - db + - redis + networks: + - scoopadive_network + + worker: + image: junnie082/scoopadive:latest + container_name: celery_worker + command: celery -A scoopadive worker --loglevel=info --concurrency=4 + env_file: .env + depends_on: + - web + - redis + networks: + - scoopadive_network + + redis: + image: redis:7 + container_name: redis + ports: + - "6379:6379" + networks: + - scoopadive_network + + + nginx: + image: nginx:latest + container_name: nginx_proxy + ports: + - "80:80" + - "443:443" + volumes: + - /etc/letsencrypt:/etc/letsencrypt:ro + - /home/ubuntu/Backend/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - /home/ubuntu/Frontend/dist:/usr/share/nginx/html:ro + - static_volume:/scoopadive/staticfiles + depends_on: + - web + networks: + - scoopadive_network + + db: + image: postgres:13 + container_name: scoopadive_db + environment: + POSTGRES_DB: "${DB_NAME}" + POSTGRES_USER: "${DB_USER}" + POSTGRES_PASSWORD: "${DB_PASSWORD}" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - scoopadive_network + +volumes: + postgres_data: + static_volume: + +networks: + scoopadive_network: diff --git a/home/__init__.py b/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/admin.py b/home/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/home/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/home/apps.py b/home/apps.py new file mode 100644 index 0000000..e7d1c7e --- /dev/null +++ b/home/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HomeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "home" diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py new file mode 100644 index 0000000..f798f68 --- /dev/null +++ b/home/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.6 on 2025-07-15 08:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Job", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("location", models.CharField(max_length=100)), + ("description", models.TextField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/home/migrations/0002_job_created_at_alter_job_description_and_more.py b/home/migrations/0002_job_created_at_alter_job_description_and_more.py new file mode 100644 index 0000000..675f738 --- /dev/null +++ b/home/migrations/0002_job_created_at_alter_job_description_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.6 on 2025-09-04 11:41 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("home", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="job", + name="created_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="job", + name="description", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="job", + name="location", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name="job", + name="title", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name="job", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/home/migrations/0003_alter_job_user.py b/home/migrations/0003_alter_job_user.py new file mode 100644 index 0000000..76c296f --- /dev/null +++ b/home/migrations/0003_alter_job_user.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.6 on 2025-09-17 03:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("home", "0002_job_created_at_alter_job_description_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="job", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="jobs", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/home/migrations/__init__.py b/home/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/models.py b/home/models.py new file mode 100644 index 0000000..7f5dddd --- /dev/null +++ b/home/models.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.utils import timezone +from django.db import models + +class Job(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='jobs', + on_delete=models.CASCADE, + null=True, + blank=True + ) + title = models.CharField(max_length=100, null=True, blank=True) + location = models.CharField(max_length=100, null=True, blank=True) + description = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(default=timezone.now) diff --git a/home/serializers.py b/home/serializers.py new file mode 100644 index 0000000..eab41d8 --- /dev/null +++ b/home/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers +from home.models import Job + + +class JobSerializer(serializers.ModelSerializer): + class Meta: + model = Job + fields = '__all__' + read_only_fields = ['user', 'created_at'] + + def create(self, validated_data): + validated_data['user'] = self.context['request'].user + return super().create(validated_data) diff --git a/home/tests.py b/home/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/home/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/home/urls.py b/home/urls.py new file mode 100644 index 0000000..a9c09d8 --- /dev/null +++ b/home/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import TopLevelMembersListAPIView, JobViewSet, TheMostVisitedSpotsAPIView + +router = DefaultRouter() +router.register('jobs', JobViewSet, basename='jobs') + +urlpatterns = [ + path('top_level_members', TopLevelMembersListAPIView.as_view(), name='top_level_members'), + path('the_most_visited_spots', TheMostVisitedSpotsAPIView.as_view(), name='the_most_visited_spots'), + path('', include(router.urls)), + +] + + diff --git a/home/views.py b/home/views.py new file mode 100644 index 0000000..e1bc24a --- /dev/null +++ b/home/views.py @@ -0,0 +1,71 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from auths.models import User +from home.models import Job +from home.serializers import JobSerializer +from logbook.models import Logbook + + +class TopLevelMembersListAPIView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + members = User.objects.all() + name_n_likes = {} + + for member in members: + count_all_likes = 0 + print(member.own_logbooks.all()) + for logbook in member.own_logbooks.all(): + count_all_likes += logbook.total_likes() + name_n_likes[member.username] = count_all_likes + + sorted_top_level_members = sorted(name_n_likes.items(), key=lambda x: x[1], reverse=True)[:3] + return Response(sorted_top_level_members) + + +class JobViewSet(viewsets.ModelViewSet): + permission_classes = [AllowAny] + queryset = Job.objects.all() + serializer_class = JobSerializer + + def get_permissions(self): + if self.action == 'create' or self.action == 'destroy': + permission_classes = [IsAuthenticated] + else: + permission_classes = [AllowAny] + return [permission() for permission in permission_classes] + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if request.user == instance.user: + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(status=status.HTTP_403_FORBIDDEN) + + +class TheMostVisitedSpotsAPIView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + logbooks = Logbook.objects.all() + visited_spots = {} + for logbook in logbooks: + if logbook.dive_site not in visited_spots: + visited_spots[logbook.dive_site] = 1 + else: + visited_spots[logbook.dive_site] += 1 + + sorted_dict = sorted(visited_spots.items(), reverse=True, key=lambda item: item[1]) + print(sorted_dict[:3]) + + return Response(sorted_dict[:3]) + + + + + + diff --git a/k6/cloudfront_test.js b/k6/cloudfront_test.js new file mode 100644 index 0000000..a8ad7a2 --- /dev/null +++ b/k6/cloudfront_test.js @@ -0,0 +1,12 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +export let options = { + vus: 100, + duration: '2m', +}; + +export default function () { + http.get('https://api.scoopadive.com'); // CloudFront에서 프론트 서빙 중인 주소 + sleep(0.5); +} diff --git a/k6/lightsail_test.js b/k6/lightsail_test.js new file mode 100644 index 0000000..095bb24 --- /dev/null +++ b/k6/lightsail_test.js @@ -0,0 +1,12 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +export let options = { + vus: 100, + duration: '2m', +}; + +export default function () { + http.get('https://scoopadive.com'); // Lightsail에서 프론트 서빙 중인 주>소 + sleep(0.5); +} diff --git a/k6/likes_async.js b/k6/likes_async.js new file mode 100644 index 0000000..4ac54eb --- /dev/null +++ b/k6/likes_async.js @@ -0,0 +1,37 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE = "https://scoopadive.com"; + +export let options = { + vus: 50, + duration: "30s", +}; + +export function setup() { + const loginPayload = JSON.stringify({ + email: "ruby@gmail.com", + password: "12345678" + }); + + const loginHeaders = { "Content-Type": "application/json" }; + const loginRes = http.post(`${BASE}/api/auths/signin/`, loginPayload, { headers: loginHeaders }); + + check(loginRes, { "login succeeded": (r) => r.status === 200 && r.json("access") !== undefined }); + + const token = loginRes.json("access"); + return { token }; +} + +export default function (data) { + const apiHeaders = { + "Authorization": `Bearer ${data.token}`, + "Content-Type": "application/json" + }; + + const res = http.get(`${BASE}/api/logbooks/likes_async/`, { headers: apiHeaders }); + + check(res, { "status was 200": (r) => r.status === 200 }); + + sleep(1); +} \ No newline at end of file diff --git a/k6/likes_sync.js b/k6/likes_sync.js new file mode 100644 index 0000000..101c08b --- /dev/null +++ b/k6/likes_sync.js @@ -0,0 +1,37 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE = "https://scoopadive.com"; + +export let options = { + vus: 50, + duration: "30s", +}; + +export function setup() { + const loginPayload = JSON.stringify({ + email: "ruby@gmail.com", + password: "12345678" + }); + + const loginHeaders = { "Content-Type": "application/json" }; + const loginRes = http.post(`${BASE}/api/auths/signin/`, loginPayload, { headers: loginHeaders }); + + check(loginRes, { "login succeeded": (r) => r.status === 200 && r.json("access") !== undefined }); + + const token = loginRes.json("access"); + return { token }; +} + +export default function (data) { + const apiHeaders = { + "Authorization": `Bearer ${data.token}`, + "Content-Type": "application/json" + }; + + const res = http.get(`${BASE}/api/logbooks/likes/`, { headers: apiHeaders }); + + check(res, { "status was 200": (r) => r.status === 200 }); + + sleep(1); +} \ No newline at end of file diff --git a/logbook/__init__.py b/logbook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/logbook/admin.py b/logbook/admin.py new file mode 100644 index 0000000..563bb9c --- /dev/null +++ b/logbook/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin +from .models import Logbook, Equipment, DiveCenter + +@admin.register(Logbook) +class LogbookAdmin(admin.ModelAdmin): + list_display = ( + 'dive_title', 'dive_site', 'dive_date', 'buddy', + 'max_depth', 'bottom_time', 'start_pressure', 'end_pressure', + 'likes' + ) + list_filter = ( + 'dive_date', 'weather', 'type_of_dive', 'dive_center', + ) + search_fields = ( + 'dive_title', 'dive_site', 'buddy', 'feeling', + ) + filter_horizontal = ('equipment',) # ManyToManyField 용 + readonly_fields = ('bottom_time',) # 선택적으로 읽기 전용 처리 + + fieldsets = ( + ('기본 정보', { + 'fields': ('dive_title', 'dive_site', 'dive_date', 'buddy', 'feeling', 'dive_image', 'likes') + }), + ('다이브 상세', { + 'fields': ('max_depth', 'bottom_time', 'start_pressure', 'end_pressure', 'weight') + }), + ('환경 및 장비', { + 'fields': ('weather', 'type_of_dive', 'equipment', 'dive_center') + }), + ) + + def formatted_bottom_time(self, obj): + if obj.bottom_time: + minutes = obj.bottom_time.total_seconds() // 60 + return f"{int(minutes)} min" + return "-" + + def likes(self, obj): + return ", ".join([user.username for user in obj.likes.all()]) + likes.short_description = 'Likes' + + + +@admin.register(Equipment) +class EquipmentAdmin(admin.ModelAdmin): + list_display = ('name',) + +@admin.register(DiveCenter) +class DiveCenterAdmin(admin.ModelAdmin): + list_display = ('name',) diff --git a/logbook/apps.py b/logbook/apps.py new file mode 100644 index 0000000..b3e96d6 --- /dev/null +++ b/logbook/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogbookConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "logbook" diff --git a/logbook/constants.py b/logbook/constants.py new file mode 100644 index 0000000..38b0c52 --- /dev/null +++ b/logbook/constants.py @@ -0,0 +1,15 @@ +# Optional: 선택 가능한 값 정의 +WEATHER_CHOICES = [ + ('sunny', 'Sunny'), + ('cloudy', 'Cloudy'), + ('rainy', 'Rainy'), + ('stormy', 'Stormy'), +] + +DIVE_TYPE_CHOICES = [ + ('fun', 'Fun Dive'), + ('training', 'Training'), + ('night', 'Night Dive'), + ('deep', 'Deep Dive'), + ('wreck', 'Wreck Dive'), +] diff --git a/logbook/migrations/0001_initial.py b/logbook/migrations/0001_initial.py new file mode 100644 index 0000000..9071630 --- /dev/null +++ b/logbook/migrations/0001_initial.py @@ -0,0 +1,171 @@ +# Generated by Django 5.0.6 on 2025-09-06 02:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DiveCenter", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=256)), + ], + ), + migrations.CreateModel( + name="Equipment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name="Logbook", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "dive_image", + models.ImageField( + blank=True, null=True, upload_to="logbooks_images" + ), + ), + ("feeling", models.TextField(blank=True, null=True)), + ("buddy", models.TextField(blank=True, null=True)), + ("dive_title", models.CharField(max_length=256)), + ("dive_site", models.CharField(max_length=256)), + ("dive_date", models.DateField()), + ("max_depth", models.FloatField()), + ("bottom_time", models.DurationField(help_text="예: 00:35:00 (35분)")), + ( + "weather", + models.CharField( + blank=True, + choices=[ + ("sunny", "Sunny"), + ("cloudy", "Cloudy"), + ("rainy", "Rainy"), + ("stormy", "Stormy"), + ], + max_length=100, + null=True, + ), + ), + ( + "type_of_dive", + models.CharField( + blank=True, + choices=[ + ("fun", "Fun Dive"), + ("training", "Training"), + ("night", "Night Dive"), + ("deep", "Deep Dive"), + ("wreck", "Wreck Dive"), + ], + max_length=256, + null=True, + ), + ), + ("weight", models.PositiveSmallIntegerField(help_text="사용한 납 무게 (kg)")), + ("start_pressure", models.PositiveSmallIntegerField()), + ("end_pressure", models.PositiveSmallIntegerField()), + ( + "dive_center", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="logbook.divecenter", + ), + ), + ( + "equipment", + models.ManyToManyField( + blank=True, related_name="equipment", to="logbook.equipment" + ), + ), + ( + "likes", + models.ManyToManyField( + blank=True, + related_name="liked_logbooks", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="own_logbooks", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-dive_date"], + }, + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "logbook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="logbook.logbook", + ), + ), + ], + ), + ] diff --git a/logbook/migrations/0002_logbook_dive_image_url.py b/logbook/migrations/0002_logbook_dive_image_url.py new file mode 100644 index 0000000..3db0d98 --- /dev/null +++ b/logbook/migrations/0002_logbook_dive_image_url.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.6 on 2025-11-14 03:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("logbook", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="logbook", + name="dive_image_url", + field=models.URLField(blank=True, max_length=500, null=True), + ), + ] diff --git a/logbook/migrations/0003_alter_logbook_dive_image_url.py b/logbook/migrations/0003_alter_logbook_dive_image_url.py new file mode 100644 index 0000000..37ef6aa --- /dev/null +++ b/logbook/migrations/0003_alter_logbook_dive_image_url.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.6 on 2025-11-17 06:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("logbook", "0002_logbook_dive_image_url"), + ] + + operations = [ + migrations.AlterField( + model_name="logbook", + name="dive_image_url", + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/logbook/migrations/__init__.py b/logbook/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/logbook/models.py b/logbook/models.py new file mode 100644 index 0000000..be2595d --- /dev/null +++ b/logbook/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.core.exceptions import ValidationError +from datetime import timedelta + +from logbook.constants import WEATHER_CHOICES, DIVE_TYPE_CHOICES +from scoopadive import settings + +User = settings.AUTH_USER_MODEL + +class Equipment(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + +class DiveCenter(models.Model): + name = models.CharField(max_length=256) + + def __str__(self): + return self.name + +class Logbook(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='own_logbooks') + dive_image = models.ImageField(upload_to='logbooks_images', null=True, blank=True) + dive_image_url = models.CharField(max_length=1000, blank=True, null=True) + feeling = models.TextField(null=True, blank=True) + # buddy = models.ForeignKey(User, on_delete=models.CASCADE, related_name='buddy_logbooks') + buddy = models.TextField(null=True, blank=True) + dive_title = models.CharField(max_length=256) + dive_site = models.CharField(max_length=256) + dive_date = models.DateField() + max_depth = models.FloatField() + bottom_time = models.DurationField(help_text="예: 00:35:00 (35분)") + weather = models.CharField(max_length=100, choices=WEATHER_CHOICES, null=True, blank=True) + type_of_dive = models.CharField(max_length=256, choices=DIVE_TYPE_CHOICES, null=True, blank=True) + equipment = models.ManyToManyField(Equipment, related_name="equipment", blank=True) + weight = models.PositiveSmallIntegerField(help_text="사용한 납 무게 (kg)") + start_pressure = models.PositiveSmallIntegerField() + end_pressure = models.PositiveSmallIntegerField() + dive_center = models.ForeignKey(DiveCenter, on_delete=models.SET_NULL, null=True, blank=True) + likes = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="liked_logbooks", blank=True) + + def total_likes(self): + return self.likes.count() + + class Meta: + ordering = ['-dive_date'] + + def clean(self): + if self.end_pressure > self.start_pressure: + raise ValidationError("End pressure cannot be greater than start pressure.") + if self.bottom_time and self.bottom_time < timedelta(minutes=1): + raise ValidationError("Bottom time must be at least 1 minute.") + + def __str__(self): + return f"{self.dive_title} @ {self.dive_site} ({self.dive_date})" + +class Comment(models.Model): + logbook = models.ForeignKey(Logbook, on_delete=models.CASCADE, related_name='comments') + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.logbook} @ {self.user}" \ No newline at end of file diff --git a/logbook/serializers.py b/logbook/serializers.py new file mode 100644 index 0000000..eb8bb84 --- /dev/null +++ b/logbook/serializers.py @@ -0,0 +1,83 @@ +from logbook.models import Comment +from rest_framework import serializers +from .models import Logbook, Equipment + +class LogbookSerializer(serializers.ModelSerializer): + liked_by_current_user = serializers.SerializerMethodField() + likes_count = serializers.SerializerMethodField() + dive_image_url = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True + ) + + class Meta: + model = Logbook + fields = '__all__' + read_only_fields = ('user', 'likes') + + def create(self, validated_data): + request = self.context.get('request') + if request and request.user.is_authenticated: + validated_data['user'] = request.user + + # equipment 직접 꺼내기 + equipment_names = self.initial_data.get("equipment", []) + if not isinstance(equipment_names, list): + equipment_names = [] + + logbook = super().create(validated_data) + + for name in equipment_names: + eq, _ = Equipment.objects.get_or_create(name=name) + logbook.equipment.add(eq) + + return logbook + + def update(self, instance, validated_data): + equipment_names = self.initial_data.get("equipment", None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + if isinstance(equipment_names, list): + instance.equipment.clear() + for name in equipment_names: + eq, _ = Equipment.objects.get_or_create(name=name) + instance.equipment.add(eq) + + return instance + + def to_representation(self, instance): + rep = super().to_representation(instance) + # equipment를 문자열 배열로 변환 + rep['equipment'] = [eq.name for eq in instance.equipment.all()] + return rep + + def get_liked_by_current_user(self, obj): + request = self.context.get('request') + user = getattr(request, 'user', None) + if user and user.is_authenticated: + return obj.likes.filter(id=user.id).exists() + return False + + def get_likes_count(self, obj): + return obj.likes.count() + + + + +class LogbookLikeSerializer(serializers.ModelSerializer): + class Meta: + model = Logbook + fields = ('likes',) + read_only_fields = ('likes',) # 직접 수정 금지 + + +class CommentSerializer(serializers.ModelSerializer): + author_username = serializers.CharField(source='author.username', read_only=True) + + class Meta: + model = Comment + fields = ['id', 'text', 'author_username', 'created_at'] diff --git a/logbook/tasks.py b/logbook/tasks.py new file mode 100644 index 0000000..9e0cdce --- /dev/null +++ b/logbook/tasks.py @@ -0,0 +1,18 @@ +from celery import shared_task +from django.core.cache import cache +from .models import Logbook + +@shared_task +def add_like_task(logbook_id, user_id): + log = Logbook.objects.get(id=logbook_id) + log.likes.add(user_id) # DB에 좋아요 추가 + cache.delete("logbook_likes") # 캐시 무효화 + + +@shared_task +def remove_like_task(logbook_id, user_id): + log = Logbook.objects.get(id=logbook_id) + log.likes.remove(user_id) # DB에서 좋아요 제거 + cache.delete("logbook_likes") # 캐시 무효화 + + diff --git a/logbook/tests.py b/logbook/tests.py new file mode 100644 index 0000000..e50274f --- /dev/null +++ b/logbook/tests.py @@ -0,0 +1,5 @@ +from django.test import TestCase + +# Create your tests here. + +# If this comment is shown test was successful. \ No newline at end of file diff --git a/logbook/urls.py b/logbook/urls.py new file mode 100644 index 0000000..70a9f99 --- /dev/null +++ b/logbook/urls.py @@ -0,0 +1,18 @@ +from django.urls import path, include + +from . import views +from rest_framework import routers + +from .views import FriendLogbookAPIView, LikesAsyncView + +app_name = 'logbook' +router = routers.DefaultRouter() +router.register(r'', views.LogbookViewSet, basename='logbook') +urlpatterns = [ + path('', include(router.urls)), + path('likes_async/', LikesAsyncView.as_view(), name='likes_async'), + path('/friend_logbooks/', FriendLogbookAPIView.as_view(), name='friend_logbook'), + path('/comments', views.CommentAPIView.as_view(), name='comments'), + path('/uncomment/', views.UncommentAPIView.as_view(), name='comment-delete'), +] + diff --git a/logbook/views.py b/logbook/views.py new file mode 100644 index 0000000..db00e67 --- /dev/null +++ b/logbook/views.py @@ -0,0 +1,176 @@ +from asgiref.sync import sync_to_async +from rest_framework import permissions, viewsets, status +from rest_framework.decorators import action +from rest_framework import generics +from rest_framework.parsers import MultiPartParser + +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated + +from rest_framework.response import Response +from rest_framework.views import APIView + +from auths.models import User +from logbook.models import Logbook, Comment +from .serializers import LogbookSerializer, LogbookLikeSerializer, CommentSerializer + +# Redis cache +from django.core.cache import cache +# Async 처리 +from asgiref.sync import sync_to_async +# Celery Task +from .tasks import add_like_task, remove_like_task + +class LogbookViewSet(viewsets.ModelViewSet): + queryset = Logbook.objects.all().order_by('-dive_date') + parser_classes = [MultiPartParser] + + def get_serializer_class(self): + if self.action == 'like': + return LogbookLikeSerializer + return LogbookSerializer + + def get_permissions(self): + if self.action in ['list', 'retrieve', 'get_likes', 'get_like']: + return [permissions.AllowAny()] + return [permissions.IsAuthenticated()] + + def destroy(self, request, pk=None): + logbook = get_object_or_404(Logbook, pk=pk) + if logbook.user != request.user: + return Response(status=status.HTTP_403_FORBIDDEN) + logbook.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=['get']) + def my_logbooks(self, request): + logbooks = Logbook.objects.filter(user=self.request.user) + serializer = LogbookSerializer(logbooks, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def likes(self, request): + cache_key = "logbook_likes" # 캐시 키 정의 + data = cache.get(cache_key) # 캐시에서 먼저 조회 + if not data: + data = [ + { + 'id': logbook.id, + 'likes': list(logbook.likes.values_list('username', flat=True)) + } + for logbook in Logbook.objects.all().prefetch_related("likes") + if logbook.likes.exists() + ] + cache.set(cache_key, data, timeout=60) # Redis 캐시에 저장 (1분) + return Response(data) + + @action(detail=True, methods=['get', 'post', 'delete']) + def like(self, request, pk=None): + log = self.get_object() + user = request.user + + if request.method == 'GET': + likes = list(log.likes.values_list('username', flat=True)) + return Response({ + 'likes': likes, + 'likes_count': log.likes.count() + }) + + elif request.method == 'POST': + # 좋아요 작업을 Celery Task로 비동기 처리 + add_like_task.delay(log.id, user.id) + return Response({ + 'liked': True, + 'likes_count': log.likes.count() + }, status=status.HTTP_200_OK) + + elif request.method == 'DELETE': + # 좋아요 삭제를 Celery Task로 비동기 처리 + remove_like_task.delay(log.id, user.id) + return Response({ + 'liked': False, + 'likes_count': log.likes.count() + }, status=status.HTTP_200_OK) + + +class LikesAsyncView(APIView): + permission_classes = [IsAuthenticated] + + async def get(self, request): + cache_key = "logbook_likes" # 캐시 키 정의 + data = await sync_to_async(cache.get)(cache_key) # async-safe 캐시 조회 + + if not data: + # DB 조회를 async-safe로 감싸기 + async_get_likes = sync_to_async( + lambda: [ + { + 'id': logbook.id, + 'likes': list(logbook.likes.values_list("username", flat=True)) + } + for logbook in Logbook.objects.all().prefetch_related("likes") + if logbook.likes.exists() + ], + thread_sensitive=True + ) + data = await async_get_likes() + await sync_to_async(cache.set)(cache_key, data, 60) # <- async-safe 캐시 저장 + + return Response(data) + +class FriendLogbookAPIView(APIView): + serializer_class = LogbookSerializer + permission_classes = [permissions.AllowAny] + + def get(self, request, user_id=None): + user = User.objects.get(id=user_id) + logbooks = Logbook.objects.filter(user=user) + serializer = LogbookSerializer(logbooks, many=True) + return Response(serializer.data) + +class CommentAPIView(generics.CreateAPIView): + queryset = Comment.objects.all().order_by('created_at') + serializer_class = CommentSerializer + + def get_permissions(self): + if self.request.method == 'GET': + return [permissions.AllowAny()] + else: + return [permissions.IsAuthenticated()] + + + def get(self, request, logbook_id=None): + logbook = get_object_or_404(Logbook, pk=logbook_id) + comments = logbook.comments.all() + serializer = CommentSerializer(comments, many=True) + return Response(serializer.data) + + def post(self, request, logbook_id=None): + logbook = get_object_or_404(Logbook, pk=logbook_id) + serializer = CommentSerializer(data=request.data) + + if serializer.is_valid(): + serializer.save(logbook=logbook, author=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def put(self, request, logbook_id=None): + logbook = get_object_or_404(Logbook, pk=logbook_id) + comment_text = request.data.get('text') + if comment_text: + comment = Comment.objects.create( + logbook=logbook, + text=comment_text, + author=request.user + ) + +class UncommentAPIView(APIView): + queryset = Comment.objects.all().order_by('created_at') + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request, logbook_id=None, comment_id=None): + logbook = get_object_or_404(Logbook, pk=logbook_id) + comment = logbook.comments.get(pk=comment_id) + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..4436de5 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "scoopadive.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/mypage/__init__.py b/mypage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mypage/admin.py b/mypage/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/mypage/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/mypage/apps.py b/mypage/apps.py new file mode 100644 index 0000000..8a5f240 --- /dev/null +++ b/mypage/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MypageConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mypage" diff --git a/mypage/migrations/0001_initial.py b/mypage/migrations/0001_initial.py new file mode 100644 index 0000000..5fb2623 --- /dev/null +++ b/mypage/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.0.6 on 2025-11-02 06:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="BucketList", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bucketlists", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="SkillSet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("skill", models.CharField(max_length=200)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_sets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Friend", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "friend", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="friend_of", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="friends", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["user", "friend"], name="mypage_frie_user_id_86c752_idx" + ) + ], + "unique_together": {("user", "friend")}, + }, + ), + ] diff --git a/mypage/migrations/__init__.py b/mypage/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mypage/models.py b/mypage/models.py new file mode 100644 index 0000000..f66bd39 --- /dev/null +++ b/mypage/models.py @@ -0,0 +1,39 @@ +from django.db import models + +from scoopadive import settings + +User = settings.AUTH_USER_MODEL + + +class BucketList(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='bucketlists') + title = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title + +class Friend(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friends') + friend = models.ForeignKey(User, related_name='friend_of', on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'friend') # 중복 친구 관계 방지 + indexes = [ + models.Index(fields=['user', 'friend']), + ] + + def __str__(self): + return f"{self.user} is friends with {self.friend}" + + +class SkillSet(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='skill_sets') + skill = models.CharField(max_length=200) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.user.username + + diff --git a/mypage/serializers.py b/mypage/serializers.py new file mode 100644 index 0000000..ed80b71 --- /dev/null +++ b/mypage/serializers.py @@ -0,0 +1,69 @@ +from django.contrib.auth import get_user_model +from django.core.validators import validate_email +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework import serializers + +from logbook.serializers import LogbookSerializer +from mypage.models import BucketList, Friend, SkillSet + +User = get_user_model() + +class BucketListSerializer(serializers.ModelSerializer): + class Meta: + model = BucketList + fields = '__all__' + +class FriendSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email'] + + +class UserDetailSerializer(serializers.ModelSerializer): + profile_image = serializers.ImageField(required=False) + bucketlists = BucketListSerializer(many=True, read_only=True) + own_logbooks = LogbookSerializer(many=True, read_only=True) + friends = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + 'id', 'profile_image', 'email', 'username', 'country', 'introduction', + 'date_joined', 'bucketlists', 'own_logbooks', 'friends', 'license' + ] + read_only_fields = ['date_joined', 'bucketlists', 'own_logbooks'] + + def get_friends(self, obj): + friend_ids = Friend.objects.filter(user=obj).values_list('friend_id', flat=True) + return list(friend_ids) + + +class UserUpdateSerializer(serializers.ModelSerializer): + profile_image = serializers.ImageField(required=False) + + class Meta: + model = User + fields = ['profile_image', 'username', 'email', 'country', 'license', 'introduction'] + + def validate_email(self, value): + try: + validate_email(value) + except DjangoValidationError: + raise serializers.ValidationError("유효한 이메일 주소를 입력하세요.") + return value + + def validate_username(self, value): + if len(value) < 3: + raise serializers.ValidationError("사용자 이름은 최소 3자 이상이어야 합니다.") + return value + + def validate_country(self, value): + if not value.isalpha(): + raise serializers.ValidationError("국가명은 알파벳만 포함해야 합니다.") + return value + +class SkillSetSerializer(serializers.ModelSerializer): + class Meta: + model = SkillSet + fields = '__all__' + read_only_fields = ['user'] \ No newline at end of file diff --git a/mypage/tests.py b/mypage/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/mypage/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/mypage/urls.py b/mypage/urls.py new file mode 100644 index 0000000..2b7f09e --- /dev/null +++ b/mypage/urls.py @@ -0,0 +1,22 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import MyPageView, BucketListViewSet, FriendsListAPIView, FriendsDetailView, ListUsersView, \ + EditProfileView, MySkillsViewSet + +router = DefaultRouter() +router.register(r'bucketlists', BucketListViewSet, basename='bucketlists') + +router.register('myskills', MySkillsViewSet, basename='myskills') + +urlpatterns = [ + path('', include(router.urls)), + + path('', MyPageView.as_view(), name='mypage'), + path('all/', ListUsersView.as_view(), name='mypage-all'), + + path('friends/list/', FriendsListAPIView.as_view(), name='friends'), + + path('friends/detail//', FriendsDetailView.as_view(), name='friends-detail'), + path('edit_profile//', EditProfileView.as_view(), name='mypage-edit'), +] + diff --git a/mypage/views.py b/mypage/views.py new file mode 100644 index 0000000..984913c --- /dev/null +++ b/mypage/views.py @@ -0,0 +1,139 @@ +from django.contrib.auth import get_user_model +from drf_yasg.utils import swagger_auto_schema +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser +from .models import BucketList, Friend, SkillSet +from .serializers import UserDetailSerializer, BucketListSerializer, UserUpdateSerializer, \ + SkillSetSerializer +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.views import APIView +from rest_framework.response import Response + +from django.shortcuts import get_object_or_404 + +User = get_user_model() + +class MyPageView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + serializer = UserDetailSerializer(user) + return Response(serializer.data) + +class ListUsersView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + users = User.objects.all() + serializer = UserDetailSerializer(users, many=True) + return Response(serializer.data) + + + +class BucketListViewSet(viewsets.ModelViewSet): + queryset = BucketList.objects.all().order_by('created_at') + serializer_class = BucketListSerializer + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser] # 이미지 업로드를 위해 필요 + + def get_queryset(self): + return BucketList.objects.filter(user=self.request.user).order_by('created_at') + def perform_create(self, serializer): + serializer.save(user=self.request.user) + def perform_update(self, serializer): + serializer.save(user=self.request.user) + +class FriendsListAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + friends = User.objects.filter(friend_of__user=user).distinct() + serializer = UserDetailSerializer(friends, many=True) + return Response(serializer.data) + + +class FriendsDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, id): + user = request.user + friend = user.friends.get(id=id) + return Response(friend) + + def post(self, request, id): + user = request.user + friend = get_object_or_404(User, id=id) + + print(f"Adding user with id: {user.id} to friend: {friend}") + print(f"Adding friend with id: {friend.id}") + + if user == friend: + return Response({'error': '자기 자신을 친구로 추가할 수 없습니다.'}, status=400) + + # 이미 친구인지 확인 + if Friend.objects.filter(user=user, friend=friend).exists(): + return Response({'error': '이미 친구입니다.'}, status=400) + + # 친구 관계 추가 + Friend.objects.create(user=user, friend=friend) + Friend.objects.create(user=friend, friend=user) # 양방향 관계 + + return Response({'message': '친구 추가 성공'}, status=201) + + + def delete(self, request, id): + user = request.user + friend = get_object_or_404(User, id=id) + + if user == friend: + return Response({'error': '자기 자신은 삭제할 수 없습니다.'}, status=400) + + # 친구 관계 삭제 (양방향) + Friend.objects.filter(user=user, friend=friend).delete() + Friend.objects.filter(user=friend, friend=user).delete() + + return Response({'message': '친구 삭제 완료'}, status=status.HTTP_204_NO_CONTENT) + +class EditProfileView(APIView): + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + @swagger_auto_schema( + request_body=UserUpdateSerializer, + consumes=['multipart/form-data'] + ) + def put(self, request, id): + user = get_object_or_404(User, pk=id) + + if request.user != user: + return Response({'detail': '수정 권한이 없습니다.'}, status=status.HTTP_403_FORBIDDEN) + + serializer = UserUpdateSerializer(user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(UserDetailSerializer(user).data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class MySkillsViewSet(viewsets.ModelViewSet): + serializer_class = SkillSetSerializer + queryset = SkillSet.objects.all() + + def get_permissions(self): + if self.action == 'get_friend_skills': + return [AllowAny()] + else: + return [IsAuthenticated()] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @action(detail=False, methods=['get'], url_path='friends/(?P[^/.]+)') + def get_friend_skills(self, request, user_id=None): + user = User.objects.get(pk=user_id) + skills = SkillSet.objects.filter(user=user) + serializer = SkillSetSerializer(skills, many=True) + return Response(serializer.data) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..347a99c --- /dev/null +++ b/nginx.conf @@ -0,0 +1,75 @@ +upstream django { + server web:8000; # docker-compose 서비스명:scoopadive_web 포트 +} + +server { + listen 443 ssl; + + server_name api.scoopadive.com; + + ssl_certificate /etc/letsencrypt/live/api.scoopadive.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.scoopadive.com/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /usr/share/nginx/html; + index index.html index.htm; + + location /swagger/ { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger.json { + proxy_pass http://django/swagger.json; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /swagger.yaml { + proxy_pass http://django/swagger.yaml; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + + location / { + try_files $uri /index.html; + } + + location /api/ { + proxy_pass http://django; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + proxy_set_header Origin $http_origin; + proxy_set_header Authorization $http_authorization; + } + + location /media/ { + alias /scoopadive/media/; + } + + location /static/ { + alias /scoopadive/staticfiles/; + } + +} + +server { + listen 80; + server_name scoopadive.com www.scoopadive.com; + + # http 요청은 https로 리다이렉트 + return 301 https://$host$request_uri; +} diff --git a/photo/__init__.py b/photo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photo/admin.py b/photo/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/photo/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/photo/apps.py b/photo/apps.py new file mode 100644 index 0000000..da3f184 --- /dev/null +++ b/photo/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PhotoConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'photo' diff --git a/photo/benchmark.py b/photo/benchmark.py new file mode 100644 index 0000000..7a597b8 --- /dev/null +++ b/photo/benchmark.py @@ -0,0 +1,197 @@ +import time +import uuid +import asyncio +from multiprocessing import Pool, Manager + + +# ========================================== +# 1. Mock 환경 설정 (Presigned URL & Fishial Flow) +# ========================================== + +class MockFishialClient: + def __init__(self, shared_dict=None, lock=None, network_delay=0): + self.shared_dict = shared_dict + self.lock = lock + self.network_delay = network_delay + + async def get_async_token(self): + """[ASYNC] Step 1: 인증 토큰 발급 (공유 자원)""" + # (비동기 환경에서는 멀티프로세싱 Lock/Manager를 직접 사용하지 않습니다. + # 따라서 동기 실험과 달리 매번 토큰을 발급한다고 가정합니다.) + await asyncio.sleep(0.1) + return f"token_{uuid.uuid4().hex[:8]}" + + def get_sync_token(self): + """[SYNC] Step 1: 인증 토큰 발급 (공유 자원)""" + if self.lock and self.shared_dict is not None: + if 'access_token' in self.shared_dict: + return self.shared_dict['access_token'] + with self.lock: + if 'access_token' in self.shared_dict: + return self.shared_dict['access_token'] + time.sleep(0.1) + token = f"token_{uuid.uuid4().hex[:8]}" + self.shared_dict['access_token'] = token + return token + else: + time.sleep(0.1) + return f"token_{uuid.uuid4().hex[:8]}" + + async def process_async_image_flow(self, token, image_index): + """[ASYNC] Step 2~5: Presigned URL ~ 인공지능 분류 (핵심 병렬화 구간)""" + if self.network_delay > 0: + await asyncio.sleep(self.network_delay) + + # Step 2: Presigned URL 생성 요청 + await asyncio.sleep(0.05) + # Step 3: 클라이언트 -> S3 업로드 + await asyncio.sleep(0.2) + # Step 4: Fishial API 인식 요청 + await asyncio.sleep(0.5) + + return f"Image_{image_index}: Tuna" + + def process_sync_image_flow(self, token, image_index): + """[SYNC] Step 2~5: Presigned URL ~ 인공지능 분류""" + if self.network_delay > 0: + time.sleep(self.network_delay) + + # Step 2: Presigned URL 생성 요청 + time.sleep(0.05) + # Step 3: 클라이언트 -> S3 업로드 + time.sleep(0.2) + # Step 4: Fishial API 인식 요청 + time.sleep(0.5) + + return f"Image_{image_index}: Tuna" + + +# ========================================== +# 2. 워커 함수 +# ========================================== + +def worker_task_sync(image_idx, shared_dict, lock, network_delay=0): + client = MockFishialClient(shared_dict, lock, network_delay) + token = client.get_sync_token() + client.process_sync_image_flow(token, image_idx) + return True + + +async def worker_task_async(image_idx, network_delay=0): + client = MockFishialClient(network_delay=network_delay) + token = await client.get_async_token() + await client.process_async_image_flow(token, image_idx) + return True + + +# ========================================== +# 3. 실험 메인 컨트롤러 +# ========================================== + +def draw_bar_chart(results): + print("\n" + "=" * 60) + print("📊 [성능 비교 결과 그래프]") + print("=" * 60) + + max_time = max(results.values()) + + for name, t in results.items(): + bar_length = int((t / max_time) * 40) + bar = "█" * bar_length + print(f"{name.ljust(25)} | {bar} {t:.2f}s") + print("=" * 60 + "\n") + + +def run_experiment(): + # --- 실험 조건 --- + NUM_IMAGES = 40 + LOCAL_CORES = 4 + DISTRIBUTED_NODES = 16 + + # 개별 이미지 처리 시간 (I/O 총합): 0.1 (Token) + 0.05 (URL) + 0.2 (S3) + 0.5 (AI) = 0.85s + # 순차 처리 예상 시간: 40 * 0.85s = 34s (코드의 T1과 동일) + + print(f"\n🧪 [실험 시작] 이미지 {NUM_IMAGES}장 처리\n") + + results = {} + + # ------------------------------------------------- + # 1. 순차 처리 (Sequential) + # ------------------------------------------------- + print(f"1️⃣ [순차 처리 - Sync] 실행 중...", end=" ", flush=True) + start = time.time() + client = MockFishialClient() + for i in range(NUM_IMAGES): + token = client.get_sync_token() + client.process_sync_image_flow(token, i) + + t1 = time.time() - start + results['1. Sequential (Sync)'] = t1 + print(f"완료! ({t1:.2f}s)") + + # ------------------------------------------------- + # 2. 멀티프로세싱 (Local Parallel) + # ------------------------------------------------- + print(f"2️⃣ [멀티프로세싱 (Lock)] {LOCAL_CORES}코어 실행 중...", end=" ", flush=True) + m = Manager() + shared_dict = m.dict() + lock = m.Lock() + + start = time.time() + with Pool(processes=LOCAL_CORES) as pool: + pool.starmap(worker_task_sync, [(i, shared_dict, lock, 0) for i in range(NUM_IMAGES)]) + + t2 = time.time() - start + results['2. Multi-Processing'] = t2 + print(f"완료! ({t2:.2f}s)") + + # ------------------------------------------------- + # 3. 분산 서버 시뮬레이션 (MPI/Cluster) + # ------------------------------------------------- + print(f"3️⃣ [분산 클러스터 (MPI)] {DISTRIBUTED_NODES}노드 실행 중...", end=" ", flush=True) + + NETWORK_OVERHEAD = 0.05 + dist_m = Manager() + dist_shared = dist_m.dict() + dist_lock = dist_m.Lock() + + start = time.time() + with Pool(processes=DISTRIBUTED_NODES) as pool: + pool.starmap(worker_task_sync, + [(i, dist_shared, dist_lock, NETWORK_OVERHEAD) for i in range(NUM_IMAGES)]) + + t3 = time.time() - start + results['3. Distributed Sys'] = t3 + print(f"완료! ({t3:.2f}s)") + + # ------------------------------------------------- + # 4. Async 처리 (단일 스레드 논블로킹) + # ------------------------------------------------- + print(f"4️⃣ [비동기 처리 (Async)] 단일 스레드 실행 중...", end=" ", flush=True) + + start = time.time() + + async def async_main(): + tasks = [worker_task_async(i) for i in range(NUM_IMAGES)] + await asyncio.gather(*tasks) + + # 파이썬 3.7+ 환경에서 asyncio.run()을 사용하여 실행 + asyncio.run(async_main()) + + t4 = time.time() - start + results['4. Async (Single Thread)'] = t4 + print(f"완료! ({t4:.2f}s)") + + # ------------------------------------------------- + # 결과 출력 + # ------------------------------------------------- + draw_bar_chart(results) + + print("💡 분석 가이드:") + print(f" - 순차 처리는 1개씩 하므로 가장 느립니다. (약 {t1:.1f}초)") + print(f" - Async 처리는 단일 스레드지만, 느린 I/O 대기 시간({0.85:.2f}s)을 활용하여 {t4:.2f}s를 달성했습니다.") + print(f" - 멀티프로세싱/분산 시스템은 CPU 자원 자체를 늘려 가장 빠른 성능을 달성했습니다.") + + +if __name__ == '__main__': + run_experiment() \ No newline at end of file diff --git a/photo/migrations/0001_initial.py b/photo/migrations/0001_initial.py new file mode 100644 index 0000000..6ec2a91 --- /dev/null +++ b/photo/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.6 on 2025-11-13 12:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Photo", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("image_url", models.URLField(max_length=500)), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/photo/migrations/0002_photo_classified_as_alter_photo_image_url.py b/photo/migrations/0002_photo_classified_as_alter_photo_image_url.py new file mode 100644 index 0000000..0d339a2 --- /dev/null +++ b/photo/migrations/0002_photo_classified_as_alter_photo_image_url.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.6 on 2025-11-17 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("photo", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="photo", + name="classified_as", + field=models.CharField(default="Unknown", max_length=255), + ), + migrations.AlterField( + model_name="photo", + name="image_url", + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/photo/migrations/__init__.py b/photo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photo/models.py b/photo/models.py new file mode 100644 index 0000000..76b58b5 --- /dev/null +++ b/photo/models.py @@ -0,0 +1,12 @@ +from django.db import models + +from django.db import models + +class Photo(models.Model): + title = models.CharField(max_length=100) + image_url = models.CharField(max_length=1000, blank=True, null=True) + uploaded_at = models.DateTimeField(auto_now_add=True) + classified_as = models.CharField(max_length=255, null=False, default="Unknown") + + def __str__(self): + return self.title diff --git a/photo/serializers.py b/photo/serializers.py new file mode 100644 index 0000000..c6985de --- /dev/null +++ b/photo/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers +from photo.models import Photo + + +class PhotoSerializer(serializers.ModelSerializer): + # GET 요청에서만 classified_as를 보여주도록 수정 + classified_as = serializers.SerializerMethodField() + + image_url = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True + ) + + class Meta: + model = Photo + # 필요한 필드만 명시적으로 포함 + fields = ['id', 'title', 'image_url', 'uploaded_at', 'classified_as'] + + def get_classified_as(self, obj): + request = self.context.get('request') + # GET 요청일 때만 반환, 그 외는 None + if request and request.method == 'GET': + return obj.classified_as + return None diff --git a/photo/tests.py b/photo/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/photo/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/photo/urls.py b/photo/urls.py new file mode 100644 index 0000000..b01a673 --- /dev/null +++ b/photo/urls.py @@ -0,0 +1,12 @@ +from botocore.signers import generate_presigned_url +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from photo.views import PhotoViewSet + +router = DefaultRouter() +router.register(r'', PhotoViewSet, basename='photo') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/photo/views.py b/photo/views.py new file mode 100644 index 0000000..0a72d45 --- /dev/null +++ b/photo/views.py @@ -0,0 +1,176 @@ +import hashlib +import base64 +import mimetypes +import uuid + +import boto3 +import requests + +from django.conf import settings +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from photo.models import Photo +from photo.serializers import PhotoSerializer + + +class FishialClient: + AUTH_URL = "https://api-users.fishial.ai/v1/auth/token" + UPLOAD_URL = "https://api.fishial.ai/v1/recognition/upload" + RECOG_URL = "https://api.fishial.ai/v1/recognition/image" + + def __init__(self): + self.client_id = settings.FISHIAL_CLIENT_ID + self.client_secret = settings.FISHIAL_CLIENT_SECRET + + def get_token(self) -> str: + r = requests.post(self.AUTH_URL, json={ + "client_id": self.client_id, + "client_secret": self.client_secret + }) + r.raise_for_status() + return r.json()["access_token"] + + def request_signed_upload(self, token, filename, content_type, byte_size, checksum): + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + payload = { + "blob": { + "filename": filename, + "content_type": content_type, + "byte_size": byte_size, + "checksum": checksum + } + } + r = requests.post(self.UPLOAD_URL, headers=headers, json=payload) + r.raise_for_status() + return r.json() + + def recognize(self, token, signed_id): + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + params = {"q": signed_id} + r = requests.get(self.RECOG_URL, headers=headers, params=params) + r.raise_for_status() + return r.json() + + +ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif"] + + +class PhotoViewSet(viewsets.ModelViewSet): + queryset = Photo.objects.all().order_by('-uploaded_at') + serializer_class = PhotoSerializer + + @action(detail=False, methods=['get'], url_path='generate_presigned_url') + def generate_presigned_url(self, request): + file_name = request.GET.get('filename') + file_type = request.GET.get('filetype') + + if file_type not in ALLOWED_TYPES: + return Response({"error": "Invalid file type"}, status=400) + + # 중복 방지 UUID + key = f"uploads/{uuid.uuid4().hex}_{file_name}" + + s3 = boto3.client( + 's3', + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME + ) + + presigned_url = s3.generate_presigned_url( + 'put_object', + Params={ + 'Bucket': settings.AWS_STORAGE_BUCKET_NAME, + 'Key': key, + 'ContentType': file_type, + }, + ExpiresIn=3600 # 1시간 + ) + + file_url = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_S3_REGION_NAME}.amazonaws.com/{key}" + + return Response({'uploadUrl': presigned_url, 'fileUrl': file_url}) + + @action(detail=True, methods=['post'], url_path='classify') + def classify(self, request, pk=None): + try: + photo = Photo.objects.get(pk=pk) + except Photo.DoesNotExist: + return Response({"error": "Photo not found"}, status=404) + + if not photo.image_url: + return Response({"error": "image_url is empty"}, status=400) + + client = FishialClient() + + try: + # 1️⃣ Access Token 발급 + token = client.get_token() + + # 2️⃣ 이미지 메타데이터 계산 + filename = photo.image_url.split("/")[-1] + content_type, _ = mimetypes.guess_type(filename) + if content_type not in ["image/jpeg", "image/png", "image/gif"]: + return Response({"error": f"Unsupported content type: {content_type}"}, status=400) + + r = requests.get(photo.image_url) + r.raise_for_status() + file_bytes = r.content + byte_size = len(file_bytes) + checksum = base64.b64encode(hashlib.md5(file_bytes).digest()).decode() + + # 3️⃣ Signed Upload 요청 + upload_resp = client.request_signed_upload(token, filename, content_type, byte_size, checksum) + signed_id = upload_resp["signed-id"] + direct_upload = upload_resp["direct-upload"] + upload_url = direct_upload["url"] + headers = direct_upload.get("headers", {}) + + # 4️⃣ Direct Upload to S3 + put_headers = {} + if "Content-Disposition" in headers: + put_headers["Content-Disposition"] = headers["Content-Disposition"] + if "Content-MD5" in headers: + put_headers["Content-MD5"] = headers["Content-MD5"] + + put_resp = requests.put(upload_url, headers=put_headers, data=file_bytes) + put_resp.raise_for_status() + + # 5️⃣ Fishial Recognition (안전하게 처리) + result = client.recognize(token, signed_id) + + results = result.get("results", []) + if results and results[0].get("species"): + species_list = results[0]["species"] + best_match = max(species_list, key=lambda x: x.get("accuracy", 0)) + photo.classified_as = best_match.get("name", "Unknown") + else: + photo.classified_as = "Unknown" + + photo.save() + + return Response({ + "photo_id": photo.id, + "image_url": photo.image_url, + "classified_as": photo.classified_as, + "fishial_result": result + }) + + except requests.HTTPError as e: + resp = getattr(e, "response", None) + try: + detail = resp.json() + except Exception: + detail = str(resp.text) if resp else str(e) + return Response({"detail": "Fishial API error", "error": detail}, status=status.HTTP_502_BAD_GATEWAY) + except Exception as e: + return Response({"detail": "Internal server error", "error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/recommend/__init__.py b/recommend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommend/admin.py b/recommend/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/recommend/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/recommend/ai_system.py b/recommend/ai_system.py new file mode 100644 index 0000000..a52936d --- /dev/null +++ b/recommend/ai_system.py @@ -0,0 +1,96 @@ +import aiohttp +from openai import OpenAI +from pydantic import BaseModel +from typing import List + +from django.conf import settings + +from settings.models import Preferences + +OPENAI_API_KEY = settings.OPENAI_API_KEY + +print('openaiapikey: ', OPENAI_API_KEY) +client = OpenAI(api_key=OPENAI_API_KEY) + +class SpotExtraction(BaseModel): + region: str + country: str + intro: str + +class SpotsExtractionList(BaseModel): + spots: List[SpotExtraction] + +# 동기 처리 +class CustomPrompt(): + def __init__(self, user): + self.user = user + self.preferences = None + if user is not None: + self.preferences = Preferences.objects.filter(user=user).first() + + # 유저 정보가 주어지지 않았을 때 + if self.user is None or self.preferences is None: + self.prompt = "I am a beginner. Not experienced in Scuba Diving, so I need to start from the scratch." + # 유저 정보가 주어지지 않았을 때 + else: + self.prompt = f"""My personal info: + Birthday: {self.preferences.birthday}. + Residence: {self.preferences.residence}. + Minimum Budget: {self.preferences.budget_min}. + Maximum Budget: {self.preferences.budget_max}. + Gender: {self.preferences.gender}. + Preferred Activities: {self.preferences.preferred_activities}. + Preferred Atmosphere: {self.preferences.preferred_atmosphere}. + Last Dive Date: {self.preferences.last_dive_date}. + Preferred Diving: {self.preferences.preferred_diving}. + License: {self.user.license}. + Introduction: {self.user.introduction}. """ + + def openai_request(self): + response = client.responses.parse( + model="gpt-4o-mini-2024-07-18", + input=[ + { + "role": "system", + "content": "You are a diving spot recommend assistant. Return exactly three diving spots in JSON format with fields: region, country, intro. Do not add extra text or explanation.", + }, + {"role": "user", "content": self.prompt}, + ], + text_format=SpotsExtractionList, + ) + return response.output_parsed.spots + + +# 비동기 처리 +class AsyncCustomPrompt(CustomPrompt): + async def async_openai_request(self): + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.openai.com/v1/responses", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": "gpt-4o-mini-2024-07-18", + "input": [ + {"role": "system", "content": "*."}, + {"role": "user", "content": self.prompt}, + ], + }, + ) as resp: + data = await resp.json() + + # 결과 파싱 + # OpenAI API의 response 형식에 따라 조정 필요 + try: + output = data["output"][0]["content"][0]["text"] + parsed = SpotsExtractionList.model_validate_json(output) + return parsed.spots + except Exception as e: + print("Parsing error:", e) + return [] + + + + diff --git a/recommend/apps.py b/recommend/apps.py new file mode 100644 index 0000000..3bcb1c6 --- /dev/null +++ b/recommend/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RecommendConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "recommend" diff --git a/recommend/migrations/0001_initial.py b/recommend/migrations/0001_initial.py new file mode 100644 index 0000000..8f8ef51 --- /dev/null +++ b/recommend/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.6 on 2025-10-29 08:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Spot", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("region", models.CharField(max_length=120)), + ("country", models.CharField(max_length=120)), + ("intro", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/recommend/migrations/__init__.py b/recommend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommend/models.py b/recommend/models.py new file mode 100644 index 0000000..e205b61 --- /dev/null +++ b/recommend/models.py @@ -0,0 +1,16 @@ +from django.db import models +from auths.models import User + +class Spot(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + region = models.CharField(max_length=120) + country = models.CharField(max_length=120) + intro = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.region + + + + diff --git a/recommend/serializers.py b/recommend/serializers.py new file mode 100644 index 0000000..eb311da --- /dev/null +++ b/recommend/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from recommend.models import Spot + + +class SpotSerializer(serializers.ModelSerializer): + class Meta: + model = Spot + fields = '__all__' + + def create(self, validated_data): + validated_data['user'] = self.context['request'].user + return super().create(validated_data) diff --git a/recommend/tests.py b/recommend/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/recommend/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/recommend/urls.py b/recommend/urls.py new file mode 100644 index 0000000..6005564 --- /dev/null +++ b/recommend/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from recommend.views import SpotsRecommendView, SpotView, AsyncSpotsRecommendView + +app_name = 'recommend' +urlpatterns = [ + path('spots/', SpotsRecommendView.as_view(), name='spots_recommendation'), + path('async_spots/', AsyncSpotsRecommendView.as_view(), name='async_spots_recommendation'), + path('spots//', SpotView.as_view(), name='spot'), +] \ No newline at end of file diff --git a/recommend/views.py b/recommend/views.py new file mode 100644 index 0000000..23fa254 --- /dev/null +++ b/recommend/views.py @@ -0,0 +1,64 @@ +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from recommend.models import Spot +from recommend.ai_system import CustomPrompt, AsyncCustomPrompt +from recommend.serializers import SpotSerializer + +class SpotsRecommendView(APIView): + + permission_classes = [IsAuthenticated] + + def get(self, request): + spots = Spot.objects.filter(user_id=request.user.id) + serializer = SpotSerializer(spots, many=True) + return Response(serializer.data) + + def post(self, request): + # SPOT 전부 초기화 (POST 자체만으로 새로 프롬프트를 날려 스팟 정보를 초기화 하겠다는 뜻임) + # POST 는 사용자가 본인의 Preferences를 업데이트할 시에만 하는 것으로 하든, 아니면 주기적으로 (ex 세번 접속시) 업데이트 하는 것으로 함 + Spot.objects.filter(user_id=request.user.id).delete() + spots = CustomPrompt(request.user).openai_request() + + for s in spots: + Spot.objects.create( + user=request.user, + region=s.region, + country=s.country, + intro=s.intro + ) + + return Response({"message": "Spots updated"}, status=201) + + +from asgiref.sync import async_to_sync + +class AsyncSpotsRecommendView(APIView): + + permission_classes = [IsAuthenticated] + + async def post(self, request): + # SPOT 전부 초기화 (POST 자체만으로 새로 프롬프트를 날려 스팟 정보를 초기화 하겠다는 뜻임) + # POST 는 사용자가 본인의 Preferences를 업데이트할 시에만 하는 것으로 하든, 아니면 주기적으로 (ex 세번 접속시) 업데이트 하는 것으로 함 + Spot.objects.filter(user_id=request.user.id).delete() + spots = async_to_sync(AsyncCustomPrompt(request.user).async_openai_request)() + + for s in spots: + Spot.objects.create( + user=request.user, + region=s.region, + country=s.country, + intro=s.intro + ) + + return Response({"message": "Spots updated"}, status=201) + + +class SpotView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, spot_id): + spot = get_object_or_404(Spot, id=spot_id) + serializer = SpotSerializer(spot) + return Response(serializer.data) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5456cc3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +Django==5.0.6 +djangorestframework==3.16.0 +djangorestframework_simplejwt==5.5.0 +psycopg2-binary==2.9.10 +docker==6.0.1 +boto3==1.26.1 +gunicorn +pytest==7.3.2 +pytest-django==4.5.2 +python-dotenv==1.0.0 +django-environ==0.12.0 +drf-yasg +dj-rest-auth +django-allauth +django-rest-authtoken +django-extensions +Pillow +django-cors-headers +cryptography +python-decouple +openai +pydantic +aiohttp +django-storages[boto3] +boto3 +uvicorn +celery +celery[redis] +django-redis \ No newline at end of file diff --git a/scoopadive/__init__.py b/scoopadive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scoopadive/asgi.py b/scoopadive/asgi.py new file mode 100644 index 0000000..751fb85 --- /dev/null +++ b/scoopadive/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for scoopadive project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "scoopadive.settings") + +application = get_asgi_application() diff --git a/scoopadive/serializers.py b/scoopadive/serializers.py new file mode 100644 index 0000000..8a64402 --- /dev/null +++ b/scoopadive/serializers.py @@ -0,0 +1,10 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from rest_framework import serializers + +class GroupSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Group + fields = ['url', 'name'] + +User = get_user_model() diff --git a/scoopadive/settings.py b/scoopadive/settings.py new file mode 100644 index 0000000..ed14b30 --- /dev/null +++ b/scoopadive/settings.py @@ -0,0 +1,404 @@ +""" +Django settings for scoopadive project. + +Generated by 'django-admin startproject' using Django 5.0.6. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +import environ +import os + +env = environ.Env( + DEBUG=(bool, False), +) + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Take environment variables from .env file +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('SECRET_KEY') # 기본 사용방법 + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + + +# AWS S3 +# 환경변수에서 가져오기 (Docker, CI/CD 환경 모두 안전) +AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") +AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "my-django-app-images") +AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME", "ap-northeast-2") +AWS_S3_SIGNATURE_VERSION = "s3v4" + +# S3에 업로드 시 기본 스토리지 백엔드로 사용 +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + +# S3의 파일 URL 구성 +AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com" +MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/" + +# (선택) 캐시 제어 등 추가 설정 +AWS_S3_FILE_OVERWRITE = False # 같은 이름 파일 덮어쓰기 방지 +AWS_DEFAULT_ACL = None # 퍼블릭 ACL 경고 제거용 +AWS_QUERYSTRING_AUTH = False # URL에 ?AWSAccessKeyID=... 붙는 것 방지 + +# Django의 MEDIA 관련 설정 +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +# WordPress +WP_CLIENT_ID = os.environ.get("WP_CLIENT_ID") +WP_CLIENT_SECRET = os.environ.get("WP_CLIENT_SECRET") +WP_REDIRECT_URI = os.environ.get("WP_REDIRECT_URI") +WP_REDIRECT_URI_SWAGGER = os.environ.get("WP_REDIRECT_URI_SWAGGER") + +# OpenAI API +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") + +# FISHIAL +FISHIAL_CLIENT_ID = os.environ.get("FISHIAL_CLIENT_ID") # 예: "c0fae..." +FISHIAL_CLIENT_SECRET = os.environ.get("FISHIAL_CLIENT_SECRET") +FISHIAL_AUTH_URL = "https://api-users.fishial.ai/v1/auth/token" +FISHIAL_UPLOAD_URL = "https://api.fishial.ai/v1/recognition/upload" +FISHIAL_RECOGNITION_URL = "https://api.fishial.ai/v1/recognition/image" + + +ALLOWED_HOSTS = ['0.0.0.0', '127.0.0.1', '13.125.160.47', 'localhost', 'www.scoopadive.com', 'scoopadive.com', 'web', 'api.scoopadive.com'] + +AUTH_USER_MODEL = 'auths.User' +# Application definition + +# Celery +# Redis 브로커 사용 +CELERY_BROKER_URL = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' + +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Seoul' + +# Redis Cache +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://redis:6379/0", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +INSTALLED_APPS = [ + "logbook.apps.LogbookConfig", + "auths.apps.AuthsConfig", + "accounts.apps.AccountsConfig", + "mypage.apps.MypageConfig", + "home.apps.HomeConfig", + "search.apps.SearchConfig", + "wordpress.apps.WordpressConfig", + "recommend.apps.RecommendConfig", + "settings.apps.SettingsConfig", + "photo.apps.PhotoConfig", + # auths + "rest_framework", + # swagger + 'drf_yasg', + # cors + 'corsheaders', + + # S3 버킷 연동 + 'storages', + + 'rest_framework.authtoken', + 'rest_framework_simplejwt.token_blacklist', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'allauth', + "django_extensions", + + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + 'django.contrib.sites', # 사이트 프레임워크 필수 + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', # 구글 소셜로그인 프로바이더 + +] + +SITE_ID = 1 + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +GRAPH_MODELS = { + 'all_applications': True, + 'groups': True, +} + +REST_FRAMEWORK = { + +'DEFAULT_PERMISSION_CLASSES': [ + # DEFAULT_PERMISSION_CLASSES를 설정하면 개별 ViewSet에 permission_classes = [...]를 따로 안 써도 됩니다. + # 다만 특정 ViewSet만 로그인 필요하게 만들고 싶다면 개별 설정이 더 좋습니다. + # 'rest_framework.permissions.IsAuthenticated', + # 'rest_framework.permissions.IsAdminUser', # 관리자만 접근 가능 + 'rest_framework.permissions.AllowAny', # 누구나 접근 가능 + ], + + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', # 이 줄 추가! + ], + + # Use Django's standard `django.contrib.auths` permissions, + # or allow read-only access for unauthenticated users. + + 'DEFAULT_AUTHENTICATION_CLASSES': [ + # 'rest_framework.authentication.BasicAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + # 'dj_rest_auth.authentication.JWTCookieAuthentication', + # 'scoopadive.authentication.JWTWithBlacklistAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10 +} + +# SWAGGER 세팅 + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header', + 'description': 'JWT Authorization header. Example: "Bearer "', + } + }, + 'USE_SESSION_AUTH': False, # 세션 기반 인증 비활성화 +} + +from datetime import timedelta + +## JWT +# 추가적인 JWT_AUTH 설젇 +JWT_AUTH = { + 'JWT_SECRET_KEY': SECRET_KEY, + 'JWT_ALGORITHM': 'HS256', # 암호화 알고리즘 + 'JWT_ALLOW_REFRESH': True, # refresh 사용 여부 + 'JWT_EXPIRATION_DELTA': timedelta(days=7), # 유효기간 설정 + 'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=28), # JWT 토큰 갱신 유효기간 + # import datetime 상단에 import 하기 +} + + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + 'JWK_URL': None, + 'LEEWAY': 0, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), +} + + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + # cors + "allauth.account.middleware.AccountMiddleware", +] + + +AUTHENTICATION_BACKENDS = ( + 'allauth.account.auth_backends.AuthenticationBackend', +) + +# 구글 로그인 설정 +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") +GOOGLE_SECRET = os.environ.get("GOOGLE_SECRET") +GOOGLE_REDIRECT = os.environ.get("GOOGLE_REDIRECT") +GOOGLE_CALLBACK_URI = os.environ.get("GOOGLE_CALLBACK_URI") + +# 이메일/비밀번호 회원가입 비활성화 +ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False + +AUTH_USER_MODEL = 'auths.User' + +# 자동 구글 회원가입 허용 +SOCIALACCOUNT_AUTO_SIGNUP = True +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'SCOPE': ['profile', 'email'], + 'AUTH_PARAMS': {'access_type': 'online'}, + } +} + +# 로그인 후 리다이렉트 +LOGIN_REDIRECT_URL = '/' + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = ["https://scoopadive.com", "https://www.scoopadive.com", "https://api.scoopadive.com"] + +CSRF_TRUSTED_ORIGINS = [ + "https://scoopadive.com", + "https://www.scoopadive.com", + "https://api.scoopadive.com", +] +SESSION_COOKIE_DOMAIN = ".scoopadive.com" +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_DOMAIN = ".scoopadive.com" +CSRF_COOKIE_SECURE = True + +# SameSite 설정 +CSRF_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SAMESITE = "Lax" + +# 세션/로그인 만료 등 +SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' + +ROOT_URLCONF = "scoopadive.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "scoopadive.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DB_NAME'], + 'USER': os.environ['DB_USER'], + 'PASSWORD': os.environ['DB_PASSWORD'], + 'HOST': os.environ['DB_HOST'], + 'PORT': int(os.environ['DB_PORT']), + 'OPTIONS': { + 'options': '-c search_path=public' + }, + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +import sys +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "stream": sys.stdout, + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, +} diff --git a/scoopadive/urls.py b/scoopadive/urls.py new file mode 100644 index 0000000..3e7fe22 --- /dev/null +++ b/scoopadive/urls.py @@ -0,0 +1,85 @@ +""" +URL configuration for scoopadive project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include, re_path +from rest_framework import permissions +from django.conf import settings +from drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework import routers + +import home +from . import views +from django.conf.urls.static import static + + + +# Routers provide an easy way of automatically determining the URL conf. +router = routers.DefaultRouter() +router.register(r'groups', views.GroupViewSet) + +# Swagger UI 적용 +schema_view = get_schema_view( + openapi.Info( + title="ScoopADive API", + default_version='v1', + description="ScoopADive를 위한 유저 API 문서", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@snippets.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=[permissions.AllowAny], + url="https://api.scoopadive.com/swagger.json" +) + + +urlpatterns = [ + path("admin/", admin.site.urls), + path('api/home/', include("home.urls")), + path("api/logbooks/", include("logbook.urls")), + path("api/auths/", include("auths.urls")), + + path("api/accounts/", include("accounts.urls")), + + path('api/mypage/', include("mypage.urls")), + path('api/search/', include("search.urls")), + + path('api/wordpress/', include("wordpress.urls")), + + path('api/recommend/', include("recommend.urls")), + + path('api/settings/', include("settings.urls")), + + path('api/photo/', include('photo.urls')), + + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), +] + +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +# swagger +urlpatterns += [ + # re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), + re_path(r'^swagger(?P\.json|\.yaml)/?$', schema_view.without_ui(cache_timeout=0), name='schema-json'), + re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + + # re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + # re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), +] + diff --git a/scoopadive/views.py b/scoopadive/views.py new file mode 100644 index 0000000..ba21fe8 --- /dev/null +++ b/scoopadive/views.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import Group, User +from rest_framework import viewsets + +from logbook.models import Logbook +from .serializers import GroupSerializer + +from rest_framework import permissions + + +class GroupViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows groups to be viewed or edited. + """ + queryset = Group.objects.all().order_by('name') + serializer_class = GroupSerializer + permission_classes = [permissions.IsAuthenticated] + + diff --git a/scoopadive/wsgi.py b/scoopadive/wsgi.py new file mode 100644 index 0000000..73a66ae --- /dev/null +++ b/scoopadive/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for scoopadive project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "scoopadive.settings") + +application = get_wsgi_application() diff --git a/search/__init__.py b/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/search/admin.py b/search/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/search/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/search/apps.py b/search/apps.py new file mode 100644 index 0000000..1c3a606 --- /dev/null +++ b/search/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SearchConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "search" diff --git a/search/migrations/__init__.py b/search/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/search/models.py b/search/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/search/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/search/serializers.py b/search/serializers.py new file mode 100644 index 0000000..1ae4aac --- /dev/null +++ b/search/serializers.py @@ -0,0 +1,59 @@ +from rest_framework import serializers +from auths.models import User +from home.models import Job +from logbook.models import Logbook, Comment +from mypage.models import BucketList, Friend + + +class UserSearchSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email', 'country', 'license', 'introduction', 'profile_image'] + + +class LogbookSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Logbook + fields = [ + 'id', + 'dive_title', + 'dive_site', + 'feeling', + 'dive_date', + 'max_depth', + 'bottom_time', + 'weather', + 'type_of_dive', + 'weight', + 'start_pressure', + 'end_pressure', + ] + + +class CommentSearchSerializer(serializers.ModelSerializer): + author_username = serializers.CharField(source='author.username', read_only=True) + + class Meta: + model = Comment + fields = ['id', 'text', 'created_at', 'author_username'] + + +class BucketListSearchSerializer(serializers.ModelSerializer): + class Meta: + model = BucketList + fields = ['id', 'title', 'created_at'] + + +class FriendSearchSerializer(serializers.ModelSerializer): + user_username = serializers.CharField(source='user.username', read_only=True) + friend_username = serializers.CharField(source='friend.username', read_only=True) + + class Meta: + model = Friend + fields = ['id', 'user_username', 'friend_username', 'created_at'] + + +class JobSearchSerializer(serializers.ModelSerializer): + class Meta: + model = Job + fields = ['id', 'title', 'location', 'description'] diff --git a/search/tests.py b/search/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/search/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/search/urls.py b/search/urls.py new file mode 100644 index 0000000..925058d --- /dev/null +++ b/search/urls.py @@ -0,0 +1,7 @@ +# urls.py +from django.urls import path +from .views import GlobalSearchAPIView + +urlpatterns = [ + path('', GlobalSearchAPIView.as_view(), name='global-search'), +] diff --git a/search/views.py b/search/views.py new file mode 100644 index 0000000..ace6941 --- /dev/null +++ b/search/views.py @@ -0,0 +1,73 @@ +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.views import APIView +from rest_framework.response import Response +from django.db.models import Q +from django.contrib.auth import get_user_model + +from logbook.models import Logbook, Comment +from mypage.models import BucketList, Friend +from search.serializers import UserSearchSerializer, LogbookSearchSerializer, CommentSearchSerializer, \ + BucketListSearchSerializer, FriendSearchSerializer, JobSearchSerializer + +User = get_user_model() + +class GlobalSearchAPIView(APIView): + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'q', + openapi.IN_QUERY, + description="검색어", + type=openapi.TYPE_STRING + ) + ] + ) + def get(self, request): + q = request.GET.get('q', '').strip() + if not q: + return Response({'error': '검색어(q)를 입력하세요.'}, status=400) + + users = User.objects.filter( + Q(username__icontains=q) | + Q(email__icontains=q) | + Q(country__icontains=q) | + Q(license__icontains=q) | + Q(introduction__icontains=q) + ) + + logbooks = Logbook.objects.filter( + Q(dive_title__icontains=q) | + Q(dive_site__icontains=q) | + Q(feeling__icontains=q) + ) + + comments = Comment.objects.filter( + Q(text__icontains=q) + ) + + bucketlists = BucketList.objects.filter( + Q(title__icontains=q) + ) + + friends = Friend.objects.filter( + Q(user__username__icontains=q) | + Q(friend__username__icontains=q) + ) + + from home.models import Job + jobs = Job.objects.filter( + Q(title__icontains=q) | + Q(location__icontains=q) | + Q(description__icontains=q) + ) + + return Response({ + "users": UserSearchSerializer(users, many=True).data, + "logbooks": LogbookSearchSerializer(logbooks, many=True).data, + "comments": CommentSearchSerializer(comments, many=True).data, + "bucketlists": BucketListSearchSerializer(bucketlists, many=True).data, + "friends": FriendSearchSerializer(friends, many=True).data, + "jobs": JobSearchSerializer(jobs, many=True).data, + }) diff --git a/settings/__init__.py b/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settings/admin.py b/settings/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/settings/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/settings/apps.py b/settings/apps.py new file mode 100644 index 0000000..727634f --- /dev/null +++ b/settings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SettingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "settings" diff --git a/settings/migrations/0001_initial.py b/settings/migrations/0001_initial.py new file mode 100644 index 0000000..46cf523 --- /dev/null +++ b/settings/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.0.6 on 2025-11-02 06:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Preferences", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("birthday", models.CharField(blank=True, max_length=20)), + ("residence", models.CharField(blank=True, max_length=30, null=True)), + ("budget_min", models.CharField(blank=True, max_length=30, null=True)), + ("budget_max", models.CharField(blank=True, max_length=30, null=True)), + ("gender", models.CharField(blank=True, max_length=20, null=True)), + ( + "preferred_depth_range", + models.CharField(blank=True, max_length=20, null=True), + ), + ("hobbies", models.CharField(blank=True, max_length=100, null=True)), + ( + "preferred_activities", + models.CharField(blank=True, max_length=100, null=True), + ), + ( + "preferred_atmosphere", + models.CharField(blank=True, max_length=100, null=True), + ), + ( + "last_dive_date", + models.CharField(blank=True, max_length=20, null=True), + ), + ( + "preferred_diving", + models.CharField(blank=True, max_length=100, null=True), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/settings/migrations/__init__.py b/settings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settings/models.py b/settings/models.py new file mode 100644 index 0000000..e87221f --- /dev/null +++ b/settings/models.py @@ -0,0 +1,22 @@ +from django.db import models +from scoopadive import settings + +User = settings.AUTH_USER_MODEL + +# Create your models here. +class Preferences(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + birthday = models.CharField(max_length=20, blank=True) + residence = models.CharField(max_length=30, null=True, blank=True) + budget_min = models.CharField(max_length=30, null=True, blank=True) + budget_max = models.CharField(max_length=30, null=True, blank=True) + gender = models.CharField(max_length=20, null=True, blank=True) + preferred_depth_range = models.CharField(max_length=20, null=True, blank=True) + hobbies = models.CharField(max_length=100, null=True, blank=True) + preferred_activities = models.CharField(max_length=100, null=True, blank=True) + preferred_atmosphere = models.CharField(max_length=100, null=True, blank=True) + last_dive_date = models.CharField(max_length=20, null=True, blank=True) + preferred_diving = models.CharField(max_length=100, null=True, blank=True) + + def __str__(self): + return self.user.username \ No newline at end of file diff --git a/settings/serializers.py b/settings/serializers.py new file mode 100644 index 0000000..432c7a6 --- /dev/null +++ b/settings/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers + +from settings.models import Preferences + + +class PreferencesSerializer(serializers.ModelSerializer): + class Meta: + model = Preferences + fields = '__all__' + read_only_fields = ['user'] + + def create(self, validated_data): + user = self.context['request'].user + if user.is_anonymous: + raise serializers.ValidationError("Authentication required") + validated_data['user'] = user + return super().create(validated_data) diff --git a/settings/tests.py b/settings/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/settings/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/settings/urls.py b/settings/urls.py new file mode 100644 index 0000000..0dd961d --- /dev/null +++ b/settings/urls.py @@ -0,0 +1,10 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from settings.views import PreferencesViewSet + +router = DefaultRouter() +router.register('preferences', PreferencesViewSet, basename='preferences') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/settings/views.py b/settings/views.py new file mode 100644 index 0000000..66130d7 --- /dev/null +++ b/settings/views.py @@ -0,0 +1,17 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated + +from settings.models import Preferences +from settings.serializers import PreferencesSerializer + + +# Create your views here. +class PreferencesViewSet(viewsets.ModelViewSet): + queryset = Preferences.objects.all() + serializer_class = PreferencesSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Preferences.objects.filter(user=self.request.user).order_by('id') + + diff --git a/utils/wordpress.py b/utils/wordpress.py new file mode 100644 index 0000000..4e95ac5 --- /dev/null +++ b/utils/wordpress.py @@ -0,0 +1,43 @@ +# utils/wordpress.py +import requests +import json + +WP_API_BASE = "https://public-api.wordpress.com/rest/v1.1/sites/{site_id}" + + +def upload_image(access_token, image_path, image_name): + """ + WordPress 미디어 라이브러리에 이미지 업로드 + """ + url = f"{WP_API_BASE.format(site_id='me')}/media/new" + headers = {"Authorization": f"Bearer {access_token}"} + + with open(image_path, "rb") as img_file: + files = {"file": (image_name, img_file)} + res = requests.post(url, headers=headers, files=files) + + res.raise_for_status() + return res.json()["ID"] + + +def post_to_wordpress(access_token, title, content, media_id=None): + """ + WordPress 글 작성 (UTF-8 안전) + """ + url = f"{WP_API_BASE.format(site_id='me')}/posts/new" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + } + data = { + "title": title, + "content": content, + "status": "publish", + } + if media_id: + data["media[]"] = media_id + + # JSON 문자열 UTF-8로 안전하게 변환 + res = requests.post(url, headers=headers, data=json.dumps(data, ensure_ascii=False).encode('utf-8')) + res.raise_for_status() + return res.json()["URL"] diff --git a/wordpress/__init__.py b/wordpress/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wordpress/admin.py b/wordpress/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/wordpress/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/wordpress/apps.py b/wordpress/apps.py new file mode 100644 index 0000000..726457d --- /dev/null +++ b/wordpress/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WordpressConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "wordpress" diff --git a/wordpress/migrations/0001_initial.py b/wordpress/migrations/0001_initial.py new file mode 100644 index 0000000..900671b --- /dev/null +++ b/wordpress/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.6 on 2025-09-17 03:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WordPressToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("access_token", models.CharField(max_length=255)), + ( + "refresh_token", + models.CharField(blank=True, max_length=255, null=True), + ), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="wordpress_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/wordpress/migrations/0002_alter_wordpresstoken_unique_together_and_more.py b/wordpress/migrations/0002_alter_wordpresstoken_unique_together_and_more.py new file mode 100644 index 0000000..25560f4 --- /dev/null +++ b/wordpress/migrations/0002_alter_wordpresstoken_unique_together_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.6 on 2025-09-20 13:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("wordpress", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="wordpresstoken", + unique_together={("user",)}, + ), + migrations.AddField( + model_name="wordpresstoken", + name="created_at", + field=models.DateTimeField(auto_now_add=True, default=None), + preserve_default=False, + ), + migrations.AlterField( + model_name="wordpresstoken", + name="access_token", + field=models.TextField(), + ), + migrations.AlterField( + model_name="wordpresstoken", + name="refresh_token", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="wordpresstoken", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.RemoveField( + model_name="wordpresstoken", + name="expires_at", + ), + ] diff --git a/wordpress/migrations/0003_wordpresstoken_expires_at.py b/wordpress/migrations/0003_wordpresstoken_expires_at.py new file mode 100644 index 0000000..807344f --- /dev/null +++ b/wordpress/migrations/0003_wordpresstoken_expires_at.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.6 on 2025-09-30 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("wordpress", "0002_alter_wordpresstoken_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="wordpresstoken", + name="expires_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/wordpress/migrations/0004_alter_wordpresstoken_unique_together_and_more.py b/wordpress/migrations/0004_alter_wordpresstoken_unique_together_and_more.py new file mode 100644 index 0000000..573330e --- /dev/null +++ b/wordpress/migrations/0004_alter_wordpresstoken_unique_together_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2025-11-07 06:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("wordpress", "0003_wordpresstoken_expires_at"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="wordpresstoken", + unique_together=set(), + ), + migrations.AddField( + model_name="wordpresstoken", + name="site_id", + field=models.CharField(default=None, max_length=255), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name="wordpresstoken", + unique_together={("user", "site_id")}, + ), + ] diff --git a/wordpress/migrations/__init__.py b/wordpress/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wordpress/models.py b/wordpress/models.py new file mode 100644 index 0000000..2e64e5f --- /dev/null +++ b/wordpress/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.contrib.auth.models import User +from scoopadive import settings + + +class WordPressToken(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + access_token = models.TextField() + site_id = models.CharField(max_length=255) # WordPress 사이트 ID + refresh_token = models.TextField(null=True, blank=True) + expires_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'site_id') diff --git a/wordpress/serializers.py b/wordpress/serializers.py new file mode 100644 index 0000000..865e8fc --- /dev/null +++ b/wordpress/serializers.py @@ -0,0 +1,12 @@ +# myapp/serializers.py +from rest_framework import serializers +from .models import WordPressToken + +class WordPressTokenSerializer(serializers.ModelSerializer): + class Meta: + model = WordPressToken + fields = ["id", "user", "access_token", "refresh_token", "expires_at"] + read_only_fields = ["user"] # user는 자동 연결되도록 + +class LogbookPostSerializer(serializers.Serializer): + logbook_id = serializers.IntegerField() diff --git a/wordpress/tests.py b/wordpress/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/wordpress/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/wordpress/urls.py b/wordpress/urls.py new file mode 100644 index 0000000..c9d61f5 --- /dev/null +++ b/wordpress/urls.py @@ -0,0 +1,19 @@ +# myapp/urls.py +from django.urls import path, include +from .views import wp_login, wp_callback, wp_login_swagger, wp_callback_swagger +from rest_framework.routers import DefaultRouter +from .views import WordPressTokenViewSet, LogbookPostViewSet + +router = DefaultRouter() +router.register(r"wordpress-tokens", WordPressTokenViewSet, basename="wordpress-token") +router.register(r"logbook-post", LogbookPostViewSet, basename="logbook-post") + +urlpatterns = [ + path("oauth/login/", wp_login, name="wp_login"), + path("oauth/callback/", wp_callback, name="wp_callback"), + + path("oauth/login/swagger/", wp_login_swagger, name="wp_login_swagger"), + path("oauth/callback/swagger/", wp_callback_swagger, name="wp_callback_swagger"), + + path("", include(router.urls)), +] diff --git a/wordpress/views.py b/wordpress/views.py new file mode 100644 index 0000000..391e92d --- /dev/null +++ b/wordpress/views.py @@ -0,0 +1,295 @@ +from urllib.parse import urlencode + +import requests +from django.contrib.auth import get_user_model +from django.http import JsonResponse, HttpResponse +from django.shortcuts import redirect, get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import AccessToken + +from logbook.models import Logbook +from scoopadive import settings +from .models import WordPressToken +from .serializers import WordPressTokenSerializer, LogbookPostSerializer +from utils.wordpress import upload_image + +WP_CLIENT_ID = settings.WP_CLIENT_ID +WP_CLIENT_SECRET = settings.WP_CLIENT_SECRET +WP_REDIRECT_URI = settings.WP_REDIRECT_URI +WP_REDIRECT_URI_SWAGGER = settings.WP_REDIRECT_URI_SWAGGER + +User = get_user_model() + +# -------------------------- +# 브라우저용 OAuth +# -------------------------- +@api_view(['GET', 'HEAD']) +@permission_classes([permissions.AllowAny]) +def wp_login(request): + raw_token = request.GET.get("token") or request.GET.get("state", "") + params = { + "client_id": WP_CLIENT_ID, + "response_type": "code", + "redirect_uri": WP_REDIRECT_URI, + "scope": "global posts", + "state": raw_token, + "token": raw_token, + } + auth_url = f"https://public-api.wordpress.com/oauth2/authorize?{urlencode(params)}" + return redirect(auth_url) + + +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def wp_callback(request): + code = request.GET.get("code") + raw_token = request.GET.get("token") or request.GET.get("state") + if not code: + return JsonResponse({"detail": "WordPress OAuth code missing"}, status=400) + + user = None + if raw_token: + try: + validated = AccessToken(raw_token) + user_id = validated.get("user_id") + user = User.objects.get(id=user_id) + except Exception as e: + print("JWT decode failed:", e) + user = None + + if not user: + return JsonResponse({"detail": "User not authenticated"}, status=401) + + # WordPress access_token 요청 + res = requests.post( + "https://public-api.wordpress.com/oauth2/token", + data={ + "client_id": WP_CLIENT_ID, + "client_secret": WP_CLIENT_SECRET, + "redirect_uri": WP_REDIRECT_URI, + "code": code, + "grant_type": "authorization_code", + }, + timeout=10 + ) + data = res.json() + access_token = data.get("access_token") + if not access_token: + return JsonResponse({"detail": "WordPress returned error", "response": data}, status=400) + + # WordPress site_id 조회 + site_res = requests.get( + "https://public-api.wordpress.com/rest/v1.1/me/sites", + headers={"Authorization": f"Bearer {access_token}"} + ).json() + + if not site_res.get("sites"): + return JsonResponse({"detail": "WordPress site not found"}, status=400) + + # 첫 번째 사이트 기준 (필요 시 선택 UI 추가 가능) + site = site_res["sites"][0] + site_id = str(site["ID"]) + + # DB 저장 (user + site_id 기준) + WordPressToken.objects.update_or_create( + user=user, + site_id=site_id, + defaults={"access_token": access_token, "refresh_token": data.get("refresh_token")} + ) + + # --------- 여기부터 중요: 팝업 닫는 HTML 응답 --------- + # 부모 SPA와 동일한 origin 문자열 생성 + origin = ('https://' if request.is_secure() else 'http://') + request.get_host() + + html = f""" + + + + + Connecting... + + + + + + """ + + return HttpResponse(html) + + +# Swagger용 OAuth +# -------------------------- +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def wp_login_swagger(request): + """Swagger 전용: OAuth URL 반환""" + auth_url = ( + f"https://public-api.wordpress.com/oauth2/authorize?" + f"client_id={WP_CLIENT_ID}&response_type=code" + f"&redirect_uri={WP_REDIRECT_URI_SWAGGER}" + f"&scope=global posts" + f"&state=swagger" + ) + return JsonResponse({"auth_url": auth_url}) + + +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def wp_callback_swagger(request): + """Swagger 전용: 승인 코드로 access_token 반환""" + code = request.GET.get("code") + if not code: + return JsonResponse({"detail": "WordPress OAuth code missing"}, status=400) + + res = requests.post( + "https://public-api.wordpress.com/oauth2/token", + data={ + "client_id": WP_CLIENT_ID, + "client_secret": WP_CLIENT_SECRET, + "redirect_uri": WP_REDIRECT_URI_SWAGGER, + "code": code, + "grant_type": "authorization_code", + } + ) + res.raise_for_status() + data = res.json() + access_token = data.get("access_token") + refresh_token = data.get("refresh_token") + if not access_token: + return JsonResponse({"detail": "WordPress token request failed"}, status=400) + + return JsonResponse({"access_token": access_token, "refresh_token": refresh_token}) + + +# -------------------------- +# WordPressToken ViewSet +# -------------------------- +class WordPressTokenViewSet(viewsets.ModelViewSet): + queryset = WordPressToken.objects.all() + serializer_class = WordPressTokenSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return WordPressToken.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +# -------------------------- +# WordPress 유틸 함수 +# -------------------------- +def get_wordpress_site_id(access_token): + headers = {"Authorization": f"Bearer {access_token}"} + res = requests.get("https://public-api.wordpress.com/rest/v1.1/me/sites", headers=headers) + + if res.status_code != 200: + raise ValueError(f"WordPress API error: {res.status_code} - {res.text}") + + data = res.json() + if not data.get("sites"): + raise ValueError("WordPress site not found") + + return data["sites"][0]["ID"] + + +def post_to_wordpress(access_token, title, content, media_id=None): + site_id = get_wordpress_site_id(access_token) + post_data = {"title": title, "content": content, "status": "publish"} + if media_id: + post_data["media_ids"] = [media_id] + + url = f"https://public-api.wordpress.com/rest/v1.1/sites/{site_id}/posts/new" + res = requests.post(url, headers={"Authorization": f"Bearer {access_token}"}, json=post_data) + res.raise_for_status() + return res.json().get("URL") + + +# -------------------------- +# Logbook → WordPress 포스트 +# -------------------------- +class LogbookPostViewSet(viewsets.ViewSet): + permission_classes = [permissions.IsAuthenticated] + + @swagger_auto_schema(request_body=LogbookPostSerializer) + @action(detail=False, methods=['post']) + def post_to_wp(self, request): + serializer = LogbookPostSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + logbook_id = serializer.validated_data['logbook_id'] + site_id = serializer.validated_data.get('site_id') # 선택적으로 site_id 전달 가능 + + # 유저 토큰 가져오기 + if site_id: + token_obj = WordPressToken.objects.filter(user=request.user, site_id=site_id).first() + else: + token_obj = WordPressToken.objects.filter(user=request.user).first() + + if not token_obj: + return Response({"detail": "워드프레스 계정 로그인 필요"}, status=403) + + access_token = token_obj.access_token.strip() + + logbook = get_object_or_404(Logbook, id=logbook_id, user=request.user) + + media_id = None + if logbook.dive_image: + media_id = upload_image(access_token, logbook.dive_image.path, logbook.dive_image.name) + + # HTML 콘텐츠 구성 + equipment_list = ', '.join(eq.name for eq in logbook.equipment.all()) + buddy_info = logbook.buddy or 'N/A' + dive_center_name = logbook.dive_center.name if logbook.dive_center else 'N/A' + weather_info = logbook.weather or 'N/A' + dive_type_info = logbook.type_of_dive or 'N/A' + + content = f""" +

{logbook.dive_title}

+
    +
  • 날짜: {logbook.dive_date}
  • +
  • 장소: {logbook.dive_site}
  • +
  • 최대 수심: {logbook.max_depth} m
  • +
  • 바텀타임: {str(logbook.bottom_time)}
  • +
  • 버디: {buddy_info}
  • +
  • 날씨: {weather_info}
  • +
  • 다이브 타입: {dive_type_info}
  • +
  • 장비: {equipment_list}
  • +
  • 납 무게: {logbook.weight} kg
  • +
  • 탱크 압력: {logbook.start_pressure} → {logbook.end_pressure}
  • +
  • 다이브 센터: {dive_center_name}
  • +
+

{logbook.feeling or ''}

+ """ + + title = logbook.dive_title # WordPress 포스트 제목 + + try: + post_url = post_to_wordpress(access_token, title, content, media_id) + except requests.HTTPError as e: + return Response( + {"detail": f"WordPress API error: {e.response.status_code} - {e.response.text}"}, + status=e.response.status_code + ) + + return Response({"wordpress_url": post_url}, status=status.HTTP_201_CREATED)