diff --git a/.gitignore b/.gitignore index 32abb73..b6dab46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,38 @@ # 파이썬 캐시 __pycache__/ *.py[cod] +*.pyc # 가상환경 .conda/ env/ venv/ + # 데이터베이스 db.sqlite3 + # 미디어 및 정적 파일 폴더 media/ -static/ +storage/ + # VS Code 설정 .vscode/ -# 보안 및 환경 설정 파일 (추가) + +# 보안 및 환경 설정 파일 .env -.env.example \ No newline at end of file +credentials.json +token.json + +# Celery +celerybeat-schedule + +# 로그 +*.log + +# OS +.DS_Store +Thumbs.db + +# cookie +cookies.txt +cookie.txt \ No newline at end of file diff --git a/accounts/static/css/mypage.css b/accounts/static/css/mypage.css new file mode 100644 index 0000000..4c984f3 --- /dev/null +++ b/accounts/static/css/mypage.css @@ -0,0 +1,90 @@ +/* 마이페이지 전체 컨테이너 */ +.mypage-container { + max-width: 600px; + margin: 50px auto; + background: #fff; + padding: 40px; + border-radius: 24px; + box-shadow: 0 10px 40px rgba(0,0,0,0.06); + text-align: center; +} + +.mypage-container h2 { + font-size: 28px; + margin-bottom: 30px; + color: #1a1a1a; + font-weight: 800; +} + +/* 유저 아바타(가상) 및 이름 섹션 */ +.user-profile-header { + margin-bottom: 30px; +} + +.avatar-circle { + width: 80px; + height: 80px; + background: #e7f1ff; + color: #007bff; + font-size: 32px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + margin: 0 auto 15px; +} + +/* 유저 상세 정보 목록 */ +.user-info-list { + text-align: left; + background: #f8f9fa; + padding: 25px; + border-radius: 16px; + margin-bottom: 30px; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid #eee; +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: #888; + font-weight: 500; + font-size: 14px; +} + +.info-value { + color: #333; + font-weight: 600; +} + +/* 로그아웃 버튼 */ +.logout-wrapper { + margin-top: 20px; +} + +.btn-logout { + display: inline-block; + padding: 12px 30px; + color: #ff4757; + background: #fff5f5; + border-radius: 12px; + font-weight: 600; + transition: all 0.2s; + border: 1px solid #ffe0e0; +} + +.btn-logout:hover { + background: #ff4757; + color: #fff; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 71, 87, 0.2); +} \ No newline at end of file diff --git a/accounts/templates/accounts/mypage.html b/accounts/templates/accounts/mypage.html new file mode 100644 index 0000000..e35771e --- /dev/null +++ b/accounts/templates/accounts/mypage.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} + + +
+
+
+ {{ user.username|slice:":1"|upper }} +
+

👤 마이페이지

