diff --git a/.gitignore b/.gitignore index 32abb73..02864f4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ venv/ db.sqlite3 # 미디어 및 정적 파일 폴더 media/ -static/ +# static/ # VS Code 설정 .vscode/ # 보안 및 환경 설정 파일 (추가) diff --git a/accounts/templates/accounts/mypage.html b/accounts/templates/accounts/mypage.html new file mode 100644 index 0000000..5afdc81 --- /dev/null +++ b/accounts/templates/accounts/mypage.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block content %} +

👤 마이페이지

+
+

이름: {{ user.username }}

+

이메일: {{ user.email }}

+

가입일: {{ 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/settings.py b/config/settings.py index fab9979..4cb139f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -37,12 +37,25 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + 'accounts', 'photos', 'classification', 'gallery', ] +SITE_ID = 1 + +# 로그인 후 리다이렉트 경로 +LOGIN_REDIRECT_URL = '/' +ACCOUNT_LOGOUT_REDIRECT_URL = '/' + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -51,6 +64,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 +80,7 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'gallery.context_processors.notification_context', ], }, }, @@ -125,3 +140,18 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +import os +from dotenv import load_dotenv +load_dotenv() + +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') +GOOGLE_SECRET = os.getenv('GOOGLE_SECRET') + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') \ No newline at end of file diff --git a/config/urls.py b/config/urls.py index 5006338..4befe74 100644 --- a/config/urls.py +++ b/config/urls.py @@ -15,8 +15,16 @@ 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.conf import settings +from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), + path('accounts/', include('accounts.urls')), + path('accounts/', include('allauth.urls')), + path('gallery/', include('gallery.urls')), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ 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..01b47c3 --- /dev/null +++ b/gallery/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.17 on 2026-01-29 16:49 + +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='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/')), + ('image_hash', models.CharField(max_length=64, unique=True)), + ('source', models.CharField(choices=[('direct', 'Direct'), ('google', 'Google Drive')], max_length=20)), + ('category_main', models.CharField(default='기타', max_length=50)), + ('category_sub', models.CharField(blank=True, max_length=50, null=True)), + ('is_confirmed', models.BooleanField(default=False)), + ('memo', models.TextField(blank=True, null=True)), + ('is_bookmarked', models.BooleanField(default=False)), + ('is_trashed', models.BooleanField(default=False)), + ('trashed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to=settings.AUTH_USER_MODEL)), + ], + ), + 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)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/gallery/migrations/0002_remove_photo_category_main_remove_photo_category_sub_and_more.py b/gallery/migrations/0002_remove_photo_category_main_remove_photo_category_sub_and_more.py new file mode 100644 index 0000000..2e8f20a --- /dev/null +++ b/gallery/migrations/0002_remove_photo_category_main_remove_photo_category_sub_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.17 on 2026-01-30 06:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gallery', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='photo', + name='category_main', + ), + migrations.RemoveField( + model_name='photo', + name='category_sub', + ), + migrations.AddField( + model_name='notification', + name='photo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reminders', to='gallery.photo'), + ), + migrations.AddField( + model_name='notification', + name='remind_at', + field=models.DateTimeField(blank=True, null=True), + ), + 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', '학습/노트'), ('shopping', '쇼핑 정보'), ('schedule', '일정/예약'), ('document', '문서/정보'), ('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.AddField( + model_name='photo', + name='category', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to='gallery.category'), + ), + ] diff --git a/gallery/models.py b/gallery/models.py index 71a8362..5f357f4 100644 --- a/gallery/models.py +++ b/gallery/models.py @@ -1,3 +1,85 @@ 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', '학습/노트'), + ('shopping', '쇼핑 정보'), + ('schedule', '일정/예약'), + ('document', '문서/정보'), + ('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='photos') + image = models.ImageField(upload_to='photos/%Y/%m/%d/') + image_hash = models.CharField(max_length=64, unique=True) + source = models.CharField(max_length=20, choices=[('direct', 'Direct'), ('google', 'Google Drive')]) + + # 새로 정의한 Category 모델 연결 + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='photos') + + # 기존 문자열 필드는 데이터 마이그레이션 후 삭제해도 무방합니다. + is_confirmed = models.BooleanField(default=False) + memo = models.TextField(blank=True, null=True) + is_bookmarked = models.BooleanField(default=False) + + is_trashed = models.BooleanField(default=False) + trashed_at = models.DateTimeField(blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"[{self.user.username}] {self.id} ({self.category.name if self.category else '미분류'})" + + @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..00bfcfd --- /dev/null +++ b/gallery/signals.py @@ -0,0 +1,24 @@ +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: + # 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/js/photo_detail.js b/gallery/static/js/photo_detail.js new file mode 100644 index 0000000..906903a --- /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..e222491 --- /dev/null +++ b/gallery/templates/base.html @@ -0,0 +1,47 @@ + + + + + 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..06254fb --- /dev/null +++ b/gallery/templates/gallery/photo_detail.html @@ -0,0 +1,46 @@ +{% 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..ba70d0b --- /dev/null +++ b/gallery/templates/gallery/photo_list.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} + +{% load static %} {% block content %} +

📸 나의 Re:Capture 갤러리

+ + + 🗑️ 휴지통 보기 + + +
+ 전체 + + {% for cat in categories %} + + {{ cat.name }} + + {% endfor %} + + | + + {% if is_bookmarked %} + + ⭐ 북마크만 보기 (ON) + + {% else %} + + ☆ 북마크만 보기 (OFF) + + {% endif %} +
+ +{% if current_category %} +
+ 📁 세부 폴더: + + 전체 + + + {% for sub in sub_categories %} + + #{{ sub.name }} + + {% endfor %} + +
+{% endif %} + + + + +{% endblock %} \ No newline at end of file diff --git a/gallery/templates/gallery/trash_list.html b/gallery/templates/gallery/trash_list.html new file mode 100644 index 0000000..c3d3cb5 --- /dev/null +++ b/gallery/templates/gallery/trash_list.html @@ -0,0 +1,23 @@ +{% 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..7eb6400 --- /dev/null +++ b/gallery/urls.py @@ -0,0 +1,30 @@ +from django.urls import path +from . import views + +app_name = 'gallery' + +urlpatterns = [ + # 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..ea7e172 --- /dev/null +++ b/gallery/views/base_views.py @@ -0,0 +1,42 @@ +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 +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 = [] + + # 1. 카테고리 필터 적용 + if category_id: + photos = photos.filter(category_id=category_id) + sub_categories = Category.objects.filter(user=request.user, parent_id=category_id) + + 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, + 'is_bookmarked': is_bookmarked == 'true' + }) + +@login_required +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/2026/01/30/32c86164332926226cd8b36bd83c231d.jpg b/photos/2026/01/30/32c86164332926226cd8b36bd83c231d.jpg new file mode 100644 index 0000000..751c378 Binary files /dev/null and b/photos/2026/01/30/32c86164332926226cd8b36bd83c231d.jpg differ diff --git a/requirements.txt b/requirements.txt index e69de29..620f22d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,9 @@ +Django==4.2.17 +django-allauth==65.14.0 +python-dotenv==1.0.1 +requests==2.32.5 +pillow==12.0.0 +PyJWT==2.10.1 +cryptography==44.0.0 +asgiref==3.11.0 +sqlparse==0.5.5 \ No newline at end of file