+
+ +
+
+ 이름 + {{ user.username }} +
+
+ 이메일 + {{ user.email|default:"이메일 정보 없음" }} +
+
+ 가입일 + {{ user.date_joined|date:"Y-m-d" }} +
+
+ +
+ + 🚪 로그아웃 하기 + +
+
+{% endblock %} \ No newline at end of file diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..9be3f41 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'accounts' + +urlpatterns = [ + path('mypage/', views.mypage, name='mypage'), + path('logout/', views.logout_view, name='logout'), +] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..b184377 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,13 @@ -from django.shortcuts import render +from django.shortcuts import render, redirect +from django.contrib.auth import logout as auth_logout +from django.contrib.auth.decorators import login_required -# Create your views here. +@login_required +def mypage(request): + # 내 정보 조회 + return render(request, 'accounts/mypage.html', {'user': request.user}) + +def logout_view(request): + # 로그아웃 처리 + auth_logout(request) + return redirect('gallery:photo_list') \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py index e69de29..fd18658 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -0,0 +1,4 @@ +# config/__init__.py +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..cec3557 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,19 @@ +# config/celery.py +import os +from celery import Celery + +# Django 설정 모듈 지정 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +# Celery 앱 생성 +app = Celery('config') + +# Django settings에서 설정 가져오기 +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Django 앱들에서 tasks.py 자동 발견 +app.autodiscover_tasks() + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index fab9979..b9162a3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -11,6 +11,11 @@ """ from pathlib import Path +import os +from dotenv import load_dotenv + +# .env 파일 로드 +load_dotenv() # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,12 +25,14 @@ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-m#wcj!^3&hz&v3gz-z%yvxgng2_@r!8cg=c#i3&pt@+rt59=mh' +SECRET_KEY = os.getenv('SECRET_KEY') +if not SECRET_KEY: + raise ValueError("SECRET_KEY가 설정되지 않았습니다. .env 파일을 확인하세요.") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv('DEBUG', 'True') == 'True' -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') # Application definition @@ -37,12 +44,31 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + + # Third party apps + 'rest_framework', + 'rest_framework.authtoken', + + 'django.contrib.sites', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + + # Local apps 'accounts', 'photos', 'classification', 'gallery', ] +SITE_ID = 1 + +# 로그인 후 리다이렉트 경로 +LOGIN_REDIRECT_URL = '/gallery/' +ACCOUNT_LOGOUT_REDIRECT_URL = '/accounts/login/' + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -51,6 +77,7 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', ] ROOT_URLCONF = 'config.urls' @@ -66,6 +93,7 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'gallery.context_processors.notification_context', ], }, }, @@ -121,7 +149,56 @@ STATIC_URL = 'static/' +STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +] + +# Media files +MEDIA_URL = os.getenv('MEDIA_URL', '/media/') +MEDIA_ROOT = BASE_DIR / 'media' + # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# REST Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + +# Authentication +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +# Google OAuth (로그인용) +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') +GOOGLE_SECRET = os.getenv('GOOGLE_SECRET') + +SOCIALACCOUNT_PROVIDERS = { + "google": { + "APP": { + "client_id": os.getenv("GOOGLE_CLIENT_ID", ""), + "secret": os.getenv("GOOGLE_SECRET", ""), + "key": "", + }, + "SCOPE": ["profile", "email"], + } +} + +# Celery 설정 +CELERY_BROKER_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0') +CELERY_RESULT_BACKEND = os.getenv('REDIS_URL', 'redis://localhost:6379/0') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Seoul' \ No newline at end of file diff --git a/config/urls.py b/config/urls.py index 5006338..27b5a89 100644 --- a/config/urls.py +++ b/config/urls.py @@ -15,8 +15,25 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +from django.shortcuts import redirect +from django.conf import settings +from django.conf.urls.static import static + urlpatterns = [ + path('', lambda request: redirect('/accounts/login/')), + path('admin/', admin.site.urls), + + # auth + path('accounts/', include('accounts.urls')), + path('accounts/', include('allauth.urls')), + + # apps + path('gallery/', include('gallery.urls')), + path('api/v1/photos/', include('photos.urls')), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/create_unclassified_categories.py b/create_unclassified_categories.py new file mode 100644 index 0000000..1c70d82 --- /dev/null +++ b/create_unclassified_categories.py @@ -0,0 +1,56 @@ +""" +기존 사용자를 위한 "분류 전" 카테고리 생성 스크립트 + +이 스크립트는 이미 가입한 사용자들에게 "분류 전" 카테고리를 추가합니다. +새로운 사용자는 signals.py에서 자동으로 생성되므로 실행할 필요 없습니다. + +사용 방법: + python manage.py shell < create_unclassified_categories.py + +또는: + python manage.py shell + >>> exec(open('create_unclassified_categories.py').read()) +""" + +from django.contrib.auth.models import User +from gallery.models import Category + +def create_unclassified_for_existing_users(): + """기존 사용자들에게 "분류 전" 카테고리 생성""" + + users = User.objects.all() + created_count = 0 + skipped_count = 0 + + for user in users: + # 이미 "분류 전" 카테고리가 있는지 확인 + existing = Category.objects.filter( + user=user, + name='분류 전' + ).exists() + + if existing: + print(f"⏭️ {user.username}: 이미 '분류 전' 카테고리가 있습니다.") + skipped_count += 1 + continue + + # "분류 전" 카테고리 생성 + Category.objects.create( + user=user, + name='분류 전', + category_key=None, + parent=None + ) + + print(f"✅ {user.username}: '분류 전' 카테고리가 생성되었습니다.") + created_count += 1 + + print("\n" + "="*50) + print(f"📊 결과:") + print(f" - 생성됨: {created_count}명") + print(f" - 건너뜀: {skipped_count}명") + print(f" - 전체: {users.count()}명") + print("="*50) + +if __name__ == "__main__": + create_unclassified_for_existing_users() \ No newline at end of file diff --git a/environment.yml b/environment.yml index a5dd691..0f8f806 100644 --- a/environment.yml +++ b/environment.yml @@ -2,7 +2,7 @@ name: recapture_env channels: - defaults dependencies: - - python=3.10.16 #python 3.10의 최신 패치 버전 + - python=3.10.16 - pip - pip: - - django==4.2.17 # 4.2 LTS 버전 고정 \ No newline at end of file + - -r requirements.txt \ No newline at end of file diff --git a/gallery/admin.py b/gallery/admin.py index 8c38f3f..3407c5e 100644 --- a/gallery/admin.py +++ b/gallery/admin.py @@ -1,3 +1,19 @@ from django.contrib import admin +from .models import Photo, Category, Notification -# Register your models here. +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'category_key', 'parent', 'user'] + list_filter = ['category_key', 'user'] + +@admin.register(Photo) +class PhotoAdmin(admin.ModelAdmin): + list_display = ['id', 'user', 'category', 'is_bookmarked', 'is_trashed', 'created_at'] + list_filter = ['user', 'category', 'is_bookmarked', 'is_trashed'] + search_fields = ['memo'] + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "category": + kwargs["queryset"] = Category.objects.filter(user=request.user) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + +admin.site.register(Notification) \ No newline at end of file diff --git a/gallery/apps.py b/gallery/apps.py index 97a664a..9c4a8d3 100644 --- a/gallery/apps.py +++ b/gallery/apps.py @@ -4,3 +4,6 @@ class GalleryConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'gallery' + + def ready(self): + import gallery.signals \ No newline at end of file diff --git a/gallery/context_processors.py b/gallery/context_processors.py new file mode 100644 index 0000000..0e7ae8c --- /dev/null +++ b/gallery/context_processors.py @@ -0,0 +1,9 @@ +from .models import Notification + +def notification_context(request): + if request.user.is_authenticated: + return { + 'latest_notifications': Notification.objects.filter(user=request.user)[:5], + 'unread_notifications_count': Notification.objects.filter(user=request.user, is_read=False).count() + } + return {} \ No newline at end of file diff --git a/gallery/management/commands/cleanup_photos.py b/gallery/management/commands/cleanup_photos.py new file mode 100644 index 0000000..1413038 --- /dev/null +++ b/gallery/management/commands/cleanup_photos.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from gallery.models import Photo, Notification + +class Command(BaseCommand): + help = '7일 이상 미확인된 사진을 휴지통으로 이동합니다.' + + def handle(self, *args, **options): + # 미확인 상태의 사진들만 가져오기 + photos = Photo.objects.filter(is_confirmed=False, is_trashed=False) + count = 0 + for photo in photos: + if photo.check_auto_trash(days=0): # 7일 기준 + count += 1 + Notification.objects.create( + user=photo.user, + message=f"미확인 사진이 휴지통으로 이동되었습니다: {photo.created_at.strftime('%Y-%m-%d')}" + ) + + self.stdout.write(self.style.SUCCESS(f'총 {count}장의 사진이 휴지통으로 이동되었습니다.')) \ No newline at end of file diff --git a/gallery/migrations/0001_initial.py b/gallery/migrations/0001_initial.py new file mode 100644 index 0000000..c836b49 --- /dev/null +++ b/gallery/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.17 on 2026-02-06 14:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('category_key', models.CharField(blank=True, choices=[('finance', '결제/금융'), ('study_note', '학습/노트'), ('info', '문서/정보'), ('others', '기타정보(비정보)')], max_length=20, null=True)), + ('is_bookmarked', models.BooleanField(default=False)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='gallery.category')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'name', 'parent')}, + }, + ), + migrations.CreateModel( + name='Photo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='photos/%Y/%m/%d/')), + ('filename', models.CharField(max_length=255)), + ('url', models.TextField(blank=True, null=True)), + ('file_size', models.BigIntegerField(blank=True, null=True)), + ('file_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)), + ('phash', models.CharField(blank=True, db_index=True, max_length=16, null=True)), + ('dhash', models.CharField(blank=True, db_index=True, max_length=16, null=True)), + ('ahash', models.CharField(blank=True, max_length=16, null=True)), + ('memo', models.TextField(blank=True, null=True)), + ('is_bookmarked', models.BooleanField(default=False)), + ('is_confirmed', models.BooleanField(default=False)), + ('is_trashed', models.BooleanField(default=False)), + ('trashed_at', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False)), + ('source', models.CharField(choices=[('UPLOAD', 'Upload'), ('GOOGLE', 'Google')], default='UPLOAD', max_length=20)), + ('google_id', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('taken_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to='gallery.category')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_photos', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'combined_photos', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.CharField(max_length=255)), + ('notif_type', models.CharField(choices=[('reminder', '리마인드'), ('trash', '휴지통이동')], max_length=20)), + ('remind_at', models.DateTimeField(blank=True, null=True)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reminders', to='gallery.photo')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/gallery/models.py b/gallery/models.py index 71a8362..97174ab 100644 --- a/gallery/models.py +++ b/gallery/models.py @@ -1,3 +1,106 @@ from django.db import models +from django.contrib.auth.models import User +from django.utils import timezone +from datetime import timedelta -# Create your models here. +class Category(models.Model): + # 6가지 고정 대분류 정의 + BASIC_CATEGORIES = [ + ('finance', '결제/금융'), + ('study_note', '학습/노트'), + ('info', '문서/정보'), + ('others', '기타정보(비정보)'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='categories') + name = models.CharField(max_length=50) # 사용자가 보는 이름 (예: 예적금) + + # 1차 대분류인 경우에만 선택, 2차 커스텀 폴더일 경우 부모를 따라가거나 비워둠 + category_key = models.CharField( + max_length=20, + choices=BASIC_CATEGORIES, + null=True, blank=True + ) + + # 자기 참조: 이 필드가 있으면 2차 분류, 없으면(None) 1차 분류가 됨 + parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='subcategories') + is_bookmarked = models.BooleanField(default=False) + + class Meta: + # 같은 유저 내에서, 같은 부모 아래에 동일한 이름의 폴더를 만들 수 없도록 제한 + unique_together = ('user', 'name', 'parent') + + def __str__(self): + return f"[{self.get_category_key_display()}] {self.name}" if self.category_key else self.name + +class Photo(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gallery_photos') # 중복 방지를 위해 이름 변경 + + # [파일 및 이미지 정보] + image = models.ImageField(upload_to='photos/%Y/%m/%d/') + filename = models.CharField(max_length=255) + url = models.TextField(null=True, blank=True) + file_size = models.BigIntegerField(null=True, blank=True) + + # [해시 데이터 - photos 모델에서 가져옴] + file_hash = models.CharField(max_length=64, db_index=True, null=True, blank=True) + phash = models.CharField(max_length=16, db_index=True, null=True, blank=True) + dhash = models.CharField(max_length=16, db_index=True, null=True, blank=True) + ahash = models.CharField(max_length=16, null=True, blank=True) + + # [분류 및 서비스 정보] + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='photos') + memo = models.TextField(blank=True, null=True) + is_bookmarked = models.BooleanField(default=False) + is_confirmed = models.BooleanField(default=False) + + # [상태 정보 (휴지통 등)] + is_trashed = models.BooleanField(default=False) + trashed_at = models.DateTimeField(blank=True, null=True) + is_deleted = models.BooleanField(default=False) # Soft delete (photos 모델 호환) + + # [소스 정보] + SOURCE_CHOICES = [('UPLOAD', 'Upload'), ('GOOGLE', 'Google')] + source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='UPLOAD') + google_id = models.CharField(max_length=255, null=True, blank=True, unique=True) + + # [메타데이터] + width = models.IntegerField(null=True, blank=True) + height = models.IntegerField(null=True, blank=True) + taken_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'combined_photos' # 테이블 이름 고정 + ordering = ['-created_at'] + + def __str__(self): + return f"{self.filename} ({self.user.username})" + + @property + def expires_at(self): + if self.trashed_at: + return self.trashed_at + timedelta(days=30) + return None + + def check_auto_trash(self, days=7): + if not self.is_confirmed and self.created_at <= timezone.now() - timedelta(days=days): + self.is_trashed = True + self.trashed_at = timezone.now() + self.save() + return True + return False + +class Notification(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') + photo = models.ForeignKey(Photo, on_delete=models.CASCADE, null=True, blank=True, related_name='reminders') + message = models.CharField(max_length=255) + notif_type = models.CharField(max_length=20, choices=[('reminder', '리마인드'), ('trash', '휴지통이동')]) + remind_at = models.DateTimeField(null=True, blank=True) + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"[{self.user.username}] {self.message}" \ No newline at end of file diff --git a/gallery/signals.py b/gallery/signals.py new file mode 100644 index 0000000..2c6dc55 --- /dev/null +++ b/gallery/signals.py @@ -0,0 +1,32 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User +from .models import Category + +@receiver(post_save, sender=User) +def create_default_categories(sender, instance, created, **kwargs): + if created: + # "분류 전" 카테고리 먼저 생성 (최우선) + Category.objects.create( + user=instance, + name='분류 전', + category_key=None, # 특수 카테고리로 category_key 없음 + parent=None + ) + + # 6대 대분류 정의 + default_cats = [ + ('finance', '결제/금융'), + ('study_note', '학습/노트'), + ('shopping', '쇼핑 정보'), + ('schedule', '일정/예약'), + ('document', '문서/정보'), + ('others', '기타정보(비정보)'), + ] + for key, name in default_cats: + Category.objects.create( + user=instance, + name=name, + category_key=key, + parent=None + ) \ No newline at end of file diff --git a/gallery/static/css/base.css b/gallery/static/css/base.css new file mode 100644 index 0000000..df30da5 --- /dev/null +++ b/gallery/static/css/base.css @@ -0,0 +1,166 @@ +/* 기본 리셋 및 폰트 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif; +} + +body { + background-color: #f8f9fa; + color: #333; +} + +a { + text-decoration: none; + color: inherit; +} + +/* 헤더 디자인 */ +header { + background: #ffffff; + padding: 0 40px; + height: 70px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + position: sticky; + top: 0; + z-index: 1000; +} + +header h1 a { + font-size: 24px; + font-weight: 800; + color: #007bff; + letter-spacing: -0.5px; +} + +nav { + display: flex; + align-items: center; + gap: 20px; +} + +.user-name { + font-weight: 500; + color: #555; +} + +.btn-mypage { + padding: 8px 16px; + background: #f1f3f5; + border-radius: 8px; + transition: background 0.2s; +} + +.btn-mypage:hover { + background: #e9ecef; +} + +.btn-login { + color: #007bff; + font-weight: 600; +} + +/* 알림 드롭다운 디자인 */ +.notification-dropdown { + position: relative; + margin-left: 20px; +} + +.noti-btn { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + padding: 10px; + display: flex; + align-items: center; + gap: 5px; +} + +#noti-count { + background: #ff4757; + color: white; + font-size: 11px; + padding: 2px 6px; + border-radius: 10px; + font-weight: bold; +} + +.dropdown-content { + display: none; + position: absolute; + right: 0; + top: 50px; + background-color: #ffffff; + min-width: 280px; + max-width: 320px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0,0,0,0.1); + border: 1px solid #eee; + overflow: hidden; +} + +.notification-dropdown:hover .dropdown-content { + display: block; + animation: fadeIn 0.2s ease-out; +} + +.noti-item { + padding: 15px; + border-bottom: 1px solid #f1f1f1; + font-size: 14px; + transition: background 0.2s; +} + +.noti-item:last-child { border-bottom: none; } +.noti-item:hover { background: #f8f9fa; } +.noti-item.unread { background: #fff9e6; } + +.noti-type { + display: inline-block; + font-size: 11px; + font-weight: bold; + color: #007bff; + margin-bottom: 4px; +} + +/* 애니메이션 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 메인 컨텐츠 영역 */ +main { + max-width: 1200px; + margin: 40px auto; + padding: 0 20px; +} + +/* 태블릿 및 모바일 공통 (768px 이하) */ +@media (max-width: 768px) { + header { + padding: 0 20px; + height: auto; + flex-direction: column; + padding-bottom: 15px; + } + + header h1 { + margin: 15px 0; + } + + nav { + gap: 10px; + flex-wrap: wrap; + justify-content: center; + } + + .user-name { + display: none; /* 모바일에서는 이름 생략 가능 */ + } +} \ No newline at end of file diff --git a/gallery/static/css/gallery.css b/gallery/static/css/gallery.css new file mode 100644 index 0000000..c2f854a --- /dev/null +++ b/gallery/static/css/gallery.css @@ -0,0 +1,139 @@ +/* 갤러리 헤더 및 버튼 영역 */ +.gallery-header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 30px 0 20px; +} + +.action-buttons { + display: flex; + gap: 12px; +} + +.btn-action { + padding: 12px 24px; + border: none; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + color: white; +} + +.btn-upload { background-color: #28a745; } +.btn-upload:hover { background-color: #218838; transform: translateY(-2px); } + +.btn-google { background-color: #4285f4; } +.btn-google:hover { background-color: #357ae8; transform: translateY(-2px); } + +.btn-classify { background-color: #ff6b6b; } +.btn-classify:hover { background-color: #ee5253; transform: translateY(-2px); } + +/* 필터바 디자인 */ +.filter-bar, .sub-filter-bar { + background: #ffffff; + padding: 15px 25px; + border-radius: 15px; + border: 1px solid #eee; + margin-bottom: 20px; + display: flex; + align-items: center; + box-shadow: 0 4px 6px rgba(0,0,0,0.02); +} + +.filter-link { + margin-right: 15px; + padding: 6px 12px; + border-radius: 20px; + font-size: 14px; + color: #666; + transition: all 0.2s; +} + +.filter-link.active { + background: #007bff; + color: white !important; + font-weight: bold; +} + +.filter-link:hover:not(.active) { + background: #f1f3f5; +} + +/* 갤러리 그리드 및 카드 */ +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 25px; + margin-top: 30px; +} + +.photo-card { + background: white; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 15px rgba(0,0,0,0.08); + transition: transform 0.3s; + border: 1px solid #f1f1f1; +} + +.photo-card:hover { + transform: translateY(-8px); +} + +.photo-img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +} + +.photo-info { + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.photo-category { + font-size: 13px; + font-weight: 600; + color: #007bff; +} + +.bookmark-icon { + color: #ffcc00; + font-size: 16px; +} + +/* 진행 바 */ +#importProgress { + border: none; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.05); +} + +.gallery-grid { + display: grid; + /* 1fr은 가능한 공간을 다 채우라는 뜻입니다 */ + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 15px; +} + +@media (max-width: 480px) { + .gallery-grid { + /* 아주 작은 폰에서는 한 줄에 2개씩 */ + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .filter-bar { + overflow-x: auto; /* 카테고리가 많으면 옆으로 밀어서 볼 수 있게 */ + white-space: nowrap; + display: block; + } + + .filter-link { + display: inline-block; + } +} \ No newline at end of file diff --git a/gallery/static/css/photo_detail.css b/gallery/static/css/photo_detail.css new file mode 100644 index 0000000..6cf4629 --- /dev/null +++ b/gallery/static/css/photo_detail.css @@ -0,0 +1,143 @@ +/* 상세 페이지 컨테이너 */ +.detail-container { + max-width: 800px; + margin: 40px auto; + background: #fff; + padding: 30px; + border-radius: 20px; + box-shadow: 0 10px 30px rgba(0,0,0,0.08); + display: flex; + gap: 30px; +} + +/* 이미지 영역 */ +.detail-image-wrapper { + flex: 1; + text-align: center; +} + +.detail-image-wrapper img { + width: 100%; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + object-fit: contain; +} + +/* 정보 영역 */ +.detail-info-wrapper { + flex: 1; + display: flex; + flex-direction: column; +} + +.detail-info-wrapper h3 { + font-size: 22px; + margin-bottom: 20px; + color: #1a1a1a; +} + +/* 북마크 버튼 */ +#bookmark-btn { + align-self: flex-start; + background: #fff; + border: 1px solid #eee; + padding: 8px 16px; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; + margin-bottom: 25px; +} + +#bookmark-btn:hover { + background: #f8f9fa; + border-color: #ddd; +} + +/* 정보 리스트 */ +.info-list { + list-style: none; + margin-bottom: 25px; +} + +.info-list li { + padding: 10px 0; + border-bottom: 1px solid #f1f1f1; + font-size: 15px; + color: #666; +} + +.info-list li strong { + color: #333; + margin-right: 10px; +} + +/* 메모 섹션 */ +.memo-section h4 { + font-size: 16px; + margin-bottom: 10px; +} + +#memo-content { + width: 100%; + padding: 12px; + border: 1px solid #eee; + border-radius: 10px; + background: #fdfdfd; + resize: none; + font-size: 14px; + margin-bottom: 10px; +} + +#memo-content:focus { + outline: none; + border-color: #007bff; +} + +.btn-save-memo { + width: 100%; + padding: 10px; + background: #007bff; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + +/* 액션 버튼 (휴지통) */ +.detail-actions { + margin-top: auto; + padding-top: 20px; +} + +.btn-trash { + width: 100%; + background: #fff; + color: #ff4d4d; + border: 1px solid #ffeded; + padding: 10px; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + transition: background 0.2s; +} + +.btn-trash:hover { + background: #ffeded; +} + +@media (max-width: 768px) { + .detail-container { + flex-direction: column; /* 가로에서 세로로 변경 */ + padding: 20px; + } + + .detail-image-wrapper img { + max-width: 100%; /* 이미지가 화면 밖으로 나가지 않게 */ + } + + .detail-info-wrapper { + width: 100%; + } +} \ No newline at end of file diff --git a/gallery/static/css/trash_list.css b/gallery/static/css/trash_list.css new file mode 100644 index 0000000..0f48170 --- /dev/null +++ b/gallery/static/css/trash_list.css @@ -0,0 +1,93 @@ +/* 휴지통 헤더 및 안내 문구 */ +.trash-header { + margin-bottom: 30px; +} + +.trash-header h2 { + font-size: 26px; + margin-bottom: 10px; + color: #333; +} + +.trash-info { + color: #888; + font-size: 14px; + margin-bottom: 15px; +} + +.back-link { + display: inline-block; + color: #007bff; + font-weight: 500; + margin-bottom: 25px; + transition: transform 0.2s; +} + +.back-link:hover { + transform: translateX(-5px); +} + +/* 휴지통 그리드 및 카드 */ +.trash-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 20px; +} + +.trash-card { + background: #fff; + border-radius: 12px; + padding: 12px; + border: 1px solid #eee; + text-align: center; + transition: all 0.3s ease; + filter: grayscale(0.5); /* 전체적으로 톤다운 */ +} + +.trash-card:hover { + filter: grayscale(0); /* 호버 시에만 컬러 */ + box-shadow: 0 5px 15px rgba(0,0,0,0.05); +} + +.trash-img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 8px; + margin-bottom: 12px; +} + +/* 조작 버튼 */ +.trash-btns { + display: flex; + justify-content: center; + gap: 10px; +} + +.btn-trash-action { + padding: 6px 12px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.btn-restore { + background-color: #e7f1ff; + color: #007bff; +} + +.btn-restore:hover { + background-color: #d0e3ff; +} + +.btn-delete-perm { + background-color: #fff5f5; + color: #ff4757; +} + +.btn-delete-perm:hover { + background-color: #ffe0e0; +} \ No newline at end of file diff --git a/gallery/static/js/photo_detail.js b/gallery/static/js/photo_detail.js new file mode 100644 index 0000000..9a2cbdd --- /dev/null +++ b/gallery/static/js/photo_detail.js @@ -0,0 +1,69 @@ +function toggleBookmark(photoId, csrfToken) { + const icon = document.getElementById('bookmark-icon'); + const isBookmarked = icon.innerText.includes('취소'); + + const url = isBookmarked ? `/gallery/bookmarks/${photoId}/` : `/gallery/bookmarks/add/`; + const method = isBookmarked ? 'DELETE' : 'POST'; + const body = isBookmarked ? null : JSON.stringify({ photoId: photoId }); + + fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: body + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + if (isBookmarked) { + icon.innerText = '☆ 북마크 추가'; + icon.style.color = '#888'; + } else { + icon.innerText = '★ 북마크 취소'; + icon.style.color = 'orange'; + } + } else { + alert('북마크 처리에 실패했습니다.'); + } + }) + .catch(err => console.error('Error:', err)); +} + +function saveMemo(photoId, csrfToken) { + const content = document.getElementById('memo-content').value; + fetch(`/gallery/memos/photos/${photoId}/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ content: content }) + }) + .then(res => res.json()) + .then(data => { + if(data.success) alert('메모가 저장되었습니다!'); + }); +} + +function moveToTrash(photoId, csrfToken) { + if (!confirm('이 사진을 휴지통으로 보내시겠습니까?')) return; + fetch(`/gallery/trash/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ photoId: photoId }) + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + alert('휴지통으로 이동되었습니다.'); + location.href = '/gallery/photos/'; + } else { + alert('이동 실패: ' + (data.error || '알 수 없는 오류')); + } + }); +} \ No newline at end of file diff --git a/gallery/static/js/photo_list.js b/gallery/static/js/photo_list.js new file mode 100644 index 0000000..b59cb6d --- /dev/null +++ b/gallery/static/js/photo_list.js @@ -0,0 +1,28 @@ +function createSubCategory(parentId, csrfToken) { + const subName = prompt('새로운 세부 폴더 이름을 입력하세요:'); + if (!subName) return; + + fetch(`/gallery/categories/add/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + name: subName, + parent_id: parentId + }) + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + location.reload(); + } else { + alert('폴더 생성에 실패했습니다: ' + (data.error || '알 수 없는 오류')); + } + }) + .catch(err => { + console.error('Error:', err); + alert('서버와의 통신 중 오류가 발생했습니다.'); + }); +} \ No newline at end of file diff --git a/gallery/static/js/trash_list.js b/gallery/static/js/trash_list.js new file mode 100644 index 0000000..63830e9 --- /dev/null +++ b/gallery/static/js/trash_list.js @@ -0,0 +1,19 @@ +function restorePhoto(photoId, csrfToken) { + if (!confirm('사진을 복구하시겠습니까?')) return; + fetch(`/gallery/trash/${photoId}/restore/`, { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken } + }) + .then(res => res.json()) + .then(data => { if(data.success) location.reload(); }); +} + +function permanentDelete(photoId, csrfToken) { + if (!confirm('영구 삭제하면 되돌릴 수 없습니다. 삭제하시겠습니까?')) return; + fetch(`/gallery/trash/${photoId}/`, { + method: 'DELETE', + headers: { 'X-CSRFToken': csrfToken } + }) + .then(res => res.json()) + .then(data => { if(data.success) location.reload(); }); +} \ No newline at end of file diff --git a/gallery/templates/base.html b/gallery/templates/base.html new file mode 100644 index 0000000..7107978 --- /dev/null +++ b/gallery/templates/base.html @@ -0,0 +1,48 @@ +{% load static %} +{% load socialaccount %} + + + + + + Re:Capture + + + +
+

Re:Capture

+ +
+ + +
+ + +
+
+
+ +
+ {% block content %} + {% endblock %} +
+ + \ No newline at end of file diff --git a/gallery/templates/gallery/photo_detail.html b/gallery/templates/gallery/photo_detail.html new file mode 100644 index 0000000..8292481 --- /dev/null +++ b/gallery/templates/gallery/photo_detail.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} + + +
+
+ 상세 이미지 +
+ +
+

📄 상세 정보

+ + + + + +
+

📝 메모

+ + +
+ +
+ +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/gallery/templates/gallery/photo_list.html b/gallery/templates/gallery/photo_list.html new file mode 100644 index 0000000..b2f7612 --- /dev/null +++ b/gallery/templates/gallery/photo_list.html @@ -0,0 +1,272 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} + + + + +
+ + + + + + {% if photos and current_category_name == '분류 전' %} + + {% endif %} +
+ +
+ 전체 + {% for cat in categories %} + + {{ cat.name }}{% if cat.name == '분류 전' %} ({{ cat.photos.count }}){% endif %} + + {% endfor %} + + | + + + {% if is_bookmarked %}★ 북마크 중{% else %}☆ 북마크만 보기{% endif %} + +
+ + + + + + +{% endblock %} diff --git a/gallery/templates/gallery/trash_list.html b/gallery/templates/gallery/trash_list.html new file mode 100644 index 0000000..6d5ff3d --- /dev/null +++ b/gallery/templates/gallery/trash_list.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} + + +
+

🗑️ 휴지통

+

휴지통에 있는 사진은 30일 뒤에 영구 삭제됩니다.

+ ← 갤러리로 돌아가기 +
+ +
+ {% for photo in photos %} +
+ 삭제 대기 중인 사진 +
+ + +
+
+ {% empty %} +
+

휴지통이 비어 있습니다.

+
+ {% endfor %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/gallery/urls.py b/gallery/urls.py new file mode 100644 index 0000000..8aff971 --- /dev/null +++ b/gallery/urls.py @@ -0,0 +1,32 @@ +from django.urls import path +from . import views + +app_name = 'gallery' + +urlpatterns = [ + path('', views.photo_list, name='home'), + + # 5. 통합 조회 API (전체 사진 + 갤러리 상태) + path('photos/', views.photo_list, name='photo_list'), + path('photos//', views.photo_detail, name='photo_detail'), + + # 1. 사진 북마크 + path('bookmarks/', views.bookmark_list, name='bookmark_list'), # GET: 목록 조회 + path('bookmarks/add/', views.add_bookmark, name='add_bookmark'), # POST: 추가 + path('bookmarks//', views.delete_bookmark, name='delete_bookmark'), # DELETE: 해제 + + # 2. 사진 메모 (upsert 방식 적용) + path('memos/photos//', views.manage_memo, name='manage_memo'), # PUT: 생성/수정, GET: 조회, DELETE: 삭제 + + # 3. 리마인드 알림 + path('reminders/', views.manage_reminders, name='manage_reminders'), # POST, GET + path('reminders//', views.edit_reminder, name='edit_reminder'), # PUT, DELETE + + # 4. 휴지통 기능 + path('trash/', views.trash_list, name='trash_list'), # GET: 목록 조회, POST: 임시 이동 + path('trash//restore/', views.restore_photo, name='restore_photo'), # POST: 복구 + path('trash//', views.permanent_delete, name='permanent_delete'), # DELETE: 영구 삭제 + + # 5. 세부 카테고리 만들기 + path('categories/add/', views.add_category, name='add_category'), +] \ No newline at end of file diff --git a/gallery/views.py b/gallery/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/gallery/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/gallery/views/__init__.py b/gallery/views/__init__.py new file mode 100644 index 0000000..a0ddc0f --- /dev/null +++ b/gallery/views/__init__.py @@ -0,0 +1,7 @@ +# gallery/views/__init__.py +from .base_views import * +from .memo_views import * +from .trash_views import * +from .alarm_views import * +from .bookmark_views import * +from .category_views import * \ No newline at end of file diff --git a/gallery/views/alarm_views.py b/gallery/views/alarm_views.py new file mode 100644 index 0000000..4db94dd --- /dev/null +++ b/gallery/views/alarm_views.py @@ -0,0 +1,89 @@ +# 리마인드 알림 관련 + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from gallery.models import Photo, Category, Notification +import json + +@login_required +def reminder_page(request): + reminders = Notification.objects.filter(user=request.user, notif_type='reminder') + return render(request, 'gallery/reminder_list.html', {'reminders': reminders}) + +@csrf_exempt +@login_required +def manage_reminders(request): + # 1. 알림 목록 조회 (GET) + if request.method == 'GET': + # 아직 읽지 않은 리마인드 알림 위주로 가져오기 + reminders = Notification.objects.filter( + user=request.user, + notif_type='reminder' + ).order_by('-created_at') + + items = [] + for r in reminders: + items.append({ + "reminderId": str(r.id), + "message": r.message, + "isRead": r.is_read, + "createdAt": r.created_at.isoformat() + }) + + return JsonResponse({ + "success": True, + "data": { + "items": items, + "total": len(items) + } + }) + + # 2. 알림 등록 (POST) + elif request.method == 'POST': + try: + data = json.loads(request.body) + # 명세서 기준: photoId, remindAt(알림 예정 시각) 등이 포함될 수 있음 + photo_id = data.get('photoId') + message = data.get('message', '영수증 확인 리마인드입니다.') + + # 알림 객체 생성 + new_reminder = Notification.objects.create( + user=request.user, + message=message, + notif_type='reminder', + is_read=False + ) + + return JsonResponse({ + "success": True, + "data": { + "reminderId": str(new_reminder.id), + "status": "scheduled" + } + }, status=201) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) + +@csrf_exempt +@login_required +def edit_reminder(request, reminderId): + reminder = get_object_or_404(Notification, id=reminderId, user=request.user) + + # 3. 알림 수정 (PUT) + if request.method == 'PUT': + data = json.loads(request.body) + reminder.message = data.get('message', reminder.message) + reminder.is_read = data.get('isRead', reminder.is_read) + reminder.save() + return JsonResponse({"success": True, "data": {"reminderId": reminderId, "updated": True}}) + + # 4. 알림 삭제 (DELETE) + elif request.method == 'DELETE': + reminder.delete() + return JsonResponse({"success": True, "data": {"reminderId": reminderId, "deleted": True}}) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) \ No newline at end of file diff --git a/gallery/views/base_views.py b/gallery/views/base_views.py new file mode 100644 index 0000000..5a67715 --- /dev/null +++ b/gallery/views/base_views.py @@ -0,0 +1,51 @@ +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from gallery.models import Photo, Category + +@login_required(login_url='/accounts/login/') +def photo_list(request): + photos = Photo.objects.filter(user=request.user, is_trashed=False) + categories = Category.objects.filter(user=request.user, parent=None) + + category_id = request.GET.get('category_id') + sub_category_id = request.GET.get('sub_category_id') + is_bookmarked = request.GET.get('bookmarked') + + sub_categories = [] + current_category_name = None + + # 1. 카테고리 필터 적용 + if category_id: + photos = photos.filter(category_id=category_id) + sub_categories = Category.objects.filter(user=request.user, parent_id=category_id) + + # 현재 카테고리 이름 가져오기 + try: + current_cat = Category.objects.get(id=category_id) + current_category_name = current_cat.name + except Category.DoesNotExist: + pass + + if sub_category_id: + photos = photos.filter(category_id=sub_category_id) + + # 2. 북마크 필터 적용 + if is_bookmarked == 'true': + photos = photos.filter(is_bookmarked=True) + + categories = Category.objects.filter(user=request.user, parent=None) + + return render(request, 'gallery/photo_list.html', { + 'photos': photos.order_by('-created_at'), + 'categories': categories, + 'sub_categories': sub_categories, + 'current_category': int(category_id) if category_id else None, + 'current_sub_category': int(sub_category_id) if sub_category_id else None, + 'current_category_name': current_category_name, + 'is_bookmarked': is_bookmarked == 'true' + }) + +@login_required(login_url='/accounts/login/') +def photo_detail(request, photoid): + photo = get_object_or_404(Photo, id=photoid, user=request.user) + return render(request, 'gallery/photo_detail.html', {'photo': photo}) \ No newline at end of file diff --git a/gallery/views/bookmark_views.py b/gallery/views/bookmark_views.py new file mode 100644 index 0000000..4605eec --- /dev/null +++ b/gallery/views/bookmark_views.py @@ -0,0 +1,86 @@ +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.utils import timezone +from gallery.models import Photo, Category +import json + +@login_required +def bookmark_list(request): + if request.method == 'GET': + bookmarks = Photo.objects.filter(user=request.user, is_bookmarked=True, is_trashed=False) + return render(request, 'gallery/bookmark_list.html', {'bookmarks': bookmarks}) + +# 1. 사진 북마크 추가 (POST /gallery/bookmarks) +@csrf_exempt +@login_required +def add_bookmark(request): + if request.method == 'POST': + try: + data = json.loads(request.body) + photo_id = data.get('photoId') + # 본인 소유이며 휴지통에 있지 않은 사진 확인 + photo = get_object_or_404(Photo, id=photo_id, user=request.user, is_trashed=False) + + photo.is_bookmarked = True + photo.save() + + return JsonResponse({ + "success": True, + "data": { + "bookmarkId": f"bm_{photo.id}", + "photoId": photo.id, + "createdAt": timezone.now().isoformat() + } + }) + except (json.JSONDecodeError, KeyError): + return JsonResponse({"success": False, "error": "Invalid data format"}, status=400) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) + +# 2. 사진 북마크 해제 (DELETE /gallery/bookmarks/{photoid}) +@csrf_exempt +@login_required +def delete_bookmark(request, photoid): + if request.method == 'DELETE': + photo = get_object_or_404(Photo, id=photoid, user=request.user) + photo.is_bookmarked = False + photo.save() + + return JsonResponse({ + "success": True, + "data": { + "photoId": photo.id, + "deleted": True + } + }) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) + +# 3. 북마크된 사진 목록 조회 (GET /gallery/bookmarks) +@login_required +def bookmark_list(request): + if request.method == 'GET': + bookmarks = Photo.objects.filter(user=request.user, is_bookmarked=True, is_trashed=False) + + items = [] + for p in bookmarks: + items.append({ + "bookmarkId": f"bm_{p.id}", + "photoId": p.id, + "thumbnailUrl": p.image.url if p.image else None, + "createdAt": p.created_at.isoformat() + }) + + return JsonResponse({ + "success": True, + "data": { + "items": items, + "page": 1, + "pageSize": 30, + "total": len(items) + } + }) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) \ No newline at end of file diff --git a/gallery/views/category_views.py b/gallery/views/category_views.py new file mode 100644 index 0000000..2c3c282 --- /dev/null +++ b/gallery/views/category_views.py @@ -0,0 +1,28 @@ +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.utils import timezone +from gallery.models import Photo, Category +import json + +@csrf_exempt +@login_required +def add_category(request): + if request.method == 'POST': + data = json.loads(request.body) + name = data.get('name') + parent_id = data.get('parent_id') + + parent = get_object_or_404(Category, id=parent_id, user=request.user) + + if Category.objects.filter(user=request.user, parent_id=parent_id, name=name).exists(): + return JsonResponse({"success": False, "error": "이미 존재하는 폴더 이름입니다."}, status=400) + + new_cat = Category.objects.create( + user=request.user, + name=name, + parent=parent, + category_key=f"sub_{timezone.now().timestamp()}" # 임의 키 생성 + ) + return JsonResponse({"success": True, "id": new_cat.id}) \ No newline at end of file diff --git a/gallery/views/memo_views.py b/gallery/views/memo_views.py new file mode 100644 index 0000000..649b610 --- /dev/null +++ b/gallery/views/memo_views.py @@ -0,0 +1,46 @@ +# gallery/views/memo_views.py +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from gallery.models import Photo +import json + +@csrf_exempt +@login_required +def manage_memo(request, photoid): + photo = get_object_or_404(Photo, id=photoid, user=request.user) + + # 1. 특정 사진 메모 조회 (GET) + if request.method == 'GET': + return JsonResponse({ + "success": True, + "data": { + "memo": { + "photoId": photo.id, + "content": photo.memo, + "updatedAt": photo.updated_at.isoformat() if photo.updated_at else None + } + } + }) + + # 2. 생성/수정 (PUT) + elif request.method == 'PUT': + try: + data = json.loads(request.body) + photo.memo = data.get('content') + photo.save() + return JsonResponse({ + "success": True, + "data": {"photoId": photo.id, "content": photo.memo, "updatedAt": photo.updated_at.isoformat()} + }) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) + + # 3. 삭제 (DELETE) + elif request.method == 'DELETE': + photo.memo = None + photo.save() + return JsonResponse({"success": True, "data": {"photoId": photo.id, "deleted": True}}) + + return JsonResponse({"success": False, "error": "Method not allowed"}, status=405) \ No newline at end of file diff --git a/gallery/views/trash_views.py b/gallery/views/trash_views.py new file mode 100644 index 0000000..185ead8 --- /dev/null +++ b/gallery/views/trash_views.py @@ -0,0 +1,61 @@ +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.utils import timezone +from gallery.models import Photo +import json + +# 1. 휴지통 목록 조회 (GET) +@csrf_exempt +@login_required +def trash_list(request): + # GET: 휴지통 목록 화면 보여주기 + if request.method == 'GET': + trashed_photos = Photo.objects.filter(user=request.user, is_trashed=True) + return render(request, 'gallery/trash_list.html', {'photos': trashed_photos}) + + # POST: 휴지통으로 이동시키기 + elif request.method == 'POST': + return move_to_trash(request) + +# 2. 휴지통 이동 (POST) +@csrf_exempt +@login_required +def move_to_trash(request): + if request.method == 'POST': + try: + data = json.loads(request.body) + photo_id = data.get('photoId') + photo = get_object_or_404(Photo, id=photo_id, user=request.user) + photo.is_trashed = True + photo.trashed_at = timezone.now() + photo.save() + return JsonResponse({ + "success": True, + "data": {"photoId": photo.id, "trashed": True, "expiresAt": photo.expires_at.isoformat()} + }) + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400) + +# 3. 복구 (POST) +@csrf_exempt +@login_required +def restore_photo(request, photoid): + if request.method == 'POST': + photo = get_object_or_404(Photo, id=photoid, user=request.user, is_trashed=True) + photo.is_trashed = False + photo.trashed_at = None + photo.save() + return JsonResponse({"success": True, "data": {"photoId": photo.id, "restored": True}}) + +# 4. 영구 삭제 (DELETE) +@csrf_exempt +@login_required +def permanent_delete(request, photoid): + if request.method == 'DELETE': + photo = get_object_or_404(Photo, id=photoid, user=request.user, is_trashed=True) + if photo.image: + photo.image.delete() + photo.delete() + return JsonResponse({"success": True, "data": {"photoId": photoid, "deletedPermanently": True}}) \ No newline at end of file diff --git a/photos/api/__init__.py b/photos/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photos/api/dedupe.py b/photos/api/dedupe.py new file mode 100644 index 0000000..9ebb4e5 --- /dev/null +++ b/photos/api/dedupe.py @@ -0,0 +1,64 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status + +# from photos.models import Photo +from gallery.models import Photo +from photos.serializers.response import APIResponse +from photos.serializers.photo import PhotoSerializer +from photos.services.deduplication_service import DeduplicationService + + +@api_view(["POST"]) +def check_duplicates(request): + """ + (11) 중복 제거 + - 완전히 동일한 이미지(SHA-256 동일)만 검사 + """ + user = request.user + + photo_id = request.data.get("photoId") + file_hash = request.data.get("fileHash") + + if not photo_id and not file_hash: + return Response( + APIResponse.error( + "INVALID_REQUEST", + "photoId 또는 fileHash 중 하나는 필요합니다." + ), + status=status.HTTP_400_BAD_REQUEST, + ) + + exact = [] + + if photo_id: + try: + photo = Photo.objects.get( + id=photo_id, + user=user, + is_deleted=False, + ) + except Photo.DoesNotExist: + return Response( + APIResponse.error("NOT_FOUND", "사진을 찾을 수 없습니다."), + status=status.HTTP_404_NOT_FOUND, + ) + + exact = DeduplicationService.find_exact_duplicates( + user, + file_hash=photo.file_hash, + exclude_photo_id=photo.id, + ) + + elif file_hash: + exact = DeduplicationService.find_exact_duplicates( + user, + file_hash=file_hash, + ) + + return Response( + APIResponse.success({ + "exact": [PhotoSerializer(p).data for p in exact], + }), + status=status.HTTP_200_OK, + ) diff --git a/photos/api/google.py b/photos/api/google.py new file mode 100644 index 0000000..5b58800 --- /dev/null +++ b/photos/api/google.py @@ -0,0 +1,193 @@ +# photos/api/google.py +from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import redirect +from rest_framework.permissions import AllowAny +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import SessionAuthentication + +# CSRF 체크 안 하는 SessionAuthentication +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return + +from rest_framework.response import Response +from rest_framework import status +from photos.models import GoogleCredential +from photos.serializers.google import ( + GoogleCallbackSerializer, + GoogleStatusResponseSerializer, + GoogleAuthorizeResponseSerializer, + GoogleCallbackResponseSerializer, + GoogleUnlinkResponseSerializer +) +from photos.serializers.response import APIResponse +from photos.services.google_photos_service import GooglePhotosService +import os + +# 1. 구글 연동 상태 조회 +@api_view(['GET']) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def google_status(request): + """구글 포토 연동 상태 확인""" + user = request.user + + try: + google_cred = GoogleCredential.objects.get(user=user, is_active=True) + + response_data = APIResponse.success({ + "connected": True, + "googleEmail": google_cred.google_email + }) + + return Response(response_data, status=status.HTTP_200_OK) + + except GoogleCredential.DoesNotExist: + response_data = APIResponse.success({ + "connected": False + }) + + return Response(response_data, status=status.HTTP_200_OK) + + +# 2. 구글 연동 시작 (URL 발급) +@api_view(['GET']) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def google_authorize(request): + """구글 OAuth 인증 URL 생성""" + + try: + # 환경 변수에서 redirect URI 가져오기 + redirect_uri = os.getenv('GOOGLE_PHOTOS_REDIRECT_URI', 'http://localhost:8000/api/v1/photos/google/callback/') + + # 인증 URL 생성 + auth_url, state = GooglePhotosService.get_authorization_url(redirect_uri) + + # state와 user_id를 세션에 저장 (CSRF 방지) + request.session['google_oauth_state'] = state + request.session['google_oauth_user_id'] = request.user.id # ← 추가! + request.session.save() # ← 명시적 저장 + + response_data = APIResponse.success({ + "authUrl": auth_url + }) + + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + response_data = APIResponse.error( + code="GOOGLE_AUTH_ERROR", + message=f"구글 인증 URL 생성 실패: {str(e)}" + ) + + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# 3. 구글 연동 완료 (콜백) +@csrf_exempt +@api_view(['GET', 'POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def google_callback(request): + """구글 OAuth 콜백 처리""" + + # GET 요청 처리 (Google에서 리디렉션) + if request.method == 'GET': + code = request.GET.get('code') + state = request.GET.get('state') + + if not code: + return redirect('/gallery/?error=no_code') + + # 세션에서 user_id 가져오기 + user_id = request.session.get('google_oauth_user_id') + + if not user_id: + # 세션 없으면 로그인된 유저 확인 + if request.user.is_authenticated: + user_id = request.user.id + else: + return redirect('/accounts/login/?next=/gallery/') + + # User 객체 가져오기 + from django.contrib.auth.models import User + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + return redirect('/accounts/login/?next=/gallery/') + + # Redirect URI + redirect_uri = os.getenv('GOOGLE_PHOTOS_REDIRECT_URI', + 'http://localhost:8000/api/v1/photos/google/callback/') + + try: + # 인증 코드를 토큰으로 교환 + token_data = GooglePhotosService.exchange_code_for_tokens(code, redirect_uri) + + # DB에 저장 또는 업데이트 + google_cred, created = GoogleCredential.objects.update_or_create( + user=user, + defaults={ + 'google_email': token_data['google_email'], + 'access_token': token_data['access_token'], + 'refresh_token': token_data['refresh_token'], + 'token_uri': token_data['token_uri'], + 'client_id': token_data['client_id'], + 'client_secret': token_data['client_secret'], + 'scopes': token_data['scopes'], + 'is_active': True + } + ) + + # 세션 정리 + if 'google_oauth_user_id' in request.session: + del request.session['google_oauth_user_id'] + if 'google_oauth_state' in request.session: + del request.session['google_oauth_state'] + + # 성공 시 갤러리로 리디렉션 + return redirect('/gallery/?google_connected=true') + + except Exception as e: + print(f"Google callback error: {e}") + import traceback + traceback.print_exc() + return redirect(f'/gallery/?error=google_auth_failed') + + +# 4. 구글 연동 해제 +@api_view(['POST']) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def google_unlink(request): + """구글 포토 연동 해제""" + user = request.user + + try: + google_cred = GoogleCredential.objects.get(user=user) + google_cred.is_active = False + google_cred.save() + + response_data = APIResponse.success({ + "unlinked": True + }) + + return Response(response_data, status=status.HTTP_200_OK) + + except GoogleCredential.DoesNotExist: + response_data = APIResponse.error( + code="NOT_CONNECTED", + message="연동된 구글 계정이 없습니다." + ) + + return Response(response_data, status=status.HTTP_404_NOT_FOUND) + + except Exception as e: + response_data = APIResponse.error( + code="UNLINK_ERROR", + message=f"연동 해제 실패: {str(e)}" + ) + + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/photos/api/import_job.py b/photos/api/import_job.py new file mode 100644 index 0000000..936f555 --- /dev/null +++ b/photos/api/import_job.py @@ -0,0 +1,207 @@ +# photos/api/import_job.py +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework import status + +from photos.models import GoogleCredential, ImportJob +from photos.serializers.photo import ImportGoogleRequestSerializer +from photos.serializers.response import APIResponse +from photos.services.import_service import ImportService +from photos.services.google_photos_service import GooglePhotosService +from photos.tasks.import_photos import import_google_photos_task + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def import_from_google(request): + """ + (변경) 구글 포토 Import 시작: + - 기존: 곧바로 전체 라이브러리 다운로드 task 실행 + - 변경: Picker 세션 생성 후 pickerUri/sessionId 반환 + """ + serializer = ImportGoogleRequestSerializer(data=request.data) + if not serializer.is_valid(): + response_data = APIResponse.error(code="INVALID_REQUEST", message="잘못된 요청입니다.") + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + + user = request.user + folder_id = serializer.validated_data.get("folderId") + dedupe = serializer.validated_data.get("dedupe", True) + + try: + google_cred = GoogleCredential.objects.get(user=user, is_active=True) + except GoogleCredential.DoesNotExist: + response_data = APIResponse.error(code="GOOGLE_NOT_CONNECTED", message="구글 연동이 필요합니다.") + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + + try: + job_id = ImportService.generate_job_id() + + import_job = ImportJob.objects.create( + user=user, + job_id=job_id, + status="PICKING", # 새 상태(권장): 사용자가 Picker에서 선택 중 + source="GOOGLE", + folder_id=folder_id, + ) + + google_service = GooglePhotosService(google_credential=google_cred) + session = google_service.create_picker_session() + + session_id = session.get("sessionId") + picker_uri = session.get("pickerUri") + + # ImportJob 모델에 picker_session_id 필드가 없을 수도 있어서 안전 처리 + if hasattr(import_job, "picker_session_id"): + import_job.picker_session_id = session_id + import_job.save(update_fields=["picker_session_id"]) + + response_data = APIResponse.success( + { + "jobId": job_id, + "status": "PICKING", + "sessionId": session_id, + "pickerUri": picker_uri, + "dedupe": dedupe, + } + ) + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + response_data = APIResponse.error( + code="IMPORT_START_ERROR", + message=f"Import 작업 시작 실패: {str(e)}", + ) + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["GET"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def get_picker_session_status(request, session_id): + """ + (신규) Picker 세션 상태 조회 (프론트 폴링용) + """ + user = request.user + + try: + google_cred = GoogleCredential.objects.get(user=user, is_active=True) + except GoogleCredential.DoesNotExist: + response_data = APIResponse.error(code="GOOGLE_NOT_CONNECTED", message="구글 연동이 필요합니다.") + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + + try: + google_service = GooglePhotosService(google_credential=google_cred) + session = google_service.get_picker_session(session_id) + + response_data = APIResponse.success( + { + "sessionId": session_id, + "session": session, + } + ) + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + response_data = APIResponse.error( + code="PICKER_SESSION_ERROR", + message=f"Picker 세션 조회 실패: {str(e)}", + ) + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def confirm_google_picker_selection(request): + """ + (신규) Picker에서 선택 완료 후 다운로드 task 시작 + body 예시: + { + "jobId": "xxx", + "sessionId": "sessions/...." or "....", + "dedupe": true + } + """ + user = request.user + + job_id = request.data.get("jobId") + session_id = request.data.get("sessionId") + dedupe = request.data.get("dedupe", True) + + if not job_id or not session_id: + response_data = APIResponse.error(code="INVALID_REQUEST", message="jobId와 sessionId는 필수입니다.") + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + + try: + job = ImportJob.objects.get(job_id=job_id, user=user) + except ImportJob.DoesNotExist: + response_data = APIResponse.error(code="JOB_NOT_FOUND", message="작업을 찾을 수 없습니다.") + return Response(response_data, status=status.HTTP_404_NOT_FOUND) + + try: + # 상태 업데이트 + job.status = "QUEUED" + if hasattr(job, "picker_session_id"): + job.picker_session_id = session_id + job.save() + + # Celery 백그라운드 작업 시작 (이제 session_id를 넘겨야 함) + import_google_photos_task.delay( + user_id=user.id, + job_id=job_id, + folder_id=getattr(job, "folder_id", None), + dedupe=dedupe, + session_id=session_id, + ) + + response_data = APIResponse.success({"jobId": job_id, "status": "QUEUED"}) + return Response(response_data, status=status.HTTP_200_OK) + + except Exception as e: + response_data = APIResponse.error( + code="IMPORT_CONFIRM_ERROR", + message=f"Import 확인 처리 실패: {str(e)}", + ) + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(["GET"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def get_import_job_status(request, job_id): + """Import 작업 진행 상황 조회""" + user = request.user + + try: + job = ImportJob.objects.get(job_id=job_id, user=user) + + progress = ImportService.calculate_progress(job) + + response_data = APIResponse.success( + { + "jobId": job.job_id, + "status": job.status, + "progress": progress, + } + ) + return Response(response_data, status=status.HTTP_200_OK) + + except ImportJob.DoesNotExist: + response_data = APIResponse.error(code="JOB_NOT_FOUND", message="작업을 찾을 수 없습니다.") + return Response(response_data, status=status.HTTP_404_NOT_FOUND) + + except Exception as e: + response_data = APIResponse.error( + code="JOB_STATUS_ERROR", + message=f"작업 상태 조회 실패: {str(e)}", + ) + return Response(response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/photos/api/photos.py b/photos/api/photos.py new file mode 100644 index 0000000..da2ddba --- /dev/null +++ b/photos/api/photos.py @@ -0,0 +1,113 @@ +# photos/api/photos.py +import os +from django.utils import timezone + +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status +from rest_framework.pagination import PageNumberPagination + +# from photos.models import Photo +from gallery.models import Photo +from photos.serializers.response import APIResponse +from photos.serializers.photo import PhotoSerializer, PhotoUpdateSerializer + + +def _user_photo_or_404(user, photo_id: int) -> Photo: + return Photo.objects.get(id=photo_id, user=user, is_deleted=False) + + +@api_view(["GET"]) +def list_photos(request): + """ + (9) 사진 목록 조회 + - query optional: category, source + """ + qs = Photo.objects.filter( + user=request.user, + is_deleted=False + ).order_by("-created_at") + + category = request.query_params.get("category") + source = request.query_params.get("source") + + if category: + qs = qs.filter(category=category) + if source: + qs = qs.filter(source=source) + + paginator = PhotoPagination() + page = paginator.paginate_queryset(qs, request) + serializer = PhotoSerializer(page, many=True) + + return paginator.get_paginated_response( + APIResponse.success({"items": serializer.data}) + ) + + +@api_view(["GET"]) +def get_photo_detail(request, photo_id: int): + """ + (8) 사진 상세 조회 + """ + user = request.user + try: + photo = _user_photo_or_404(user, photo_id) + return Response(APIResponse.success(PhotoSerializer(photo).data), status=status.HTTP_200_OK) + except Photo.DoesNotExist: + return Response(APIResponse.error("NOT_FOUND", "사진을 찾을 수 없습니다."), status=status.HTTP_404_NOT_FOUND) + + +@api_view(["PATCH"]) +def update_photo(request, photo_id: int): + """ + (10) 사진 메타 수정(카테고리/메모 등) + """ + user = request.user + try: + photo = _user_photo_or_404(user, photo_id) + except Photo.DoesNotExist: + return Response(APIResponse.error("NOT_FOUND", "사진을 찾을 수 없습니다."), status=status.HTTP_404_NOT_FOUND) + + serializer = PhotoUpdateSerializer(photo, data=request.data, partial=True) + if not serializer.is_valid(): + return Response(APIResponse.error("INVALID_REQUEST", "잘못된 요청입니다."), status=status.HTTP_400_BAD_REQUEST) + + serializer.save() + return Response(APIResponse.success(PhotoSerializer(photo).data), status=status.HTTP_200_OK) + + +@api_view(["DELETE"]) +def delete_photo(request, photo_id: int): + """ + (12) 사진 삭제 + - soft delete + 파일 정리 + """ + user = request.user + try: + photo = _user_photo_or_404(user, photo_id) + except Photo.DoesNotExist: + return Response(APIResponse.error("NOT_FOUND", "사진을 찾을 수 없습니다."), status=status.HTTP_404_NOT_FOUND) + + # 파일 삭제 시도 (MEDIA_URL을 실제 경로로 바꿔서 삭제) + # url이 "/media/photos/..." 같은 형태라면 MEDIA_ROOT 기준으로 변환 필요 + # 여기서는 "storage가 MEDIA_ROOT 아래"라는 전제에서 상대 경로로 변환 + # (환경에 따라 다를 수 있어, 안 맞으면 이 함수만 조정하면 됨) + for u in [photo.url, photo.thumb_url]: + try: + if u and "/media/" in u: + rel = u.split("/media/", 1)[1] + abs_path = os.path.join("media", rel) # 기본 media 폴더 가정 + if os.path.exists(abs_path): + os.remove(abs_path) + except Exception: + pass + + photo.is_deleted = True + photo.deleted_at = timezone.now() + photo.save(update_fields=["is_deleted", "deleted_at"]) + + return Response(APIResponse.success({"deleted": True, "photoId": photo.id}), status=status.HTTP_200_OK) + +class PhotoPagination(PageNumberPagination): + page_size = 30 \ No newline at end of file diff --git a/photos/api/upload.py b/photos/api/upload.py new file mode 100644 index 0000000..f66bf7b --- /dev/null +++ b/photos/api/upload.py @@ -0,0 +1,120 @@ +""" +주의: +이 앱은 TokenAuthentication + IsAuthenticated 전제를 기반으로 동작한다. +accounts 앱에서 토큰 발급 API가 반드시 선행되어야 한다. +""" + +import os + +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status + +from gallery.models import Photo, Category +from photos.serializers.response import APIResponse +from photos.serializers.photo import PhotoSerializer +from photos.services.upload_service import save_uploaded_file +from photos.services.deduplication_service import DeduplicationService + + +# CSRF 체크 안 하는 SessionAuthentication +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return # CSRF 체크 비활성화 + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([IsAuthenticated]) +def upload_photos(request): + """ + (5) 직접 업로드 + - SHA-256 기준 완전히 동일한 파일만 중복 처리 + - 업로드된 사진은 자동으로 "분류 전" 카테고리에 넣으려고! + """ + user = request.user + files = request.FILES.getlist("files") + + if not files: + return Response( + APIResponse.error("NO_FILES", "업로드할 파일이 없습니다."), + status=status.HTTP_400_BAD_REQUEST, + ) + + # "분류 전" 카테고리 가져오기 (없으면 생성) + unclassified_category, _ = Category.objects.get_or_create( + user=user, + name='분류 전', + defaults={ + 'category_key': None, + 'parent': None + } + ) + + created = [] + duplicates = [] + + for f in files: + meta = save_uploaded_file(user, f) + + # 1️⃣ Exact 중복 사전 체크 + existing = Photo.objects.filter( + user=user, + file_hash=meta["file_hash"], + is_deleted=False, + ).first() + + if existing: + # 방금 저장한 파일/썸네일 정리 + for path in (meta.get("_final_path"), meta.get("_thumb_path")): + if path and os.path.exists(path): + try: + os.remove(path) + except OSError: + pass + + duplicates.append({ + "filename": meta["filename"], + "existingPhotoId": existing.id, + }) + continue + + # 2️⃣ Photo 생성 ("분류 전" 카테고리에 자동 할당) + # ImageField에는 media root 기준 상대 경로 저장 + from django.conf import settings + relative_path = meta["_final_path"].replace(str(settings.MEDIA_ROOT), "").lstrip("/").lstrip("\\") + + photo = Photo.objects.create( + user=user, + filename=meta["filename"], + image=relative_path, + url=meta["url"], + file_size=meta["file_size"], + file_hash=meta["file_hash"], + phash=meta["phash"], + dhash=meta["dhash"], + ahash=meta["ahash"], + width=meta["width"], + height=meta["height"], + taken_at=meta["taken_at"], + source="UPLOAD", + category=unclassified_category, + ) + + # 3️⃣ 생성 직후 exact duplicate 재확인 + 마킹 + DeduplicationService.check_exact_duplicate_and_mark( + user, + photo=photo, + ) + + created.append(PhotoSerializer(photo).data) + + return Response( + APIResponse.success({ + "created": created, + "duplicates": duplicates, + }), + status=status.HTTP_200_OK, + ) \ No newline at end of file diff --git a/photos/migrations/0001_initial.py b/photos/migrations/0001_initial.py new file mode 100644 index 0000000..65f80b5 --- /dev/null +++ b/photos/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.17 on 2026-02-02 20:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GoogleCredential', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('google_email', models.EmailField(max_length=254)), + ('access_token', models.TextField()), + ('refresh_token', models.TextField()), + ('token_uri', models.CharField(max_length=255)), + ('client_id', models.CharField(max_length=255)), + ('client_secret', models.CharField(max_length=255)), + ('scopes', models.JSONField()), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_credential', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'google_credentials', + }, + ), + migrations.CreateModel( + name='ImportJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('job_id', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(choices=[('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('DONE', 'Done'), ('FAILED', 'Failed')], default='QUEUED', max_length=20)), + ('source', models.CharField(default='GOOGLE', max_length=20)), + ('folder_id', models.CharField(blank=True, max_length=255, null=True)), + ('total_count', models.IntegerField(default=0)), + ('done_count', models.IntegerField(default=0)), + ('skipped_count', models.IntegerField(default=0)), + ('failed_count', models.IntegerField(default=0)), + ('error_message', models.TextField(blank=True, null=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'import_jobs', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'status'], name='import_jobs_user_id_2d2855_idx')], + }, + ), + ] diff --git a/photos/migrations/0002_importjob_picker_session_id_alter_importjob_status_and_more.py b/photos/migrations/0002_importjob_picker_session_id_alter_importjob_status_and_more.py new file mode 100644 index 0000000..944d21f --- /dev/null +++ b/photos/migrations/0002_importjob_picker_session_id_alter_importjob_status_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.17 on 2026-02-05 17:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('photos', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='importjob', + name='picker_session_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='importjob', + name='status', + field=models.CharField(choices=[('PICKING', 'Picking'), ('QUEUED', 'Queued'), ('RUNNING', 'Running'), ('DONE', 'Done'), ('FAILED', 'Failed')], default='QUEUED', max_length=20), + ), + migrations.AddIndex( + model_name='importjob', + index=models.Index(fields=['picker_session_id'], name='import_jobs_picker__ed9de7_idx'), + ), + ] diff --git a/photos/models.py b/photos/models.py index 71a8362..7fd5cc8 100644 --- a/photos/models.py +++ b/photos/models.py @@ -1,3 +1,68 @@ +# photos/models.py from django.db import models +from django.contrib.auth.models import User -# Create your models here. + +class GoogleCredential(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="google_credential") + + google_email = models.EmailField() + access_token = models.TextField() + refresh_token = models.TextField() + token_uri = models.CharField(max_length=255) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + scopes = models.JSONField() + + is_active = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "google_credentials" + + def __str__(self): + return f"{self.user.username} - {self.google_email}" + + +class ImportJob(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="import_jobs") + + job_id = models.CharField(max_length=100, unique=True) + + STATUS_CHOICES = [ + ("PICKING", "Picking"), + ("QUEUED", "Queued"), + ("RUNNING", "Running"), + ("DONE", "Done"), + ("FAILED", "Failed"), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="QUEUED") + source = models.CharField(max_length=20, default="GOOGLE") + folder_id = models.CharField(max_length=255, null=True, blank=True) + + picker_session_id = models.CharField(max_length=255, null=True, blank=True) + + total_count = models.IntegerField(default=0) + done_count = models.IntegerField(default=0) + skipped_count = models.IntegerField(default=0) + failed_count = models.IntegerField(default=0) + + error_message = models.TextField(null=True, blank=True) + + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "import_jobs" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["user", "status"]), + models.Index(fields=["picker_session_id"]), + ] + + def __str__(self): + return f"Job {self.job_id} - {self.status}" diff --git a/photos/serializers/__init__.py b/photos/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photos/serializers/google.py b/photos/serializers/google.py new file mode 100644 index 0000000..63a2188 --- /dev/null +++ b/photos/serializers/google.py @@ -0,0 +1,25 @@ +# photos/serializers/google.py +from rest_framework import serializers + +class GoogleCallbackSerializer(serializers.Serializer): + """구글 OAuth 콜백 요청""" + code = serializers.CharField(required=True) + redirectUri = serializers.CharField(required=True) + +class GoogleStatusResponseSerializer(serializers.Serializer): + """구글 연동 상태 응답""" + connected = serializers.BooleanField() + googleEmail = serializers.EmailField(required=False) + +class GoogleAuthorizeResponseSerializer(serializers.Serializer): + """구글 연동 URL 응답""" + authUrl = serializers.CharField() + +class GoogleCallbackResponseSerializer(serializers.Serializer): + """구글 콜백 완료 응답""" + connected = serializers.BooleanField() + googleEmail = serializers.EmailField() + +class GoogleUnlinkResponseSerializer(serializers.Serializer): + """구글 연동 해제 응답""" + unlinked = serializers.BooleanField() \ No newline at end of file diff --git a/photos/serializers/photo.py b/photos/serializers/photo.py new file mode 100644 index 0000000..653c790 --- /dev/null +++ b/photos/serializers/photo.py @@ -0,0 +1,55 @@ +# photos/serializers/photo.py +from rest_framework import serializers +from gallery.models import Photo + +class ImportGoogleRequestSerializer(serializers.Serializer): + """구글 Import 요청""" + folderId = serializers.CharField(required=False, allow_null=True) + dedupe = serializers.BooleanField(default=True) + +class ImportJobStatusResponseSerializer(serializers.Serializer): + """Import Job 상태 응답""" + jobId = serializers.CharField() + status = serializers.CharField() + progress = serializers.DictField() + +class ImportJobStartResponseSerializer(serializers.Serializer): + """Import Job 시작 응답""" + jobId = serializers.CharField() + status = serializers.CharField() + +class PhotoSerializer(serializers.ModelSerializer): + class Meta: + model = Photo + fields = [ + "id", + "filename", + "url", + "file_size", + "file_hash", + "phash", + "dhash", + "ahash", + "category", + "source", + "google_id", + "memo", + "width", + "height", + "taken_at", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", "url", "file_size", + "file_hash", "phash", "dhash", "ahash", + "source", "google_id", + "width", "height", "taken_at", + "created_at", "updated_at", + ] + + +class PhotoUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Photo + fields = ["category", "memo"] \ No newline at end of file diff --git a/photos/serializers/response.py b/photos/serializers/response.py new file mode 100644 index 0000000..8200721 --- /dev/null +++ b/photos/serializers/response.py @@ -0,0 +1,27 @@ +# photos/serializers/response.py +from rest_framework import serializers +from typing import TypeVar, Generic, Optional + +T = TypeVar('T') + +class APIResponse: + """공통 응답 포맷""" + + @staticmethod + def success(data): + """성공 응답""" + return { + "success": True, + "data": data + } + + @staticmethod + def error(code: str, message: str): + """실패 응답""" + return { + "success": False, + "error": { + "code": code, + "message": message + } + } \ No newline at end of file diff --git a/photos/services/__init__.py b/photos/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photos/services/deduplication_service.py b/photos/services/deduplication_service.py new file mode 100644 index 0000000..3248a24 --- /dev/null +++ b/photos/services/deduplication_service.py @@ -0,0 +1,61 @@ +from typing import List, Optional +# from photos.models import Photo +from gallery.models import Photo + + +class DeduplicationService: + """ + 완전히 동일한 이미지(SHA-256 동일)만 중복으로 판단 + """ + + @staticmethod + def find_exact_duplicates( + user, + *, + file_hash: str, + exclude_photo_id: Optional[int] = None, + ) -> List[Photo]: + """ + 같은 user + 같은 file_hash를 가진 사진 조회 + """ + qs = Photo.objects.filter( + user=user, + file_hash=file_hash, + is_deleted=False, + ) + + if exclude_photo_id is not None: + qs = qs.exclude(id=exclude_photo_id) + + return list(qs.order_by("created_at")) + + @staticmethod + def mark_exact_duplicate(photo: Photo, original: Photo) -> None: + """ + photo를 original의 exact duplicate로 표시 + """ + photo.duplicate_of = original + photo.save(update_fields=["duplicate_of"]) + + @staticmethod + def check_exact_duplicate_and_mark( + user, + *, + photo: Photo, + ) -> Optional[Photo]: + """ + photo 기준으로 exact duplicate 검사 후, + 있으면 DB에 duplicate_of 저장 + """ + exact = DeduplicationService.find_exact_duplicates( + user, + file_hash=photo.file_hash, + exclude_photo_id=photo.id, + ) + + if exact: + original = exact[0] + DeduplicationService.mark_exact_duplicate(photo, original) + return original + + return None diff --git a/photos/services/google_photos_service.py b/photos/services/google_photos_service.py new file mode 100644 index 0000000..280aebb --- /dev/null +++ b/photos/services/google_photos_service.py @@ -0,0 +1,210 @@ +# photos/services/google_photos_service.py +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build +import os +import requests + + +class GooglePhotosService: + """Google Photos Picker API 연동 서비스""" + + SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", + ] + + PICKER_BASE_URL = "https://photospicker.googleapis.com/v1" + + def __init__(self, google_credential=None): + """ + Args: + google_credential: GoogleCredential 모델 인스턴스 + """ + self.google_credential = google_credential + self.credentials = None + + if google_credential: + self.credentials = self._build_credentials(google_credential) + + def _build_credentials(self, google_credential): + """GoogleCredential 모델로부터 Credentials 객체 생성""" + return Credentials( + token=google_credential.access_token, + refresh_token=google_credential.refresh_token, + token_uri=google_credential.token_uri, + client_id=google_credential.client_id, + client_secret=google_credential.client_secret, + scopes=google_credential.scopes, + ) + + def _auth_headers(self): + if not self.credentials or not self.credentials.token: + raise ValueError("Google credentials not set") + return {"Authorization": f"Bearer {self.credentials.token}"} + + @staticmethod + def get_authorization_url(redirect_uri): + """OAuth 인증 URL 생성""" + client_config = { + "web": { + "client_id": os.getenv("GOOGLE_PHOTOS_CLIENT_ID"), + "client_secret": os.getenv("GOOGLE_PHOTOS_CLIENT_SECRET"), + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": [redirect_uri], + } + } + + flow = Flow.from_client_config( + client_config, + scopes=GooglePhotosService.SCOPES, + redirect_uri=redirect_uri, + ) + + auth_url, state = flow.authorization_url( + access_type="offline", + include_granted_scopes="true", + prompt="consent", + ) + + return auth_url, state + + @staticmethod + def exchange_code_for_tokens(code, redirect_uri): + """인증 코드를 토큰으로 교환""" + client_config = { + "web": { + "client_id": os.getenv("GOOGLE_PHOTOS_CLIENT_ID"), + "client_secret": os.getenv("GOOGLE_PHOTOS_CLIENT_SECRET"), + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": [redirect_uri], + } + } + + flow = Flow.from_client_config( + client_config, + scopes=GooglePhotosService.SCOPES, + redirect_uri=redirect_uri, + ) + + flow.fetch_token(code=code) + credentials = flow.credentials + + service = build("oauth2", "v2", credentials=credentials) + user_info = service.userinfo().get().execute() + + return { + "access_token": credentials.token, + "refresh_token": credentials.refresh_token, + "token_uri": credentials.token_uri, + "client_id": credentials.client_id, + "client_secret": credentials.client_secret, + "scopes": credentials.scopes, + "google_email": user_info.get("email"), + } + + # ------------------------- + # Picker API 핵심 플로우 + # ------------------------- + + def create_picker_session(self): + """ + Picker 세션 생성 + Returns: + { "sessionId": "...", "pickerUri": "...", "raw": } + """ + url = f"{self.PICKER_BASE_URL}/sessions" + resp = requests.post(url, headers=self._auth_headers(), json={}) + resp.raise_for_status() + data = resp.json() + + return { + "sessionId": data.get("id"), + "pickerUri": data.get("pickerUri"), + "raw": data, + } + + def get_picker_session(self, session_id): + """ + 세션 상태 조회 (프론트 폴링용) + """ + if not session_id: + raise ValueError("session_id is required") + + url = f"{self.PICKER_BASE_URL}/sessions/{session_id}" + resp = requests.get(url, headers=self._auth_headers()) + resp.raise_for_status() + return resp.json() + + def list_picked_media_items(self, session_id, page_size=100, page_token=None): + """ + 유저가 Picker에서 선택한 미디어 아이템 목록 조회 + """ + if not session_id: + raise ValueError("session_id is required") + + url = f"{self.PICKER_BASE_URL}/mediaItems" + params = {"sessionId": session_id, "pageSize": page_size} + if page_token: + params["pageToken"] = page_token + + resp = requests.get(url, headers=self._auth_headers(), params=params) + resp.raise_for_status() + data = resp.json() + + return { + "items": data.get("mediaItems", []), + "nextPageToken": data.get("nextPageToken"), + } + + # ------------------------- + # 기존 이름 유지용 (호환) + # ------------------------- + + def get_photos(self, page_size=100, page_token=None, session_id=None): + """ + (호환용) 예전에는 라이브러리 전체를 가져왔지만, + 이제는 Picker 세션에서 선택된 항목만 가져올 수 있음. + """ + return self.list_picked_media_items( + session_id=session_id, + page_size=page_size, + page_token=page_token, + ) + + def get_photos_since(self, since_date, page_size=100): + """ + Picker API에서는 "특정 날짜 이후 전체 사진" 같은 필터링 불가. + 이 로직 쓰는 곳 있으면 구조를 바꿔야 함. + """ + raise NotImplementedError( + "Google Photos Picker API에서는 날짜 기반 전체 조회(get_photos_since)를 지원하지 않습니다. " + "사용자가 Picker에서 선택한 항목만 가져올 수 있습니다." + ) + + def download_photo(self, media_item, save_path): + """ + 사진 다운로드 + - Library API의 baseUrl 형태도, Picker API의 mediaFile.baseUrl 형태도 둘 다 대응 + """ + base_url = media_item.get("baseUrl") + if not base_url: + media_file = media_item.get("mediaFile") or {} + base_url = media_file.get("baseUrl") + + if not base_url: + raise ValueError("No baseUrl in media item") + + download_url = f"{base_url}=d" + + resp = requests.get(download_url, headers=self._auth_headers(), timeout=60) + resp.raise_for_status() + + with open(save_path, "wb") as f: + f.write(resp.content) + + return save_path diff --git a/photos/services/import_service.py b/photos/services/import_service.py new file mode 100644 index 0000000..8556265 --- /dev/null +++ b/photos/services/import_service.py @@ -0,0 +1,39 @@ +# photos/services/import_service.py +import uuid +from datetime import datetime + + +class ImportService: + @staticmethod + def generate_job_id(): + return f"imp_{uuid.uuid4().hex[:12]}" + + @staticmethod + def calculate_progress(job): + total = job.total_count or 0 + done = job.done_count or 0 + skipped = job.skipped_count or 0 + failed = job.failed_count or 0 + + return { + "total": total, + "done": done, + "skipped": skipped, + "failed": failed, + } + + @staticmethod + def update_job_status(job, status, error_message=None): + job.status = status + + if status == "RUNNING" and not job.started_at: + job.started_at = datetime.now() + + if status in ["DONE", "FAILED"]: + job.completed_at = datetime.now() + + if error_message: + job.error_message = error_message + + job.save() + return job diff --git a/photos/services/upload_service.py b/photos/services/upload_service.py new file mode 100644 index 0000000..513fcfd --- /dev/null +++ b/photos/services/upload_service.py @@ -0,0 +1,179 @@ +# photos/services/upload_service.py + +import os +import uuid +import hashlib +from datetime import datetime +from typing import Dict + +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile + +from PIL import Image, ExifTags +import imagehash + + +# ========================= +# Path Utils +# ========================= + +def _ensure_dir(path: str): + """디렉토리 없으면 생성""" + os.makedirs(path, exist_ok=True) + + +def _user_storage_paths(user_id: int) -> Dict[str, str]: + """ + 사용자별 저장 경로 반환 + """ + base = settings.MEDIA_ROOT + return { + "temp": os.path.join(base, "temp", str(user_id)), + "photo": os.path.join(base, "photos", str(user_id)), + "thumb": os.path.join(base, "thumbnails", str(user_id)), + } + + +# ========================= +# Hash Utils +# ========================= + +def _calculate_sha256(file_path: str) -> str: + """SHA-256 해시 계산 (Exact duplicate)""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + +def _calculate_image_hashes(image: Image.Image) -> Dict[str, str]: + """pHash / dHash / aHash 계산""" + return { + "phash": str(imagehash.phash(image)), + "dhash": str(imagehash.dhash(image)), + "ahash": str(imagehash.average_hash(image)), + } + + +# ========================= +# Image Utils +# ========================= + +def _extract_exif_taken_at(image: Image.Image): + """EXIF 촬영 시간 추출""" + try: + exif = image._getexif() + if not exif: + return None + + for tag, value in exif.items(): + tag_name = ExifTags.TAGS.get(tag) + if tag_name == "DateTimeOriginal": + return datetime.strptime(value, "%Y:%m:%d %H:%M:%S") + except Exception: + return None + + return None + + +def _create_thumbnail(image: Image.Image, size=(300, 300)) -> Image.Image: + """썸네일 생성""" + thumb = image.copy() + thumb.thumbnail(size) + return thumb + + +# ========================= +# Main Service +# ========================= + +def save_uploaded_file(user, uploaded_file: UploadedFile) -> Dict: + """ + 업로드된 이미지 파일 저장 및 메타데이터 추출 + + return: + { + filename, + url, + thumb_url, + file_size, + width, + height, + taken_at, + file_hash, + phash, + dhash, + ahash, + } + """ + + user_id = user.id + paths = _user_storage_paths(user_id) + + for p in paths.values(): + _ensure_dir(p) + + # 파일명 정리 (충돌 방지) + original_name = uploaded_file.name + ext = os.path.splitext(original_name)[1].lower() + unique_name = f"{uuid.uuid4().hex}{ext}" + + temp_path = os.path.join(paths["temp"], unique_name) + final_path = os.path.join(paths["photo"], unique_name) + thumb_path = os.path.join(paths["thumb"], unique_name) + + # 임시 저장 + with open(temp_path, "wb+") as f: + for chunk in uploaded_file.chunks(): + f.write(chunk) + + # 이미지 로드 + image = Image.open(temp_path) + image = image.convert("RGB") # 포맷 통일 + + width, height = image.size + file_size = uploaded_file.size + + # 해시 계산 + file_hash = _calculate_sha256(temp_path) + image_hashes = _calculate_image_hashes(image) + + # 메타데이터 + taken_at = _extract_exif_taken_at(image) + + # 원본 저장 + image.save(final_path, format="JPEG", quality=95) + + # 썸네일 생성 + thumbnail = _create_thumbnail(image) + thumbnail.save(thumb_path, format="JPEG", quality=85) + + # temp 파일 삭제 + try: + os.remove(temp_path) + except OSError: + pass + + # URL 구성 + photo_url = f"{settings.MEDIA_URL}photos/{user_id}/{unique_name}" + thumb_url = f"{settings.MEDIA_URL}thumbnails/{user_id}/{unique_name}" + + return { + "filename": original_name, + "url": photo_url, + "thumb_url": thumb_url, + "file_size": file_size, + "width": width, + "height": height, + "taken_at": taken_at, + "file_hash": file_hash, + "phash": image_hashes["phash"], + "dhash": image_hashes["dhash"], + "ahash": image_hashes["ahash"], + + + # 내부 처리용(중복 시 파일 정리 등). 외부 응답에는 쓰지 않아도 됨. + "_final_path": final_path, + "_thumb_path": thumb_path, + } diff --git a/photos/tasks/__init__.py b/photos/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/photos/tasks/import_photos.py b/photos/tasks/import_photos.py new file mode 100644 index 0000000..1954329 --- /dev/null +++ b/photos/tasks/import_photos.py @@ -0,0 +1,117 @@ +# photos/tasks/import_photos.py +from celery import shared_task +from django.contrib.auth.models import User +from django.conf import settings +from photos.models import GoogleCredential, ImportJob +from gallery.models import Photo, Category +from photos.services.google_photos_service import GooglePhotosService +from photos.services.import_service import ImportService +import os + + +@shared_task(bind=True) +def import_google_photos_task(self, user_id, job_id, folder_id=None, dedupe=True, session_id=None): + try: + user = User.objects.get(id=user_id) + job = ImportJob.objects.get(job_id=job_id) + + if not session_id: + if hasattr(job, "picker_session_id") and job.picker_session_id: + session_id = job.picker_session_id + + if not session_id: + raise ValueError("session_id is required for Google Photos Picker import") + + ImportService.update_job_status(job, "RUNNING") + + google_cred = GoogleCredential.objects.get(user=user, is_active=True) + + unclassified_category, _ = Category.objects.get_or_create( + user=user, + name="분류 전", + defaults={"category_key": None, "parent": None}, + ) + + google_service = GooglePhotosService(google_cred) + + page_token = None + all_items = [] + + while True: + result = google_service.get_photos( + page_size=100, + page_token=page_token, + session_id=session_id, + ) + items = result.get("items", []) + all_items.extend(items) + + page_token = result.get("nextPageToken") + if not page_token: + break + + job.total_count = len(all_items) + job.save(update_fields=["total_count"]) + + for media_item in all_items: + try: + google_id = media_item.get("id") + media_file = media_item.get("mediaFile") or {} + filename = media_file.get("filename") or media_item.get("filename") or f"photo_{google_id}.jpg" + + if dedupe and google_id: + exists = Photo.objects.filter( + user=user, + google_id=google_id, + is_deleted=False, + ).exists() + if exists: + job.skipped_count += 1 + job.save(update_fields=["skipped_count"]) + continue + + media_subdir = f"photos/{user.id}" + relative_path = f"{media_subdir}/{filename}" + + abs_dir = os.path.join(settings.MEDIA_ROOT, media_subdir) + os.makedirs(abs_dir, exist_ok=True) + + abs_path = os.path.join(settings.MEDIA_ROOT, relative_path) + + google_service.download_photo(media_item, abs_path) + + Photo.objects.create( + user=user, + filename=filename, + image=relative_path, + url=relative_path, + file_hash="", + phash="", + dhash="", + source="GOOGLE", + google_id=google_id, + category=unclassified_category, + ) + + job.done_count += 1 + job.save(update_fields=["done_count"]) + + except Exception as e: + job.failed_count += 1 + job.save(update_fields=["failed_count"]) + print(f"Failed to import photo {media_item.get('id')}: {e}") + continue + + ImportService.update_job_status(job, "DONE") + + return { + "total": job.total_count, + "done": job.done_count, + "skipped": job.skipped_count, + "failed": job.failed_count, + } + + except Exception as e: + job = ImportJob.objects.get(job_id=job_id) + ImportService.update_job_status(job, "FAILED", error_message=str(e)) + raise diff --git a/photos/urls.py b/photos/urls.py new file mode 100644 index 0000000..ceb2682 --- /dev/null +++ b/photos/urls.py @@ -0,0 +1,43 @@ +# photos/urls.py +from django.urls import path +from photos.api import google, upload, import_job, photos, dedupe + +app_name = "photos" + +urlpatterns = [ + # 구글 연동 + path("google/status", google.google_status, name="google-status"), + path("google/authorize", google.google_authorize, name="google-authorize"), + path("google/callback/", google.google_callback, name="google-callback"), + path("google/unlink", google.google_unlink, name="google-unlink"), + + # 직접 업로드 + path("upload", upload.upload_photos, name="upload-photos"), + + # Google Photos Import (Picker 기반) + path("import/google", import_job.import_from_google, name="import-google"), + path( + "import/google/session/", + import_job.get_picker_session_status, + name="import-google-session-status", + ), + path( + "import/google/confirm", + import_job.confirm_google_picker_selection, + name="import-google-confirm", + ), + path( + "import/jobs/", + import_job.get_import_job_status, + name="import-job-status", + ), + + # 사진 CRUD + path("", photos.list_photos, name="list-photos"), + path("", photos.get_photo_detail, name="photo-detail"), + path("/update", photos.update_photo, name="update-photo"), + path("/delete", photos.delete_photo, name="delete-photo"), + + # 중복 제거 + path("dedupe/check", dedupe.check_duplicates, name="dedupe-check"), +] diff --git a/photos/views.py b/photos/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/photos/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/requirements.txt b/requirements.txt index e69de29..ca4df6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,58 @@ +amqp==5.3.1 +asgiref==3.11.0 +async-timeout==5.0.1 +billiard==4.2.4 +celery==5.6.2 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +cryptography==46.0.4 +Django==4.2.17 +django-allauth==65.14.0 +django-cors-headers==4.9.0 +django-csp==4.0 +djangorestframework==3.16.1 +exceptiongroup==1.3.1 +google-api-core==2.29.0 +google-api-python-client==2.188.0 +google-auth==2.48.0 +google-auth-httplib2==0.3.0 +google-auth-oauthlib==1.2.4 +googleapis-common-protos==1.72.0 +httplib2==0.31.2 +idna==3.11 +ImageHash==4.3.2 +kombu==5.6.2 +numpy==2.2.6 +oauthlib==3.3.1 +pillow==12.1.0 +prompt_toolkit==3.0.52 +proto-plus==1.27.0 +protobuf==6.33.5 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycparser==3.0 +PyJWT==2.10.1 +pyparsing==3.3.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +PyWavelets==1.8.0 +redis==7.1.0 +requests==2.32.5 +requests-oauthlib==2.0.0 +rsa==4.9.1 +scipy==1.15.3 +six==1.17.0 +sqlparse==0.5.5 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +uritemplate==4.2.0 +urllib3==2.6.3 +vine==5.1.0 +wcwidth==0.5.2