diff --git a/.env b/.env new file mode 100644 index 0000000..bd96598 --- /dev/null +++ b/.env @@ -0,0 +1,18 @@ +# 네이버 클라우드 플랫폼 SMS 설정 +# 승인받은 발신번호로 설정 + +# Access Key ID +NAVER_CLOUD_ACCESS_KEY=ncp_iam_BPAMKR1m30ZhNpesC6mm + +# Secret Key +NAVER_CLOUD_SECRET_KEY=ncp_iam_BPKMKREe9zWcD1Z0Pp9B4OIZSWZmo51Sdu + +# SMS 서비스 ID +NAVER_CLOUD_SMS_SERVICE_ID=ncp:sms:kr:335843392196:silla_amp + +# 승인받은 발신번호 (여기에 실제 승인된 번호 입력) +NAVER_CLOUD_SMS_SENDER_PHONE=01033433319 + +# SMS 인증 설정 +SMS_VERIFICATION_TIMEOUT=180 +SMS_MAX_RETRY_COUNT=3 \ No newline at end of file diff --git a/A_core/__pycache__/settings.cpython-313.pyc b/A_core/__pycache__/settings.cpython-313.pyc index deef499..4eb94e9 100644 Binary files a/A_core/__pycache__/settings.cpython-313.pyc and b/A_core/__pycache__/settings.cpython-313.pyc differ diff --git a/A_core/__pycache__/settings.cpython-38.pyc b/A_core/__pycache__/settings.cpython-38.pyc index d0a8b74..7c1b3c8 100644 Binary files a/A_core/__pycache__/settings.cpython-38.pyc and b/A_core/__pycache__/settings.cpython-38.pyc differ diff --git a/A_core/__pycache__/sms_utils.cpython-313.pyc b/A_core/__pycache__/sms_utils.cpython-313.pyc new file mode 100644 index 0000000..e7c47dd Binary files /dev/null and b/A_core/__pycache__/sms_utils.cpython-313.pyc differ diff --git a/A_core/__pycache__/sms_utils.cpython-38.pyc b/A_core/__pycache__/sms_utils.cpython-38.pyc new file mode 100644 index 0000000..d6a3c07 Binary files /dev/null and b/A_core/__pycache__/sms_utils.cpython-38.pyc differ diff --git a/A_core/__pycache__/urls.cpython-38.pyc b/A_core/__pycache__/urls.cpython-38.pyc index 69ee8e6..2c9ac4e 100644 Binary files a/A_core/__pycache__/urls.cpython-38.pyc and b/A_core/__pycache__/urls.cpython-38.pyc differ diff --git a/A_core/__pycache__/views.cpython-38.pyc b/A_core/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000..e5e2893 Binary files /dev/null and b/A_core/__pycache__/views.cpython-38.pyc differ diff --git a/A_core/__pycache__/wsgi.cpython-313.pyc b/A_core/__pycache__/wsgi.cpython-313.pyc index 7fcfa0a..01f2518 100644 Binary files a/A_core/__pycache__/wsgi.cpython-313.pyc and b/A_core/__pycache__/wsgi.cpython-313.pyc differ diff --git a/A_core/settings.py b/A_core/settings.py index f901980..a17d5ea 100644 --- a/A_core/settings.py +++ b/A_core/settings.py @@ -11,10 +11,15 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ """ from pathlib import Path +import os +from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# 환경 변수 로드 +load_dotenv(BASE_DIR / '.env') + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ @@ -71,8 +76,10 @@ AUTHENTICATION_BACKENDS = [ 'allauth.account.auth_backends.AuthenticationBackend', ] +# 성능 최적화를 위한 미들웨어 순서 조정 MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'django.middleware.cache.UpdateCacheMiddleware', # 캐시 미들웨어 (상단) 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -81,6 +88,7 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'allauth.account.middleware.AccountMiddleware', 'C_accounts.middleware.ForcePasswordSetMiddleware', # 강제 비밀번호 설정 미들웨어 + 'django.middleware.cache.FetchFromCacheMiddleware', # 캐시 미들웨어 (하단) ] ROOT_URLCONF = 'A_core.urls' @@ -149,12 +157,18 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [ BASE_DIR / 'static', ] +# 정적 파일 찾기 설정 (프로덕션 환경) +STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +] + MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' @@ -164,17 +178,56 @@ MEDIA_ROOT = BASE_DIR / 'media' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -STATIC_URL = '/static/' - - # 로그인/회원가입 redirect 설정 LOGIN_REDIRECT_URL = '/' ACCOUNT_LOGOUT_REDIRECT_URL = '/accounts/login/' -# 전화번호로 로그인 (username 사용) -ACCOUNT_AUTHENTICATION_METHOD = 'username' # 'email' → 'username' +# 전화번호로 로그인 (username 사용) - 최신 allauth 설정 +ACCOUNT_LOGIN_METHODS = {'username'} # username으로 로그인 +ACCOUNT_SIGNUP_FIELDS = ['username', 'password1', 'password2'] # 회원가입 필드 ACCOUNT_USERNAME_REQUIRED = True # username 필드 사용 (전화번호) ACCOUNT_EMAIL_REQUIRED = False # email 필수 아님 ACCOUNT_USER_MODEL_USERNAME_FIELD = 'username' # 사용자 모델의 username 필드 활성화 +# 네이버 클라우드 플랫폼 SMS 설정 +NAVER_CLOUD_ACCESS_KEY = os.getenv('NAVER_CLOUD_ACCESS_KEY', 'your_access_key_here') +NAVER_CLOUD_SECRET_KEY = os.getenv('NAVER_CLOUD_SECRET_KEY', 'your_secret_key_here') +NAVER_CLOUD_SMS_SERVICE_ID = os.getenv('NAVER_CLOUD_SMS_SERVICE_ID', 'your_service_id_here') +NAVER_CLOUD_SMS_SENDER_PHONE = os.getenv('NAVER_CLOUD_SMS_SENDER_PHONE', 'your_sender_phone_here') # 발신번호 (예: 01012345678) + +# 세션 설정 - 성능 최적화 +SESSION_COOKIE_AGE = 1800 # 세션 만료 시간 (30분 = 1800초) +SESSION_EXPIRE_AT_BROWSER_CLOSE = True # 브라우저 닫으면 세션 만료 +SESSION_SAVE_EVERY_REQUEST = False # 성능 향상을 위해 매 요청마다 세션 저장 비활성화 +SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' # 세션 캐싱 활성화 + +# 캐시 설정 (성능 향상) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake', + 'TIMEOUT': 300, # 5분 캐시 + 'OPTIONS': { + 'MAX_ENTRIES': 1000, + 'CULL_FREQUENCY': 3, + } + } +} + +# SMS 인증 설정 +SMS_VERIFICATION_TIMEOUT = int(os.getenv('SMS_VERIFICATION_TIMEOUT', 180)) # 인증번호 유효시간 (초) +SMS_MAX_RETRY_COUNT = int(os.getenv('SMS_MAX_RETRY_COUNT', 3)) # 최대 재발송 횟수 + +# 이메일 설정 +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'cpabong79@gmail.com' +EMAIL_HOST_PASSWORD = 'wqol wsll vsrl jeqe' # Gmail 앱 비밀번호 필요 +DEFAULT_FROM_EMAIL = 'cpabong79@gmail.com' + +# 개발용 - 콘솔 출력 (테스트 시 사용) +# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + diff --git a/A_core/sms_utils.py b/A_core/sms_utils.py new file mode 100644 index 0000000..c3dec49 --- /dev/null +++ b/A_core/sms_utils.py @@ -0,0 +1,166 @@ +import requests +import json +import time +import hashlib +import hmac +import base64 +from django.conf import settings +from typing import Dict, Any, Optional + +class NaverCloudSMS: + """네이버 클라우드 플랫폼 SMS 서비스 클래스""" + + def __init__(self): + self.access_key = getattr(settings, 'NAVER_CLOUD_ACCESS_KEY', '') + self.secret_key = getattr(settings, 'NAVER_CLOUD_SECRET_KEY', '') + self.service_id = getattr(settings, 'NAVER_CLOUD_SMS_SERVICE_ID', '') + self.sender_phone = getattr(settings, 'NAVER_CLOUD_SMS_SENDER_PHONE', '') + + # API 엔드포인트 + self.base_url = "https://sens.apigw.ntruss.com" + self.sms_url = f"{self.base_url}/sms/v2/services/{self.service_id}/messages" + + def _make_signature(self, timestamp: str) -> str: + """네이버 클라우드 API 서명 생성""" + space = " " + new_line = "\n" + method = "POST" + url = f"/sms/v2/services/{self.service_id}/messages" + + message = method + space + url + new_line + timestamp + new_line + self.access_key + message = message.encode('utf-8') + + signing_key = base64.b64encode( + hmac.new( + self.secret_key.encode('utf-8'), + message, + digestmod=hashlib.sha256 + ).digest() + ).decode('utf-8') + + return signing_key + + def send_sms(self, phone_number: str, message: str) -> Dict[str, Any]: + """SMS 발송""" + try: + timestamp = str(int(time.time() * 1000)) + signature = self._make_signature(timestamp) + + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'x-ncp-apigw-timestamp': timestamp, + 'x-ncp-iam-access-key': self.access_key, + 'x-ncp-apigw-signature-v2': signature + } + + data = { + 'type': 'SMS', + 'contentType': 'COMM', + 'countryCode': '82', + 'from': self.sender_phone, + 'content': message, + 'messages': [ + { + 'to': phone_number + } + ] + } + + response = requests.post( + self.sms_url, + headers=headers, + data=json.dumps(data) + ) + + if response.status_code == 202: + result = response.json() + return { + 'success': True, + 'request_id': result.get('requestId'), + 'status_code': result.get('statusCode'), + 'status_name': result.get('statusName') + } + else: + return { + 'success': False, + 'error': f'HTTP {response.status_code}: {response.text}' + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def send_verification_code(self, phone_number: str, verification_code: str) -> Dict[str, Any]: + """인증번호 SMS 발송""" + message = f"[신라AMP] 인증번호는 [{verification_code}] 입니다. 3분 이내에 입력해주세요." + + # 발신번호가 설정되지 않은 경우 에러 반환 + if not self.sender_phone: + print(f"[ERROR] 발신번호가 설정되지 않았습니다.") + return { + 'success': False, + 'error': '발신번호가 설정되지 않았습니다. .env 파일을 확인해주세요.' + } + + print(f"[INFO] 실제 SMS 발송 시도: {phone_number} - {verification_code}") + return self.send_sms(phone_number, message) + + def send_withdrawal_approval_sms(self, phone_number: str, name: str) -> Dict[str, Any]: + """탈퇴 승인 SMS 발송""" + message = f"[신라AMP] {name}님의 회원탈퇴 요청이 처리되었습니다." + + if not self.sender_phone: + print(f"[ERROR] 발신번호가 설정되지 않았습니다.") + return { + 'success': False, + 'error': '발신번호가 설정되지 않았습니다.' + } + + print(f"[INFO] 탈퇴 승인 SMS 발송: {phone_number} - {name}") + return self.send_sms(phone_number, message) + + def send_withdrawal_rejection_sms(self, phone_number: str, name: str, reason: str = None) -> Dict[str, Any]: + """탈퇴 거부 SMS 발송""" + if reason: + # message = f"[신라AMP] {name}님의 회원탈퇴 요청이 거부되었습니다. 사유: {reason}" + message = f"[신라AMP] {name}님의 회원탈퇴 요청이 거부되었습니다. 자세한 내용은 관리자에게 문의해주세요." + else: + message = f"[신라AMP] {name}님의 회원탈퇴 요청이 거부되었습니다. 자세한 내용은 관리자에게 문의해주세요." + + if not self.sender_phone: + print(f"[ERROR] 발신번호가 설정되지 않았습니다.") + return { + 'success': False, + 'error': '발신번호가 설정되지 않았습니다.' + } + + print(f"[INFO] 탈퇴 거부 SMS 발송: {phone_number} - {name}") + return self.send_sms(phone_number, message) + +# 전역 인스턴스 +sms_service = NaverCloudSMS() + +def send_verification_sms(phone_number: str, verification_code: str) -> Dict[str, Any]: + """인증번호 SMS 발송 함수 (편의 함수)""" + # 실제 SMS 발송 시도 + print(f"[DEBUG] SMS 발송 시도: {phone_number} - {verification_code}") + print(f"[DEBUG] Access Key: {sms_service.access_key}") + print(f"[DEBUG] Service ID: {sms_service.service_id}") + print(f"[DEBUG] Sender Phone: {sms_service.sender_phone}") + + result = sms_service.send_verification_code(phone_number, verification_code) + print(f"[DEBUG] SMS 발송 결과: {result}") + + return result + + +def send_withdrawal_approval_sms(phone_number: str, name: str) -> Dict[str, Any]: + """탈퇴 승인 SMS 발송 함수 (편의 함수)""" + return sms_service.send_withdrawal_approval_sms(phone_number, name) + + +def send_withdrawal_rejection_sms(phone_number: str, name: str, reason: str = None) -> Dict[str, Any]: + """탈퇴 거부 SMS 발송 함수 (편의 함수)""" + return sms_service.send_withdrawal_rejection_sms(phone_number, name, reason) diff --git a/A_core/urls.py b/A_core/urls.py index 961af29..d156675 100644 --- a/A_core/urls.py +++ b/A_core/urls.py @@ -23,7 +23,7 @@ from django.views.static import serve urlpatterns = [ - path('admin/', admin.site.urls), + path('kmobsk/', admin.site.urls), # allauth 비밀번호 재설정을 커스텀 시스템으로 리다이렉트 path('accounts/password/reset/', RedirectView.as_view(url='/accounts/password_reset/', permanent=False), name='account_reset_password'), path('accounts/', include('allauth.urls')), # allauth 기본 URL @@ -34,8 +34,13 @@ urlpatterns = [ urlpatterns += [ re_path(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + # 정적 파일 직접 서빙 (프로덕션 환경) + re_path(r'^static/(?P.*)$', serve, {'document_root': settings.STATIC_ROOT}), ] -# 정적 파일 서빙 (개발 환경에서만) -if settings.DEBUG: - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +# 추가 정적 파일 서빙 (백업) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +# 커스텀 에러 핸들러 +handler404 = 'A_core.views.custom_404_view' +handler500 = 'A_core.views.custom_500_view' \ No newline at end of file diff --git a/A_core/views.py b/A_core/views.py new file mode 100644 index 0000000..7f5b89f --- /dev/null +++ b/A_core/views.py @@ -0,0 +1,10 @@ +from django.shortcuts import render +from django.http import HttpResponseNotFound, HttpResponseServerError + +def custom_404_view(request, exception): + """커스텀 404 Not Found 페이지""" + return HttpResponseNotFound(render(request, '404.html')) + +def custom_500_view(request): + """커스텀 500 Server Error 페이지""" + return HttpResponseServerError(render(request, '500.html')) diff --git a/B_main/__pycache__/admin.cpython-313.pyc b/B_main/__pycache__/admin.cpython-313.pyc index f892a6d..df67ef2 100644 Binary files a/B_main/__pycache__/admin.cpython-313.pyc and b/B_main/__pycache__/admin.cpython-313.pyc differ diff --git a/B_main/__pycache__/admin.cpython-38.pyc b/B_main/__pycache__/admin.cpython-38.pyc index 2793789..b31a4da 100644 Binary files a/B_main/__pycache__/admin.cpython-38.pyc and b/B_main/__pycache__/admin.cpython-38.pyc differ diff --git a/B_main/__pycache__/apps.cpython-313.pyc b/B_main/__pycache__/apps.cpython-313.pyc index 320c6a5..1c338f5 100644 Binary files a/B_main/__pycache__/apps.cpython-313.pyc and b/B_main/__pycache__/apps.cpython-313.pyc differ diff --git a/B_main/__pycache__/apps.cpython-38.pyc b/B_main/__pycache__/apps.cpython-38.pyc index 606a519..2a6caa4 100644 Binary files a/B_main/__pycache__/apps.cpython-38.pyc and b/B_main/__pycache__/apps.cpython-38.pyc differ diff --git a/B_main/__pycache__/email_utils.cpython-38.pyc b/B_main/__pycache__/email_utils.cpython-38.pyc new file mode 100644 index 0000000..a3bcdbd Binary files /dev/null and b/B_main/__pycache__/email_utils.cpython-38.pyc differ diff --git a/B_main/__pycache__/forms.cpython-313.pyc b/B_main/__pycache__/forms.cpython-313.pyc index 574505b..d437bb1 100644 Binary files a/B_main/__pycache__/forms.cpython-313.pyc and b/B_main/__pycache__/forms.cpython-313.pyc differ diff --git a/B_main/__pycache__/forms.cpython-38.pyc b/B_main/__pycache__/forms.cpython-38.pyc index 6107b99..76afad1 100644 Binary files a/B_main/__pycache__/forms.cpython-38.pyc and b/B_main/__pycache__/forms.cpython-38.pyc differ diff --git a/B_main/__pycache__/log_utils.cpython-38.pyc b/B_main/__pycache__/log_utils.cpython-38.pyc new file mode 100644 index 0000000..94e407b Binary files /dev/null and b/B_main/__pycache__/log_utils.cpython-38.pyc differ diff --git a/B_main/__pycache__/models.cpython-313.pyc b/B_main/__pycache__/models.cpython-313.pyc index 5ab7e18..cecbfdc 100644 Binary files a/B_main/__pycache__/models.cpython-313.pyc and b/B_main/__pycache__/models.cpython-313.pyc differ diff --git a/B_main/__pycache__/models.cpython-38.pyc b/B_main/__pycache__/models.cpython-38.pyc index 1424fef..ee4f3b0 100644 Binary files a/B_main/__pycache__/models.cpython-38.pyc and b/B_main/__pycache__/models.cpython-38.pyc differ diff --git a/B_main/__pycache__/peopleinfo.cpython-313.pyc b/B_main/__pycache__/peopleinfo.cpython-313.pyc index f4c65cf..b7648bc 100644 Binary files a/B_main/__pycache__/peopleinfo.cpython-313.pyc and b/B_main/__pycache__/peopleinfo.cpython-313.pyc differ diff --git a/B_main/__pycache__/signals.cpython-38.pyc b/B_main/__pycache__/signals.cpython-38.pyc new file mode 100644 index 0000000..83571cb Binary files /dev/null and b/B_main/__pycache__/signals.cpython-38.pyc differ diff --git a/B_main/__pycache__/urls.cpython-313.pyc b/B_main/__pycache__/urls.cpython-313.pyc index 5954864..c29e678 100644 Binary files a/B_main/__pycache__/urls.cpython-313.pyc and b/B_main/__pycache__/urls.cpython-313.pyc differ diff --git a/B_main/__pycache__/urls.cpython-38.pyc b/B_main/__pycache__/urls.cpython-38.pyc index 6729ba6..4b02e01 100644 Binary files a/B_main/__pycache__/urls.cpython-38.pyc and b/B_main/__pycache__/urls.cpython-38.pyc differ diff --git a/B_main/__pycache__/views.cpython-313.pyc b/B_main/__pycache__/views.cpython-313.pyc index 153cebf..eeb8047 100644 Binary files a/B_main/__pycache__/views.cpython-313.pyc and b/B_main/__pycache__/views.cpython-313.pyc differ diff --git a/B_main/__pycache__/views.cpython-38.pyc b/B_main/__pycache__/views.cpython-38.pyc index 5312904..5b8c4cd 100644 Binary files a/B_main/__pycache__/views.cpython-38.pyc and b/B_main/__pycache__/views.cpython-38.pyc differ diff --git a/B_main/__pycache__/withdrawal_utils.cpython-38.pyc b/B_main/__pycache__/withdrawal_utils.cpython-38.pyc new file mode 100644 index 0000000..8ce6bdd Binary files /dev/null and b/B_main/__pycache__/withdrawal_utils.cpython-38.pyc differ diff --git a/B_main/admin.py b/B_main/admin.py index f8a0a75..106a807 100644 --- a/B_main/admin.py +++ b/B_main/admin.py @@ -1,7 +1,11 @@ from django.contrib import admin from django.utils.html import format_html from django import forms -from .models import Person +from django.http import HttpResponseRedirect +from django.contrib import messages +from django.urls import reverse +from .models import Person, AccessLog, WithdrawalRequest +from .withdrawal_utils import process_withdrawal_approval, reject_withdrawal_request class PersonAdminForm(forms.ModelForm): class Meta: @@ -16,10 +20,10 @@ class PersonAdminForm(forms.ModelForm): @admin.register(Person) class PersonAdmin(admin.ModelAdmin): form = PersonAdminForm - list_display = ['SEQUENCE', '이름', '소속', '직책', '연락처', 'user', '모든사람보기권한', '비밀번호설정필요', '사진'] + list_display = ['SEQUENCE', '이름', '소속', '직책', '연락처', 'user', '모든사람보기권한', '비밀번호설정필요', '가입일시', '사진'] list_filter = ['모든사람보기권한', '비밀번호설정필요', '소속', '직책'] search_fields = ['이름', '소속', '직책', '연락처', 'keyword1'] - readonly_fields = ['수정일시', '사진미리보기'] + readonly_fields = ['수정일시', '사진미리보기', '가입일시'] list_editable = ['SEQUENCE'] list_display_links = ['이름'] ordering = ['이름'] @@ -35,7 +39,7 @@ class PersonAdmin(admin.ModelAdmin): 'fields': ('사진', '사진미리보기') }), ('설정', { - 'fields': ('모든사람보기권한', '비밀번호설정필요', 'TITLE', 'SEQUENCE', 'keyword1') + 'fields': ('모든사람보기권한', '비밀번호설정필요', 'TITLE', 'SEQUENCE', 'keyword1', '가입일시') }), ) @@ -81,4 +85,200 @@ class PersonAdmin(admin.ModelAdmin): return request.user.is_superuser def has_view_permission(self, request, obj=None): + return request.user.is_superuser + + +@admin.register(AccessLog) +class AccessLogAdmin(admin.ModelAdmin): + list_display = ['timestamp', '사용자명', 'action_display', 'ip_address', 'description'] + list_filter = ['action', 'timestamp', 'ip_address'] + search_fields = ['user__username', 'person__이름', 'description', 'ip_address'] + readonly_fields = ['timestamp', 'user', 'person', 'action', 'description', 'ip_address', 'user_agent', 'session_key', 'metadata', '변경사항_상세보기'] + date_hierarchy = 'timestamp' + ordering = ['-timestamp'] + + # 페이지당 표시할 항목 수 + list_per_page = 50 + + def 사용자명(self, obj): + if obj.person: + return format_html('{}', obj.person.이름) + elif obj.user: + return format_html('{}', obj.user.username) + else: + return format_html('익명') + 사용자명.short_description = '사용자' + 사용자명.admin_order_field = 'person__이름' + + def action_display(self, obj): + action_colors = { + 'LOGIN': '#28a745', # 초록 + 'LOGOUT': '#6c757d', # 회색 + 'SIGNUP': '#007bff', # 파랑 + 'PROFILE_UPDATE': '#ffc107', # 노랑 + 'PASSWORD_CHANGE': '#fd7e14', # 주황 + 'PHONE_VERIFICATION': '#20c997', # 청록 + 'SEARCH': '#6f42c1', # 보라 + 'VIEW_PROFILE': '#17a2b8', # 하늘 + 'MAIN_ACCESS': '#343a40', # 어두운 회색 + 'ERROR': '#dc3545', # 빨강 + 'OTHER': '#6c757d', # 회색 + } + color = action_colors.get(obj.action, '#6c757d') + return format_html( + '{}', + color, obj.get_action_display() + ) + action_display.short_description = '활동' + action_display.admin_order_field = 'action' + + fieldsets = ( + ('기본 정보', { + 'fields': ('timestamp', 'user', 'person', 'action', 'description') + }), + ('접속 정보', { + 'fields': ('ip_address', 'user_agent', 'session_key') + }), + ('상세 변경사항', { + 'fields': ('변경사항_상세보기',), + 'classes': ('collapse',) + }), + ('추가 정보 (JSON)', { + 'fields': ('metadata',), + 'classes': ('collapse',) + }), + ) + + def 변경사항_상세보기(self, obj): + """변경사항을 보기 좋게 표시""" + if obj.action == 'PROFILE_UPDATE' and obj.metadata.get('field_changes'): + changes = obj.metadata['field_changes'] + html_parts = ['
'] + + for field_name, change_data in changes.items(): + old_value = change_data.get('old', '') + new_value = change_data.get('new', '') + + html_parts.append(f'
') + html_parts.append(f'{field_name}:
') + html_parts.append(f'이전: "{old_value}"
') + html_parts.append(f'이후: "{new_value}"') + html_parts.append('
') + + html_parts.append('
') + return format_html(''.join(html_parts)) + else: + return '변경사항 없음' + + 변경사항_상세보기.short_description = '필드별 변경사항' + + def has_add_permission(self, request): + return False # 로그는 시스템에서만 생성 + + def has_change_permission(self, request, obj=None): + return False # 로그는 수정 불가 + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser # 슈퍼유저만 삭제 가능 + + +@admin.register(WithdrawalRequest) +class WithdrawalRequestAdmin(admin.ModelAdmin): + list_display = ['request_date', '사용자명', 'status_display', 'approved_by', 'approved_date'] + list_filter = ['status', 'request_date', 'approved_date'] + search_fields = ['user__username', 'person__이름', 'reason'] + readonly_fields = ['request_date', 'user', 'person', 'reason', 'backup_data'] + date_hierarchy = 'request_date' + ordering = ['-request_date'] + + # 페이지당 표시할 항목 수 + list_per_page = 30 + + def 사용자명(self, obj): + if obj.user: + return format_html('{} ({})', obj.person.이름, obj.user.username) + else: + # 탈퇴 승인된 경우 백업 데이터에서 정보 가져오기 + username = obj.backup_data.get('user_info', {}).get('username', '탈퇴됨') + return format_html('{} ({})', obj.person.이름, username) + 사용자명.short_description = '사용자' + 사용자명.admin_order_field = 'person__이름' + + def status_display(self, obj): + status_colors = { + 'PENDING': '#ffc107', # 노랑 + 'APPROVED': '#28a745', # 초록 + 'REJECTED': '#dc3545', # 빨강 + } + color = status_colors.get(obj.status, '#6c757d') + return format_html( + '{}', + color, obj.get_status_display() + ) + status_display.short_description = '상태' + status_display.admin_order_field = 'status' + + fieldsets = ( + ('기본 정보', { + 'fields': ('request_date', 'user', 'person', 'status') + }), + ('탈퇴 요청 내용', { + 'fields': ('reason',) + }), + ('승인 정보', { + 'fields': ('approved_by', 'approved_date', 'admin_notes') + }), + ('백업 데이터', { + 'fields': ('backup_data',), + 'classes': ('collapse',) + }), + ) + + actions = ['approve_withdrawal', 'reject_withdrawal'] + + def approve_withdrawal(self, request, queryset): + """탈퇴 요청 승인""" + approved_count = 0 + failed_count = 0 + + for withdrawal_request in queryset.filter(status='PENDING'): + try: + if process_withdrawal_approval(withdrawal_request, request.user, '관리자 일괄 승인'): + approved_count += 1 + else: + failed_count += 1 + except Exception as e: + failed_count += 1 + self.message_user(request, f'{withdrawal_request.person.이름} 탈퇴 처리 실패: {e}', level=messages.ERROR) + + if approved_count > 0: + self.message_user(request, f'{approved_count}건의 탈퇴 요청을 승인했습니다.', level=messages.SUCCESS) + if failed_count > 0: + self.message_user(request, f'{failed_count}건의 탈퇴 처리에 실패했습니다.', level=messages.WARNING) + + approve_withdrawal.short_description = '선택된 탈퇴 요청 승인' + + def reject_withdrawal(self, request, queryset): + """탈퇴 요청 거부""" + rejected_count = 0 + + for withdrawal_request in queryset.filter(status='PENDING'): + if reject_withdrawal_request(withdrawal_request, request.user, '관리자 일괄 거부'): + rejected_count += 1 + + if rejected_count > 0: + self.message_user(request, f'{rejected_count}건의 탈퇴 요청을 거부했습니다.', level=messages.SUCCESS) + + reject_withdrawal.short_description = '선택된 탈퇴 요청 거부' + + def has_add_permission(self, request): + return False # 탈퇴 요청은 사용자가 직접 생성 + + def has_change_permission(self, request, obj=None): + # 승인 대기 중인 요청만 수정 가능 + if obj and obj.status != 'PENDING': + return False + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): return request.user.is_superuser \ No newline at end of file diff --git a/B_main/apps.py b/B_main/apps.py index 18edb53..14d7153 100644 --- a/B_main/apps.py +++ b/B_main/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class BMainConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'B_main' + + def ready(self): + import B_main.signals \ No newline at end of file diff --git a/B_main/email_utils.py b/B_main/email_utils.py new file mode 100644 index 0000000..250dd2f --- /dev/null +++ b/B_main/email_utils.py @@ -0,0 +1,252 @@ +""" +이메일 발송을 위한 유틸리티 함수들 +""" +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string +from django.utils import timezone +import json +import threading +from datetime import datetime + + +def send_withdrawal_notification(withdrawal_request): + """ + 회원탈퇴 승인 시 관리자에게 탈퇴 정보를 이메일로 발송 + + Args: + withdrawal_request: WithdrawalRequest 객체 + """ + try: + person = withdrawal_request.person + user = withdrawal_request.user + backup_data = withdrawal_request.backup_data + + # 이메일 제목 + subject = f"[신라 AMP] 회원탈퇴 처리 완료 - {person.이름}" + + # 이메일 내용 구성 + approved_date_str = withdrawal_request.approved_date.strftime('%Y년 %m월 %d일 %H시 %M분') if withdrawal_request.approved_date else '처리 중' + email_content = f""" +=== 신라 AMP 회원탈퇴 처리 완료 === + +탈퇴 처리 일시: {approved_date_str} +승인자: {withdrawal_request.approved_by.username if withdrawal_request.approved_by else '시스템'} + +=== 탈퇴한 회원 정보 === +이름: {person.이름} +전화번호: {user.username} +탈퇴 요청일: {withdrawal_request.request_date.strftime('%Y년 %m월 %d일 %H시 %M분')} +탈퇴 사유: {withdrawal_request.reason or '사유 없음'} + +=== 탈퇴 전 수정된 정보 === +""" + + # 백업 데이터가 있으면 추가 + if backup_data and 'person_info' in backup_data: + person_info = backup_data['person_info'] + email_content += f"검색 키워드: {person_info.get('keyword1', '없음')}\n" + email_content += f"소개글: {person_info.get('소개글', '없음')}\n" + email_content += f"가입일시: {person_info.get('가입일시', '없음')}\n" + email_content += f"전화번호: {person_info.get('연락처', '없음')}\n" + email_content += f"소속: {person_info.get('소속', '없음')}\n" + email_content += f"직책: {person_info.get('직책', '없음')}\n" + + email_content += f""" +=== 원본 정보로 복원 === +- Person 정보가 peopleinfo.py의 원본 데이터로 복원되었습니다. +- User 계정이 삭제되었습니다. +- Person과 User 연결이 해제되었습니다. + +=== 관리자 메모 === +{withdrawal_request.admin_notes or '없음'} + +--- +신라 AMP 시스템에서 자동 발송된 메일입니다. +""" + + # 이메일 발송 + send_mail( + subject=subject, + message=email_content, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=['cpabong.com@gmail.com'], + fail_silently=False, + ) + + print(f"[EMAIL] 탈퇴 알림 이메일 발송 성공: {person.이름}") + return True + + except Exception as e: + print(f"[EMAIL_ERROR] 탈퇴 알림 이메일 발송 실패: {e}") + return False + + +def test_email_settings(): + """이메일 설정 테스트""" + try: + send_mail( + subject='[신라 AMP] 이메일 설정 테스트', + message='이메일 설정이 정상적으로 작동합니다.', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=['cpabong.com@gmail.com'], + fail_silently=False, + ) + print("[EMAIL] 테스트 이메일 발송 성공") + return True + except Exception as e: + print(f"[EMAIL_ERROR] 테스트 이메일 발송 실패: {e}") + return False + + +def send_withdrawal_request_notification(user, person, reason): + """ + 회원탈퇴 요청 시 관리자에게 탈퇴 정보를 이메일로 발송 (백그라운드) + + Args: + user: User 객체 + person: Person 객체 + reason: 탈퇴 사유 + """ + def _send_email(): + try: + # 이메일 제목 + subject = f"[신라 AMP] 회원탈퇴 요청 - {person.이름}" + + # 현재 회원 정보 수집 + current_info = { + 'user_info': { + 'username': user.username, + 'email': user.email, + 'date_joined': user.date_joined.isoformat() if user.date_joined else None, + 'last_login': user.last_login.isoformat() if user.last_login else None, + }, + 'person_info': { + '이름': person.이름, + '소속': person.소속, + '생년월일': person.생년월일.isoformat() if person.생년월일 else None, + '직책': person.직책, + '연락처': person.연락처, + '주소': person.주소, + '사진': person.사진.name if person.사진 else None, + 'TITLE': person.TITLE, + 'SEQUENCE': person.SEQUENCE, + 'keyword1': person.keyword1, + '소개글': person.소개글, + '모든사람보기권한': person.모든사람보기권한, + '비밀번호설정필요': person.비밀번호설정필요, + '가입일시': person.가입일시.isoformat() if person.가입일시 else None, + } + } + + # 이메일 내용 구성 + email_content = f""" +=== 신라 AMP 회원탈퇴 요청 === + +탈퇴 요청 일시: {timezone.now().strftime('%Y년 %m월 %d일 %H시 %M분')} + +=== 탈퇴 요청자 정보 === +이름: {person.이름} +전화번호: {user.username} +탈퇴 사유: {reason or '사유 없음'} + +=== 현재 회원정보 === +""" + + # 현재 정보 추가 + if current_info and 'person_info' in current_info: + person_info = current_info['person_info'] + email_content += f"검색 키워드: {person_info.get('keyword1', '없음')}\n" + email_content += f"소개글: {person_info.get('소개글', '없음')}\n" + email_content += f"가입일시: {person_info.get('가입일시', '없음')}\n" + email_content += f"전화번호: {person_info.get('연락처', '없음')}\n" + email_content += f"소속: {person_info.get('소속', '없음')}\n" + email_content += f"직책: {person_info.get('직책', '없음')}\n" + + email_content += f""" + +=== 처리 안내 === +- Django Admin에서 탈퇴 요청을 승인 또는 거부할 수 있습니다. +- 승인 시 위 정보가 백업되고 계정이 삭제됩니다. +- 거부 시 해당 회원에게 SMS로 알림이 전송됩니다. + +--- +신라 AMP 시스템에서 자동 발송된 메일입니다. +""" + + # 이메일 발송 + send_mail( + subject=subject, + message=email_content, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=['cpabong.com@gmail.com'], + fail_silently=False, + ) + + print(f"[EMAIL] 탈퇴 요청 이메일 발송 성공: {person.이름}") + + except Exception as e: + print(f"[EMAIL_ERROR] 탈퇴 요청 이메일 발송 실패: {e}") + + # 백그라운드 스레드에서 이메일 발송 + email_thread = threading.Thread(target=_send_email) + email_thread.daemon = True + email_thread.start() + print(f"[EMAIL] 탈퇴 요청 이메일 백그라운드 발송 시작: {person.이름}") + + +def test_withdrawal_email(): + """탈퇴 이메일 템플릿 테스트""" + try: + # 가상의 백업 데이터로 테스트 + test_backup_data = { + 'user_info': { + 'username': '01033433319', + 'email': '', + 'date_joined': '2025-08-23T16:24:35', + 'last_login': '2025-08-23T16:27:44' + }, + 'person_info': { + '이름': '김봉수', + '소속': '신라대학교', + '생년월일': '1979-03-19', + '직책': '교수', + '연락처': '01033433319', + '주소': '부산시 사상구', + '사진': 'profile_photos/김봉수.png', + 'TITLE': 'AMP 8기', + 'SEQUENCE': 1, + 'keyword1': '테스트키워드', + '소개글': '테스트 소개글입니다', + '모든사람보기권한': True, + '비밀번호설정필요': False, + '가입일시': '2025-08-23T16:24:35' + } + } + + # 테스트용 가상 WithdrawalRequest 객체 + class TestWithdrawalRequest: + def __init__(self): + self.person = type('Person', (), {'이름': '김봉수'})() + self.user = type('User', (), {'username': '01033433319'})() + self.backup_data = test_backup_data + self.request_date = timezone.now() + self.reason = '테스트 탈퇴 사유' + self.approved_date = timezone.now() + self.approved_by = type('User', (), {'username': 'admin'})() + self.admin_notes = '테스트 관리자 메모' + + # 테스트 이메일 발송 + test_request = TestWithdrawalRequest() + result = send_withdrawal_notification(test_request) + + if result: + print("[EMAIL] 탈퇴 이메일 테스트 발송 성공") + else: + print("[EMAIL] 탈퇴 이메일 테스트 발송 실패") + + return result + + except Exception as e: + print(f"[EMAIL_ERROR] 탈퇴 이메일 테스트 실패: {e}") + return False diff --git a/B_main/forms.py b/B_main/forms.py index 4c873bd..dbfee55 100644 --- a/B_main/forms.py +++ b/B_main/forms.py @@ -143,6 +143,9 @@ class Step2AccountForm(forms.Form): privacy_agreement = forms.BooleanField( required=True, label='정보공개 및 개인정보처리방침 동의', + error_messages={ + 'required': '회원가입을 계속하기 위해서 정보공개 등에 동의해주세요' + }, widget=forms.CheckboxInput(attrs={ 'class': 'w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2' }) @@ -157,9 +160,6 @@ class Step2AccountForm(forms.Form): if password1 and password2 and password1 != password2: raise forms.ValidationError('비밀번호가 일치하지 않습니다.') - if not privacy_agreement: - raise forms.ValidationError('정보공개 및 개인정보처리방침 동의는 필수입니다.') - return cleaned_data def save(self, name, phone, request, commit=True): @@ -200,13 +200,16 @@ class Step2AccountForm(forms.Form): existing_person = None if existing_person: - # 기존 미가입 Person이 있으면 user 연결 + # 기존 미가입 Person이 있으면 user 연결하고 가입일시 설정 + from django.utils import timezone existing_person.user = user + existing_person.가입일시 = timezone.now() existing_person.save() - print(f"[DEBUG] 기존 Person 업데이트: {name} (user 연결)") + print(f"[DEBUG] 기존 Person 업데이트: {name} (user 연결, 가입일시 기록)") return user else: - # 기존 Person이 없으면 새로 생성 + # 기존 Person이 없으면 새로 생성하고 가입일시 설정 + from django.utils import timezone Person.objects.create( user=user, 이름=name, @@ -214,9 +217,10 @@ class Step2AccountForm(forms.Form): 소속='', 직책='', 주소='', - 사진='profile_photos/default_user.png' + 사진='profile_photos/default_user.png', + 가입일시=timezone.now() # 회원가입 완료 시점 기록 ) - print(f"[DEBUG] 새 Person 생성: {name}") + print(f"[DEBUG] 새 Person 생성: {name} (가입일시 기록)") return user except Exception as e: print(f"[DEBUG] 사용자 생성 중 오류: {e}") diff --git a/B_main/log_utils.py b/B_main/log_utils.py new file mode 100644 index 0000000..51219c8 --- /dev/null +++ b/B_main/log_utils.py @@ -0,0 +1,232 @@ +""" +접속 로그 기록을 위한 유틸리티 함수들 +""" +from django.contrib.auth.models import User +from .models import AccessLog, Person + + +def get_client_ip(request): + """클라이언트 IP 주소 가져오기""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + +def get_user_agent(request): + """사용자 에이전트 가져오기""" + return request.META.get('HTTP_USER_AGENT', '') + + +def log_user_activity(request, action, description=None, user=None, metadata=None): + """ + 사용자 활동 로그 기록 + + Args: + request: Django request 객체 + action: 활동 유형 (AccessLog.ACTION_CHOICES 중 하나) + description: 상세 설명 (선택사항) + user: 사용자 객체 (선택사항, 없으면 request.user 사용) + metadata: 추가 정보 딕셔너리 (선택사항) + """ + try: + # 사용자 정보 가져오기 + if user is None: + user = request.user if request.user.is_authenticated else None + + # Person 객체 가져오기 + person = None + if user: + try: + person = Person.objects.get(user=user) + except Person.DoesNotExist: + pass + + # 메타데이터 기본값 설정 + if metadata is None: + metadata = {} + + # 요청 정보 추가 + metadata.update({ + 'path': request.path, + 'method': request.method, + 'referer': request.META.get('HTTP_REFERER', ''), + }) + + # 로그 생성 + AccessLog.objects.create( + user=user, + person=person, + action=action, + description=description, + ip_address=get_client_ip(request), + user_agent=get_user_agent(request), + session_key=request.session.session_key, + metadata=metadata + ) + + print(f"[ACCESS_LOG] {action}: {user.username if user else 'Anonymous'} - {description}") + + except Exception as e: + print(f"[ACCESS_LOG_ERROR] 로그 기록 실패: {e}") + + +def log_login(request, user): + """로그인 로그 기록""" + log_user_activity( + request=request, + action='LOGIN', + description=f'사용자 로그인: {user.username}', + user=user + ) + + +def log_logout(request, user): + """로그아웃 로그 기록""" + log_user_activity( + request=request, + action='LOGOUT', + description=f'사용자 로그아웃: {user.username}', + user=user + ) + + +def log_signup(request, user): + """회원가입 로그 기록""" + log_user_activity( + request=request, + action='SIGNUP', + description=f'새 회원가입: {user.username}', + user=user + ) + + +def log_profile_update(request, user, updated_fields=None, field_changes=None): + """프로필 수정 로그 기록""" + description = f'프로필 수정: {user.username}' + if updated_fields: + description += f' (수정된 필드: {", ".join(updated_fields)})' + + metadata = {} + if updated_fields: + metadata['updated_fields'] = updated_fields + + # 필드별 수정 전/후 값 기록 + if field_changes: + metadata['field_changes'] = field_changes + # 상세 설명에 변경사항 추가 + change_details = [] + for field_name, changes in field_changes.items(): + old_value = changes.get('old', '') + new_value = changes.get('new', '') + # 값이 너무 길면 자르기 + if len(str(old_value)) > 50: + old_value = str(old_value)[:50] + '...' + if len(str(new_value)) > 50: + new_value = str(new_value)[:50] + '...' + change_details.append(f"{field_name}: '{old_value}' → '{new_value}'") + + if change_details: + description += f' | 변경사항: {" | ".join(change_details)}' + + log_user_activity( + request=request, + action='PROFILE_UPDATE', + description=description, + user=user, + metadata=metadata + ) + + +def log_password_change(request, user): + """비밀번호 변경 로그 기록""" + log_user_activity( + request=request, + action='PASSWORD_CHANGE', + description=f'비밀번호 변경: {user.username}', + user=user + ) + + +def log_phone_verification(request, phone_number, user=None): + """전화번호 인증 로그 기록""" + log_user_activity( + request=request, + action='PHONE_VERIFICATION', + description=f'전화번호 인증: {phone_number}', + user=user, + metadata={'phone_number': phone_number} + ) + + +def log_search(request, query, result_count=None): + """검색 로그 기록""" + description = f'검색 쿼리: {query}' + if result_count is not None: + description += f' (결과: {result_count}개)' + + metadata = {'query': query} + if result_count is not None: + metadata['result_count'] = result_count + + log_user_activity( + request=request, + action='SEARCH', + description=description, + metadata=metadata + ) + + +def log_main_access(request): + """메인페이지 접속 로그 기록""" + log_user_activity( + request=request, + action='MAIN_ACCESS', + description='메인페이지 접속' + ) + + +def log_error(request, error_message, error_type=None): + """에러 로그 기록""" + metadata = {'error_message': error_message} + if error_type: + metadata['error_type'] = error_type + + log_user_activity( + request=request, + action='ERROR', + description=f'에러 발생: {error_message}', + metadata=metadata + ) + + +def log_withdrawal_request(request, user, withdrawal_request_id): + """회원탈퇴 요청 로그 기록""" + log_user_activity( + request=request, + action='OTHER', + description=f'회원탈퇴 요청 제출: {user.username}', + user=user, + metadata={ + 'withdrawal_request_id': withdrawal_request_id, + 'action_type': 'withdrawal_request' + } + ) + + +def log_withdrawal_approval(request, approved_by, withdrawn_user, withdrawn_person, withdrawal_request_id): + """회원탈퇴 승인 로그 기록""" + log_user_activity( + request=request, + action='OTHER', + description=f'회원탈퇴 승인 처리: {withdrawn_user} ({withdrawn_person})', + user=approved_by, + metadata={ + 'withdrawal_request_id': withdrawal_request_id, + 'withdrawn_user': withdrawn_user, + 'withdrawn_person': withdrawn_person, + 'action_type': 'withdrawal_approval' + } + ) diff --git a/B_main/manual_populate.py b/B_main/manual_populate.py index b0aecf7..e125792 100644 --- a/B_main/manual_populate.py +++ b/B_main/manual_populate.py @@ -118,9 +118,6 @@ def create_persons_from_peopleinfo(): print(f"이미 존재하는 Person: {name} ({phone})") continue - # 김봉수, 김태형만 보이게 설정, 나머지는 안보이게 설정 - show_in_main = name in ['김봉수', '김태형'] - # 새 Person 생성 person = Person.objects.create( 이름=name, @@ -131,8 +128,7 @@ def create_persons_from_peopleinfo(): 주소=address, 사진=photo, TITLE=title, - SEQUENCE=sequence, - 보일지여부=show_in_main + SEQUENCE=sequence ) created_count += 1 diff --git a/B_main/migrations/0011_person_가입일시.py b/B_main/migrations/0011_person_가입일시.py new file mode 100644 index 0000000..1432ea1 --- /dev/null +++ b/B_main/migrations/0011_person_가입일시.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-08-22 11:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0010_alter_person_options_person_비밀번호설정필요'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='가입일시', + field=models.DateTimeField(auto_now_add=True, help_text='회원가입을 완료한 날짜와 시간', null=True, verbose_name='가입일시'), + ), + ] diff --git a/B_main/migrations/0012_alter_person_가입일시.py b/B_main/migrations/0012_alter_person_가입일시.py new file mode 100644 index 0000000..e279b1c --- /dev/null +++ b/B_main/migrations/0012_alter_person_가입일시.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-08-22 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0011_person_가입일시'), + ] + + operations = [ + migrations.AlterField( + model_name='person', + name='가입일시', + field=models.DateTimeField(blank=True, help_text='회원가입을 완료한 날짜와 시간', null=True, verbose_name='가입일시'), + ), + ] diff --git a/B_main/migrations/0013_add_database_indexes.py b/B_main/migrations/0013_add_database_indexes.py new file mode 100644 index 0000000..5034db0 --- /dev/null +++ b/B_main/migrations/0013_add_database_indexes.py @@ -0,0 +1,38 @@ +# Generated manually for performance optimization + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0012_alter_person_가입일시'), + ] + + operations = [ + # 검색 성능 향상을 위한 인덱스 추가 + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_name ON B_main_person (이름);", + reverse_sql="DROP INDEX IF EXISTS idx_person_name;" + ), + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_소속 ON B_main_person (소속);", + reverse_sql="DROP INDEX IF EXISTS idx_person_소속;" + ), + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_직책 ON B_main_person (직책);", + reverse_sql="DROP INDEX IF EXISTS idx_person_직책;" + ), + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_keyword1 ON B_main_person (keyword1);", + reverse_sql="DROP INDEX IF EXISTS idx_person_keyword1;" + ), + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_sequence ON B_main_person (SEQUENCE);", + reverse_sql="DROP INDEX IF EXISTS idx_person_sequence;" + ), + migrations.RunSQL( + "CREATE INDEX IF NOT EXISTS idx_person_user ON B_main_person (user_id);", + reverse_sql="DROP INDEX IF EXISTS idx_person_user;" + ), + ] diff --git a/B_main/migrations/0014_person_소개글.py b/B_main/migrations/0014_person_소개글.py new file mode 100644 index 0000000..390ce69 --- /dev/null +++ b/B_main/migrations/0014_person_소개글.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-08-23 02:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0013_add_database_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='소개글', + field=models.TextField(blank=True, help_text='자신을 소개하는 간단한 글을 작성하세요 (최대 200자)', max_length=200, null=True, verbose_name='소개글'), + ), + ] diff --git a/B_main/migrations/0015_accesslog.py b/B_main/migrations/0015_accesslog.py new file mode 100644 index 0000000..4539908 --- /dev/null +++ b/B_main/migrations/0015_accesslog.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.16 on 2025-08-23 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), + ('B_main', '0014_person_소개글'), + ] + + operations = [ + migrations.CreateModel( + name='AccessLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('LOGIN', '로그인'), ('LOGOUT', '로그아웃'), ('SIGNUP', '회원가입'), ('PROFILE_UPDATE', '회원정보수정'), ('PASSWORD_CHANGE', '비밀번호변경'), ('PHONE_VERIFICATION', '전화번호인증'), ('SEARCH', '검색'), ('VIEW_PROFILE', '프로필조회'), ('MAIN_ACCESS', '메인페이지접속'), ('ERROR', '에러발생'), ('OTHER', '기타')], max_length=20, verbose_name='활동유형')), + ('description', models.TextField(blank=True, null=True, verbose_name='상세설명')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP주소')), + ('user_agent', models.TextField(blank=True, null=True, verbose_name='사용자에이전트')), + ('session_key', models.CharField(blank=True, max_length=40, null=True, verbose_name='세션키')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='발생시간')), + ('metadata', models.JSONField(blank=True, default=dict, verbose_name='추가정보')), + ('person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='B_main.person', verbose_name='Person')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='사용자')), + ], + options={ + 'verbose_name': '접속 로그', + 'verbose_name_plural': '접속 로그들', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['user', '-timestamp'], name='B_main_acce_user_id_28481c_idx'), models.Index(fields=['action', '-timestamp'], name='B_main_acce_action_2076f4_idx'), models.Index(fields=['-timestamp'], name='B_main_acce_timesta_6c0a5c_idx'), models.Index(fields=['ip_address', '-timestamp'], name='B_main_acce_ip_addr_ce96a0_idx')], + }, + ), + ] diff --git a/B_main/migrations/0016_withdrawalrequest.py b/B_main/migrations/0016_withdrawalrequest.py new file mode 100644 index 0000000..5caab52 --- /dev/null +++ b/B_main/migrations/0016_withdrawalrequest.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.16 on 2025-08-23 07:12 + +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), + ('B_main', '0015_accesslog'), + ] + + operations = [ + migrations.CreateModel( + name='WithdrawalRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('PENDING', '승인 대기'), ('APPROVED', '승인 완료'), ('REJECTED', '승인 거부')], default='PENDING', max_length=10, verbose_name='승인 상태')), + ('reason', models.TextField(blank=True, null=True, verbose_name='탈퇴 사유')), + ('request_date', models.DateTimeField(auto_now_add=True, verbose_name='요청 일시')), + ('approved_date', models.DateTimeField(blank=True, null=True, verbose_name='승인 일시')), + ('admin_notes', models.TextField(blank=True, null=True, verbose_name='관리자 메모')), + ('backup_data', models.JSONField(default=dict, verbose_name='탈퇴 전 정보 백업')), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_withdrawals', to=settings.AUTH_USER_MODEL, verbose_name='승인자')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='B_main.person', verbose_name='Person 정보')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='탈퇴 요청자')), + ], + options={ + 'verbose_name': '회원탈퇴 요청', + 'verbose_name_plural': '회원탈퇴 요청들', + 'ordering': ['-request_date'], + 'indexes': [models.Index(fields=['status', '-request_date'], name='B_main_with_status_4e8c92_idx'), models.Index(fields=['user', '-request_date'], name='B_main_with_user_id_596df2_idx')], + }, + ), + ] diff --git a/B_main/migrations/0017_alter_withdrawalrequest_user.py b/B_main/migrations/0017_alter_withdrawalrequest_user.py new file mode 100644 index 0000000..7e89d82 --- /dev/null +++ b/B_main/migrations/0017_alter_withdrawalrequest_user.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2025-08-23 07:18 + +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), + ('B_main', '0016_withdrawalrequest'), + ] + + operations = [ + migrations.AlterField( + model_name='withdrawalrequest', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='탈퇴 요청자'), + ), + ] diff --git a/B_main/migrations/__pycache__/0001_initial.cpython-313.pyc b/B_main/migrations/__pycache__/0001_initial.cpython-313.pyc index 24c6bd3..6b2dec4 100644 Binary files a/B_main/migrations/__pycache__/0001_initial.cpython-313.pyc and b/B_main/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0002_person_user.cpython-313.pyc b/B_main/migrations/__pycache__/0002_person_user.cpython-313.pyc index fe81406..93685f4 100644 Binary files a/B_main/migrations/__pycache__/0002_person_user.cpython-313.pyc and b/B_main/migrations/__pycache__/0002_person_user.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-313.pyc b/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-313.pyc index 75e3a63..4b61835 100644 Binary files a/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-313.pyc and b/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-313.pyc b/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-313.pyc index 3c67e0e..95bfb4d 100644 Binary files a/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-313.pyc and b/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-313.pyc b/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-313.pyc index 45cdbce..7e9c0e0 100644 Binary files a/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-313.pyc and b/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-313.pyc b/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-313.pyc index bf0b4e4..ab56d23 100644 Binary files a/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-313.pyc and b/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-313.pyc b/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-313.pyc index d8fee29..1539c00 100644 Binary files a/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-313.pyc and b/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-313.pyc b/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-313.pyc index 8052774..f60c722 100644 Binary files a/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-313.pyc and b/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-313.pyc b/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-313.pyc index 444b297..a51d281 100644 Binary files a/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-313.pyc and b/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-313.pyc b/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-313.pyc index 99fb7b2..fbd0bbc 100644 Binary files a/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-313.pyc and b/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0011_person_가입일시.cpython-313.pyc b/B_main/migrations/__pycache__/0011_person_가입일시.cpython-313.pyc new file mode 100644 index 0000000..756565a Binary files /dev/null and b/B_main/migrations/__pycache__/0011_person_가입일시.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0011_person_가입일시.cpython-38.pyc b/B_main/migrations/__pycache__/0011_person_가입일시.cpython-38.pyc new file mode 100644 index 0000000..61bbfc4 Binary files /dev/null and b/B_main/migrations/__pycache__/0011_person_가입일시.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-313.pyc b/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-313.pyc new file mode 100644 index 0000000..26ea0e8 Binary files /dev/null and b/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-313.pyc differ diff --git a/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-38.pyc b/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-38.pyc new file mode 100644 index 0000000..93d7cbd Binary files /dev/null and b/B_main/migrations/__pycache__/0012_alter_person_가입일시.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0013_add_database_indexes.cpython-38.pyc b/B_main/migrations/__pycache__/0013_add_database_indexes.cpython-38.pyc new file mode 100644 index 0000000..85db8a6 Binary files /dev/null and b/B_main/migrations/__pycache__/0013_add_database_indexes.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0014_person_소개글.cpython-38.pyc b/B_main/migrations/__pycache__/0014_person_소개글.cpython-38.pyc new file mode 100644 index 0000000..c820418 Binary files /dev/null and b/B_main/migrations/__pycache__/0014_person_소개글.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0015_accesslog.cpython-38.pyc b/B_main/migrations/__pycache__/0015_accesslog.cpython-38.pyc new file mode 100644 index 0000000..4da4016 Binary files /dev/null and b/B_main/migrations/__pycache__/0015_accesslog.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0016_withdrawalrequest.cpython-38.pyc b/B_main/migrations/__pycache__/0016_withdrawalrequest.cpython-38.pyc new file mode 100644 index 0000000..94d3d10 Binary files /dev/null and b/B_main/migrations/__pycache__/0016_withdrawalrequest.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0017_alter_withdrawalrequest_user.cpython-38.pyc b/B_main/migrations/__pycache__/0017_alter_withdrawalrequest_user.cpython-38.pyc new file mode 100644 index 0000000..faf7529 Binary files /dev/null and b/B_main/migrations/__pycache__/0017_alter_withdrawalrequest_user.cpython-38.pyc differ diff --git a/B_main/models.py b/B_main/models.py index 746397c..6a2820f 100644 --- a/B_main/models.py +++ b/B_main/models.py @@ -22,7 +22,9 @@ class Person(models.Model): SEQUENCE = models.IntegerField(blank=True, null=True) 모든사람보기권한 = models.BooleanField(default=False, verbose_name='모든 사람 보기 권한', help_text='True인 경우 모든 사람을 볼 수 있고, False인 경우 회원가입한 사람만 볼 수 있습니다.') keyword1 = models.CharField(max_length=50, blank=True, null=True, verbose_name='검색 키워드', help_text='다른 사람들이 당신을 찾을 수 있도록 키워드를 입력하세요 (예: 회계감사)') + 소개글 = models.TextField(max_length=200, blank=True, null=True, verbose_name='소개글', help_text='자신을 소개하는 간단한 글을 작성하세요 (최대 200자)') 비밀번호설정필요 = models.BooleanField(default=False, verbose_name='비밀번호 설정 필요', help_text='True인 경우 사용자가 메인페이지 접근 시 비밀번호 설정 페이지로 리다이렉트됩니다.') + 가입일시 = models.DateTimeField(null=True, blank=True, verbose_name='가입일시', help_text='회원가입을 완료한 날짜와 시간') class Meta: verbose_name = '사람' @@ -30,3 +32,85 @@ class Person(models.Model): def __str__(self): return self.이름 + + +class AccessLog(models.Model): + """사용자 접속 및 활동 로그""" + + ACTION_CHOICES = [ + ('LOGIN', '로그인'), + ('LOGOUT', '로그아웃'), + ('SIGNUP', '회원가입'), + ('PROFILE_UPDATE', '회원정보수정'), + ('PASSWORD_CHANGE', '비밀번호변경'), + ('PHONE_VERIFICATION', '전화번호인증'), + ('SEARCH', '검색'), + ('VIEW_PROFILE', '프로필조회'), + ('MAIN_ACCESS', '메인페이지접속'), + ('ERROR', '에러발생'), + ('OTHER', '기타'), + ] + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='사용자') + person = models.ForeignKey(Person, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Person') + action = models.CharField(max_length=20, choices=ACTION_CHOICES, verbose_name='활동유형') + description = models.TextField(blank=True, null=True, verbose_name='상세설명') + ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP주소') + user_agent = models.TextField(blank=True, null=True, verbose_name='사용자에이전트') + session_key = models.CharField(max_length=40, blank=True, null=True, verbose_name='세션키') + timestamp = models.DateTimeField(auto_now_add=True, verbose_name='발생시간') + + # 추가 메타데이터 (JSON 형태로 저장) + metadata = models.JSONField(default=dict, blank=True, verbose_name='추가정보') + + class Meta: + verbose_name = '접속 로그' + verbose_name_plural = '접속 로그들' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['user', '-timestamp']), + models.Index(fields=['action', '-timestamp']), + models.Index(fields=['-timestamp']), + models.Index(fields=['ip_address', '-timestamp']), + ] + + def __str__(self): + user_name = self.person.이름 if self.person else (self.user.username if self.user else '익명') + return f"{user_name} - {self.get_action_display()} ({self.timestamp.strftime('%Y-%m-%d %H:%M:%S')})" + + +class WithdrawalRequest(models.Model): + """회원탈퇴 요청""" + + STATUS_CHOICES = [ + ('PENDING', '승인 대기'), + ('APPROVED', '승인 완료'), + ('REJECTED', '승인 거부'), + ] + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name='탈퇴 요청자') + person = models.ForeignKey(Person, on_delete=models.CASCADE, verbose_name='Person 정보') + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='PENDING', verbose_name='승인 상태') + reason = models.TextField(blank=True, null=True, verbose_name='탈퇴 사유') + request_date = models.DateTimeField(auto_now_add=True, verbose_name='요청 일시') + + # 승인 관련 정보 + approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='approved_withdrawals', verbose_name='승인자') + approved_date = models.DateTimeField(null=True, blank=True, verbose_name='승인 일시') + admin_notes = models.TextField(blank=True, null=True, verbose_name='관리자 메모') + + # 탈퇴 전 사용자 정보 백업 (이메일 발송용) + backup_data = models.JSONField(default=dict, verbose_name='탈퇴 전 정보 백업') + + class Meta: + verbose_name = '회원탈퇴 요청' + verbose_name_plural = '회원탈퇴 요청들' + ordering = ['-request_date'] + indexes = [ + models.Index(fields=['status', '-request_date']), + models.Index(fields=['user', '-request_date']), + ] + + def __str__(self): + return f"{self.person.이름} - {self.get_status_display()} ({self.request_date.strftime('%Y-%m-%d %H:%M')})" diff --git a/B_main/signals.py b/B_main/signals.py new file mode 100644 index 0000000..6a231d1 --- /dev/null +++ b/B_main/signals.py @@ -0,0 +1,19 @@ +""" +Django 시그널을 사용한 자동 로그 기록 +""" +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.dispatch import receiver +from .log_utils import log_login, log_logout + + +@receiver(user_logged_in) +def log_user_login(sender, request, user, **kwargs): + """사용자 로그인 시 로그 기록""" + log_login(request, user) + + +@receiver(user_logged_out) +def log_user_logout(sender, request, user, **kwargs): + """사용자 로그아웃 시 로그 기록""" + if user: # user가 None이 아닌 경우에만 로그 기록 + log_logout(request, user) diff --git a/B_main/templates/B_main/main.htm b/B_main/templates/B_main/main.htm index 4190ec4..4aa421a 100644 --- a/B_main/templates/B_main/main.htm +++ b/B_main/templates/B_main/main.htm @@ -26,7 +26,7 @@
-

신라대학교 AMP 제8기

+ 신라대학교 AMP 제8기
{% if user.is_authenticated %}
@@ -121,6 +121,32 @@
+ + {% if messages %} + {% for message in messages %} +
+
+ {{ message }} + +
+
+ {% endfor %} + + {% endif %}
@@ -131,11 +157,17 @@ placeholder="검색..." class="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200" hx-get="/search/" - hx-trigger="keyup changed delay:500ms" + hx-trigger="keyup changed delay:300ms" hx-target="#card-container" hx-include="#search-input" + hx-indicator="#loading-indicator" autocomplete="off" > + +
+
+ 검색 중... +
@@ -146,6 +178,15 @@
+ + + + + + + \ No newline at end of file diff --git a/B_main/templates/B_main/partials/card.htm b/B_main/templates/B_main/partials/card.htm index 51e9871..acb1baa 100644 --- a/B_main/templates/B_main/partials/card.htm +++ b/B_main/templates/B_main/partials/card.htm @@ -7,14 +7,14 @@ src="{{ person.사진.url }}" alt="{{ person.이름 }}" class="w-[150px] h-[150px] object-cover rounded-lg border border-gray-300 dark:border-gray-600 mb-2 cursor-pointer transition-colors duration-300" - onclick="openModal(this.src)" + onclick="openProfileModal('{{ person.이름|escapejs }}', this.src, '{{ person.소개글|default:""|escapejs }}')" > {% else %} {{ person.이름 }} {% endif %} {% if person.이름 %} @@ -78,46 +78,4 @@ - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/B_main/urls.py b/B_main/urls.py index 20f680b..7fa94c4 100644 --- a/B_main/urls.py +++ b/B_main/urls.py @@ -12,4 +12,5 @@ urlpatterns = [ path('session_logout/', views.session_logout, name='session_logout'), path('signup/', views.signup_view, name='signup'), path('privacy-policy/', views.privacy_policy, name='privacy_policy'), + path('test-500/', views.test_500_error, name='test_500_error'), ] \ No newline at end of file diff --git a/B_main/views.py b/B_main/views.py index cf01da4..9823de4 100644 --- a/B_main/views.py +++ b/B_main/views.py @@ -9,22 +9,20 @@ from .models import Person from django.shortcuts import get_object_or_404 from django.db.models import Q, Case, When, Value, IntegerField from django.contrib.auth import login, logout +from A_core.sms_utils import send_verification_sms +from .log_utils import log_signup, log_phone_verification, log_search, log_main_access, log_error import random import json +import time def password_required(request): PASSWORD = '1110' # 실제 비밀번호 - # 디버깅을 위한 로그 - print(f"[DEBUG] password_required - user.is_authenticated: {request.user.is_authenticated}") - print(f"[DEBUG] password_required - user: {request.user}") - # 로그인이 된 사용자는 바로 메인 페이지로 리다이렉트 if request.user.is_authenticated: next_url = request.GET.get("next", "/") if not next_url: next_url = "/" - print(f"[DEBUG] User is authenticated, redirecting to: {next_url}") return redirect(next_url) if request.method == "POST": @@ -47,32 +45,26 @@ def password_required(request): # 인증 검사 함수 def check_authentication(request): - # 디버깅을 위한 로그 - print(f"[DEBUG] check_authentication - user.is_authenticated: {request.user.is_authenticated}") - print(f"[DEBUG] check_authentication - session.authenticated: {request.session.get('authenticated')}") - print(f"[DEBUG] check_authentication - user: {request.user}") - # 로그인이 된 사용자는 인증 통과 if request.user.is_authenticated: - print(f"[DEBUG] User is authenticated, allowing access") return None # 세션 인증이 된 사용자도 통과 if request.session.get("authenticated"): - print(f"[DEBUG] Session is authenticated, allowing access") return None # 둘 다 안 된 경우에만 비밀번호 페이지로 리다이렉트 - print(f"[DEBUG] No authentication found, redirecting to password page") return redirect(f"/accounts/login/?next={request.path}") def main(request): - print('def main(request):') auth_check = check_authentication(request) if auth_check: return auth_check + + # 메인 페이지 접속 로그 기록 + log_main_access(request) # 현재 사용자의 Person 정보 가져오기 current_user_person = None @@ -90,19 +82,12 @@ def main(request): ) # 현재 사용자의 권한에 따라 추가 필터 적용 - print(f"[DEBUG] 사용자: {request.user.username}, 슈퍼유저: {request.user.is_superuser}") - print(f"[DEBUG] current_user_person: {current_user_person}") - - # 슈퍼유저이거나 Person 객체가 없는 경우 모든 사람 표시 if request.user.is_superuser or current_user_person is None: - print(f"[DEBUG] 슈퍼유저 또는 Person 객체 없음 - 모든 사람 표시 모드") # 모든 사람 표시 (필터 추가 없음) + pass elif current_user_person and not current_user_person.모든사람보기권한: # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 base_filter = base_filter.filter(user__isnull=False) - print(f"[DEBUG] 회원가입자만 표시 모드: {current_user_person.이름}") - else: - print(f"[DEBUG] 모든 사람 표시 모드 (모든사람보기권한: {current_user_person.모든사람보기권한})") # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 people = base_filter.annotate( @@ -113,13 +98,6 @@ def main(request): ) ).order_by('sequence_order', 'SEQUENCE', '이름') - print(f"[DEBUG] 메인 페이지 표시: {people.count()}명") - print(f"[DEBUG] === 표시되는 사람들 ===") - for person in people: - status = "회원가입" if person.user else "미가입" - print(f"[DEBUG] - {person.이름} (상태: {status}, 소속: {person.소속})") - print(f"[DEBUG] === 표시 끝 ===") - return render(request, 'B_main/main.htm', {'people': people}) @@ -130,7 +108,6 @@ def search_people(request): return auth_check query = request.GET.get('q', '') - print(f"[DEBUG] 검색 쿼리: '{query}'") # 현재 사용자의 Person 정보 가져오기 current_user_person = None @@ -144,19 +121,12 @@ def search_people(request): base_filter = Person.objects.all() # 현재 사용자의 권한에 따라 추가 필터 적용 - print(f"[DEBUG] 검색 - 사용자: {request.user.username}, 슈퍼유저: {request.user.is_superuser}") - print(f"[DEBUG] 검색 - current_user_person: {current_user_person}") - - # 슈퍼유저이거나 Person 객체가 없는 경우 모든 사람 표시 if request.user.is_superuser or current_user_person is None: - print(f"[DEBUG] 검색 - 슈퍼유저 또는 Person 객체 없음 - 모든 사람 표시 모드") # 모든 사람 표시 (필터 추가 없음) + pass elif current_user_person and not current_user_person.모든사람보기권한: # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 base_filter = base_filter.filter(user__isnull=False) - print(f"[DEBUG] 검색 - 회원가입자만 표시 모드: {current_user_person.이름}") - else: - print(f"[DEBUG] 검색 - 모든 사람 표시 모드 (모든사람보기권한: {current_user_person.모든사람보기권한})") if query: # 이름, 소속, 직책, 키워드로 검색 @@ -179,9 +149,6 @@ def search_people(request): output_field=IntegerField(), ) ).order_by('sequence_order', 'SEQUENCE', '이름') - print(f"[DEBUG] 검색 결과: {people.count()}명") - for person in people: - print(f"[DEBUG] - {person.이름} (소속: {person.소속}, 직책: {person.직책})") else: # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 people = base_filter.filter( @@ -195,7 +162,10 @@ def search_people(request): output_field=IntegerField(), ) ).order_by('sequence_order', 'SEQUENCE', '이름') - print(f"[DEBUG] 전체 목록: {people.count()}명") + + # 검색 로그 기록 + if query.strip(): + log_search(request, query, people.count()) return render(request, 'B_main/partials/card_list.htm', {'people': people}) @@ -273,14 +243,12 @@ def withdraw(request): person.user = None person.save() - # User 객체 삭제 (전화번호 계정 삭제) + # User 객체 삭제 (전화번호 계정 삭제) user_phone = request.user.username request.user.delete() # 로그아웃 logout(request) - - print(f"[DEBUG] 회원탈퇴 완료: {user_phone} (User 삭제, Person 연결 해제)") return JsonResponse({'success': True}) except Person.DoesNotExist: return JsonResponse({'success': False, 'error': 'Person 정보를 찾을 수 없습니다.'}) @@ -326,14 +294,29 @@ def signup_view(request): # 폼 검증에서 이미 허가되지 않은 사용자 체크를 했으므로 여기서는 제거 code = str(random.randint(100000, 999999)) - request.session['signup_code'] = code - request.session['signup_name'] = name - request.session['signup_phone'] = phone - request.session['signup_verified'] = False - print(f"[DEBUG] 인증번호 발송: {name} ({phone}) - {code}") - return render(request, 'B_main/signup.html', { - 'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.' - }) + + # 실제 SMS 발송 + sms_result = send_verification_sms(phone, code) + + if sms_result['success']: + request.session['signup_code'] = code + request.session['signup_name'] = name + request.session['signup_phone'] = phone + request.session['signup_verified'] = False + request.session['signup_code_sent_at'] = int(time.time()) + + # 전화번호 인증 로그 기록 + log_phone_verification(request, phone) + + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.' + }) + else: + pass + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': False, + 'error': '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.' + }) else: # 폼 에러 메시지 확인 error_message = '입력 정보를 확인해주세요.' @@ -352,6 +335,16 @@ def signup_view(request): if form.is_valid(): verification_code = form.cleaned_data['verification_code'] session_code = request.session.get('signup_code') + code_sent_at = request.session.get('signup_code_sent_at', 0) + current_time = int(time.time()) + + # 인증번호 만료 시간 체크 (3분) + if current_time - code_sent_at > 180: + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': False, + 'error': '인증번호가 만료되었습니다. 다시 발송해주세요.' + }) + if verification_code and verification_code == session_code: # 인증 성공 request.session['signup_verified'] = True @@ -368,11 +361,31 @@ def signup_view(request): return render(request, 'B_main/signup.html', {'step': 1, 'form1': form, 'code_sent': False}) # 2단계: 이메일, 비밀번호, 비밀번호 확인 - if step == 2 and verified and name and phone: + if step == 2: + # 세션이 만료되어 인증 정보가 없는 경우 + if not verified or not name or not phone: + # 세션 초기화 + for key in ['signup_code', 'signup_name', 'signup_phone', 'signup_verified', 'signup_step']: + request.session.pop(key, None) + request.session['signup_step'] = 1 + request.session['signup_verified'] = False + + form = Step1PhoneForm() + return render(request, 'B_main/signup.html', { + 'step': 1, + 'form1': form, + 'code_sent': False, + 'error': '세션이 만료되었습니다. 다시 인증해주세요.' + }) + if request.method == 'POST': form2 = Step2AccountForm(request.POST) if form2.is_valid(): user = form2.save(name, phone, request) + + # 회원가입 로그 기록 + log_signup(request, user) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') # 세션 정리 for key in ['signup_code', 'signup_name', 'signup_phone', 'signup_verified', 'signup_step']: @@ -391,4 +404,9 @@ def signup_view(request): def privacy_policy(request): """개인정보처리방침 페이지""" - return render(request, 'privacy_policy.html') \ No newline at end of file + return render(request, 'privacy_policy.html') + +def test_500_error(request): + """500 에러 페이지 테스트용 뷰""" + # 강제로 에러를 발생시킵니다 + raise Exception("500 에러 페이지 테스트를 위한 의도적인 에러입니다.") \ No newline at end of file diff --git a/B_main/withdrawal_utils.py b/B_main/withdrawal_utils.py new file mode 100644 index 0000000..74d0257 --- /dev/null +++ b/B_main/withdrawal_utils.py @@ -0,0 +1,277 @@ +""" +회원탈퇴 처리를 위한 유틸리티 함수들 +""" +from django.contrib.auth.models import User +from django.utils import timezone +from .models import Person, WithdrawalRequest +from .peopleinfo import PEOPLE + +from .log_utils import log_user_activity +from datetime import datetime + + +def find_original_person_data(person_name, phone_number): + """ + peopleinfo.py에서 원본 Person 데이터 찾기 + + Args: + person_name: 사람 이름 + phone_number: 전화번호 (대시 포함 또는 미포함) + + Returns: + dict: 원본 Person 데이터 또는 None + """ + # 전화번호 정규화 (대시 제거) + clean_phone = phone_number.replace('-', '').replace(' ', '') + + for person_data in PEOPLE: + original_name = person_data.get('이름', '') + original_phone = person_data.get('연락처', '').replace('-', '').replace(' ', '') + + if original_name == person_name and original_phone == clean_phone: + return person_data + + return None + + +def backup_user_data(withdrawal_request): + """ + 탈퇴 전 사용자 데이터 백업 + + Args: + withdrawal_request: WithdrawalRequest 객체 + """ + try: + person = withdrawal_request.person + user = withdrawal_request.user + + backup_data = { + 'user_info': { + 'username': user.username, + 'email': user.email, + 'date_joined': user.date_joined.isoformat() if user.date_joined else None, + 'last_login': user.last_login.isoformat() if user.last_login else None, + }, + 'person_info': { + '이름': person.이름, + '소속': person.소속, + '생년월일': person.생년월일.isoformat() if person.생년월일 else None, + '직책': person.직책, + '연락처': person.연락처, + '주소': person.주소, + '사진': person.사진.name if person.사진 else None, + 'TITLE': person.TITLE, + 'SEQUENCE': person.SEQUENCE, + 'keyword1': person.keyword1, + '소개글': person.소개글, + '모든사람보기권한': person.모든사람보기권한, + '비밀번호설정필요': person.비밀번호설정필요, + '가입일시': person.가입일시.isoformat() if person.가입일시 else None, + } + } + + withdrawal_request.backup_data = backup_data + withdrawal_request.save() + + print(f"[WITHDRAWAL] 사용자 데이터 백업 완료: {person.이름}") + return True + + except Exception as e: + print(f"[WITHDRAWAL_ERROR] 사용자 데이터 백업 실패: {e}") + return False + + +def restore_person_to_original(person): + """ + Person 데이터를 peopleinfo.py의 원본으로 복원 + + Args: + person: Person 객체 + + Returns: + bool: 성공 여부 + """ + try: + original_data = find_original_person_data(person.이름, person.연락처) + + if not original_data: + print(f"[WITHDRAWAL_ERROR] 원본 데이터를 찾을 수 없음: {person.이름} ({person.연락처})") + return False + + # 원본 데이터로 복원 + person.소속 = original_data.get('소속', '') + + # 생년월일 파싱 + birth_str = original_data.get('생년월일', '') + if birth_str: + try: + if '.' in birth_str: + person.생년월일 = datetime.strptime(birth_str, '%Y.%m.%d').date() + elif len(birth_str) == 4: + person.생년월일 = datetime.strptime(f"{birth_str}.01.01", '%Y.%m.%d').date() + else: + person.생년월일 = None + except ValueError: + person.생년월일 = None + else: + person.생년월일 = None + + person.직책 = original_data.get('직책', '') + person.주소 = original_data.get('주소', '') + + # 사진 경로 처리 + photo = original_data.get('사진', 'profile_photos/default_user.png') + if photo.startswith('media/'): + photo = photo[6:] + person.사진 = photo + + person.TITLE = original_data.get('TITLE', '') + + # SEQUENCE 처리 + sequence = original_data.get('SEQUENCE', None) + if sequence and sequence != '': + try: + person.SEQUENCE = int(sequence) + except ValueError: + person.SEQUENCE = None + else: + person.SEQUENCE = None + + # 회원가입 시 추가된 정보들 초기화 + person.user = None # User 연결 해제 + person.keyword1 = None + person.소개글 = None + person.모든사람보기권한 = False + person.비밀번호설정필요 = False + person.가입일시 = None + + person.save() + + print(f"[WITHDRAWAL] Person 데이터 원본 복원 완료: {person.이름}") + return True + + except Exception as e: + print(f"[WITHDRAWAL_ERROR] Person 데이터 복원 실패: {e}") + return False + + +def process_withdrawal_approval(withdrawal_request, approved_by, admin_notes=None): + """ + 회원탈퇴 승인 처리 + + Args: + withdrawal_request: WithdrawalRequest 객체 + approved_by: 승인자 (User 객체) + admin_notes: 관리자 메모 (선택사항) + + Returns: + bool: 성공 여부 + """ + try: + # 1. 사용자 데이터 백업 + if not backup_user_data(withdrawal_request): + return False + + # 2. Person 데이터를 원본으로 복원 + if not restore_person_to_original(withdrawal_request.person): + return False + + # 3. WithdrawalRequest 상태 업데이트 (User 삭제 전에) + withdrawal_request.status = 'APPROVED' + withdrawal_request.approved_by = approved_by + withdrawal_request.approved_date = timezone.now() + withdrawal_request.admin_notes = admin_notes + withdrawal_request.save() + + # 4. SMS 발송 (User 삭제 전에) + try: + from A_core.sms_utils import send_withdrawal_approval_sms + phone_number = withdrawal_request.user.username # username이 전화번호 + name = withdrawal_request.person.이름 + sms_result = send_withdrawal_approval_sms(phone_number, name) + if sms_result.get('success'): + print(f"[SMS] 탈퇴 승인 SMS 발송 성공: {name} ({phone_number})") + else: + print(f"[SMS_ERROR] 탈퇴 승인 SMS 발송 실패: {sms_result.get('error', 'Unknown error')}") + except Exception as e: + print(f"[SMS_ERROR] 탈퇴 승인 SMS 발송 중 오류: {e}") + + # 5. 탈퇴 로그 기록 (User 삭제 전에) + try: + from .log_utils import log_withdrawal_approval + # 간단한 request 객체 모방 (로그 기록용) + class SimpleRequest: + def __init__(self): + self.path = '/admin/withdrawal_approval/' + self.method = 'POST' + self.META = {'HTTP_REFERER': '', 'HTTP_USER_AGENT': 'Admin System'} + self.session = {'session_key': 'admin_session'} + def session_key(self): + return 'admin_session' + + fake_request = SimpleRequest() + log_withdrawal_approval( + fake_request, + approved_by, + withdrawal_request.user.username, + withdrawal_request.person.이름, + withdrawal_request.id + ) + except Exception as e: + print(f"[WITHDRAWAL_WARNING] 탈퇴 로그 기록 실패: {e}") + + # 6. User 삭제 + user_to_delete = withdrawal_request.user + user_username = user_to_delete.username + user_to_delete.delete() + + # 7. WithdrawalRequest의 user 필드를 None으로 설정 (이미 SET_NULL이므로 자동 처리됨) + + print(f"[WITHDRAWAL] 회원탈퇴 승인 처리 완료: {user_username}") + return True + + except Exception as e: + print(f"[WITHDRAWAL_ERROR] 회원탈퇴 승인 처리 실패: {e}") + return False + + +def reject_withdrawal_request(withdrawal_request, approved_by, admin_notes=None): + """ + 회원탈퇴 요청 거부 + + Args: + withdrawal_request: WithdrawalRequest 객체 + approved_by: 처리자 (User 객체) + admin_notes: 관리자 메모 (선택사항) + + Returns: + bool: 성공 여부 + """ + try: + # 1. SMS 발송 (상태 변경 전에) + try: + from A_core.sms_utils import send_withdrawal_rejection_sms + phone_number = withdrawal_request.user.username # username이 전화번호 + name = withdrawal_request.person.이름 + reason = admin_notes # 관리자 메모를 거부 사유로 사용 + sms_result = send_withdrawal_rejection_sms(phone_number, name, reason) + if sms_result.get('success'): + print(f"[SMS] 탈퇴 거부 SMS 발송 성공: {name} ({phone_number})") + else: + print(f"[SMS_ERROR] 탈퇴 거부 SMS 발송 실패: {sms_result.get('error', 'Unknown error')}") + except Exception as e: + print(f"[SMS_ERROR] 탈퇴 거부 SMS 발송 중 오류: {e}") + + # 2. 상태 변경 + withdrawal_request.status = 'REJECTED' + withdrawal_request.approved_by = approved_by + withdrawal_request.approved_date = timezone.now() + withdrawal_request.admin_notes = admin_notes + withdrawal_request.save() + + print(f"[WITHDRAWAL] 회원탈퇴 요청 거부: {withdrawal_request.person.이름}") + return True + + except Exception as e: + print(f"[WITHDRAWAL_ERROR] 회원탈퇴 요청 거부 실패: {e}") + return False diff --git a/C_accounts/__pycache__/adapter.cpython-38.pyc b/C_accounts/__pycache__/adapter.cpython-38.pyc new file mode 100644 index 0000000..fd27cd3 Binary files /dev/null and b/C_accounts/__pycache__/adapter.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/admin.cpython-313.pyc b/C_accounts/__pycache__/admin.cpython-313.pyc index ca0bb07..dca992a 100644 Binary files a/C_accounts/__pycache__/admin.cpython-313.pyc and b/C_accounts/__pycache__/admin.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/apps.cpython-313.pyc b/C_accounts/__pycache__/apps.cpython-313.pyc index 66ac349..10cec83 100644 Binary files a/C_accounts/__pycache__/apps.cpython-313.pyc and b/C_accounts/__pycache__/apps.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/forms.cpython-313.pyc b/C_accounts/__pycache__/forms.cpython-313.pyc index 38cbf07..d4f9ffa 100644 Binary files a/C_accounts/__pycache__/forms.cpython-313.pyc and b/C_accounts/__pycache__/forms.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/forms.cpython-38.pyc b/C_accounts/__pycache__/forms.cpython-38.pyc index b9c3f6c..e47bb29 100644 Binary files a/C_accounts/__pycache__/forms.cpython-38.pyc and b/C_accounts/__pycache__/forms.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/middleware.cpython-313.pyc b/C_accounts/__pycache__/middleware.cpython-313.pyc index 65224e5..4502ed7 100644 Binary files a/C_accounts/__pycache__/middleware.cpython-313.pyc and b/C_accounts/__pycache__/middleware.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/models.cpython-313.pyc b/C_accounts/__pycache__/models.cpython-313.pyc index dc65266..f1b253c 100644 Binary files a/C_accounts/__pycache__/models.cpython-313.pyc and b/C_accounts/__pycache__/models.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/signals.cpython-313.pyc b/C_accounts/__pycache__/signals.cpython-313.pyc index d34cb5b..30d3722 100644 Binary files a/C_accounts/__pycache__/signals.cpython-313.pyc and b/C_accounts/__pycache__/signals.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/urls.cpython-313.pyc b/C_accounts/__pycache__/urls.cpython-313.pyc index 4394df4..8b53fc7 100644 Binary files a/C_accounts/__pycache__/urls.cpython-313.pyc and b/C_accounts/__pycache__/urls.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/urls.cpython-38.pyc b/C_accounts/__pycache__/urls.cpython-38.pyc index 2721ec9..4bcfbab 100644 Binary files a/C_accounts/__pycache__/urls.cpython-38.pyc and b/C_accounts/__pycache__/urls.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/views.cpython-313.pyc b/C_accounts/__pycache__/views.cpython-313.pyc index 53a5c3e..b330e57 100644 Binary files a/C_accounts/__pycache__/views.cpython-313.pyc and b/C_accounts/__pycache__/views.cpython-313.pyc differ diff --git a/C_accounts/__pycache__/views.cpython-38.pyc b/C_accounts/__pycache__/views.cpython-38.pyc index 649d2b4..26e1d0a 100644 Binary files a/C_accounts/__pycache__/views.cpython-38.pyc and b/C_accounts/__pycache__/views.cpython-38.pyc differ diff --git a/C_accounts/forms.py b/C_accounts/forms.py index 56ea6be..de7dd49 100644 --- a/C_accounts/forms.py +++ b/C_accounts/forms.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.auth import get_user_model -from B_main.models import Person # 또는 Person 모델이 정의된 경로로 import +from B_main.models import Person, WithdrawalRequest # 또는 Person 모델이 정의된 경로로 import import random import re @@ -19,61 +19,97 @@ class CustomFileInput(forms.FileInput): return context class ProfileFullEditForm(forms.ModelForm): - # 통합된 이름 필드 (편집 불가능) + # 읽기 전용 필드들 full_name = forms.CharField( label="이름", required=False, widget=forms.TextInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-600 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 focus:outline-none transition-colors duration-300', 'readonly': 'readonly', 'placeholder': '이름' }) ) + + phone_display = forms.CharField( + label="전화번호", + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 focus:outline-none transition-colors duration-300', + 'readonly': 'readonly', + 'placeholder': '전화번호' + }) + ) + + birth_date_display = forms.CharField( + label="생년월일", + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 focus:outline-none transition-colors duration-300', + 'readonly': 'readonly', + 'placeholder': '생년월일' + }) + ) + + amp_title_display = forms.CharField( + label="AMP내직책", + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 focus:outline-none transition-colors duration-300', + 'readonly': 'readonly', + 'placeholder': 'AMP내직책' + }) + ) class Meta: model = Person fields = [ - '소속', '직책', '주소', '사진', 'keyword1' + '소속', '직책', '주소', 'keyword1', '소개글' ] widgets = { '소속': forms.TextInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', - 'placeholder': '소속' + 'class': 'w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-300', + 'placeholder': '소속 (예: 신라대학교 회계학과)' }), '직책': forms.TextInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', - 'placeholder': '직책' + 'class': 'w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-300', + 'placeholder': '직책 (예: 교수, 학생, 직원)' }), '주소': forms.TextInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'class': 'w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-300', 'placeholder': '주소' }), - '사진': CustomFileInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', - 'accept': 'image/*' - }), 'keyword1': forms.TextInput(attrs={ - 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'class': 'w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-300', 'placeholder': '검색 키워드 (예: 회계감사)' }), + '소개글': forms.Textarea(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-300', + 'placeholder': '자신을 소개하는 간단한 글을 작성하세요 (최대 200자)', + 'rows': 4, + 'maxlength': 200 + }), } def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super().__init__(*args, **kwargs) - # 통합된 이름 설정 (first_name + last_name) - full_name = f"{self.user.first_name or ''} {self.user.last_name or ''}".strip() - self.fields['full_name'].initial = full_name - - # Person 모델 필드 초기값 설정 (기존 인스턴스가 있는 경우) + # 읽기 전용 필드들 초기값 설정 if self.instance and self.instance.pk: - # 기존 Person 인스턴스의 데이터로 초기화 - for field_name in self.fields: - if field_name == 'full_name': - continue - if hasattr(self.instance, field_name): - self.fields[field_name].initial = getattr(self.instance, field_name) + # 이름 설정 (Person 모델의 이름 필드 사용) + self.fields['full_name'].initial = self.instance.이름 + + # 전화번호 설정 (Person 모델의 연락처 필드 사용) + self.fields['phone_display'].initial = self.instance.연락처 + + # 생년월일 설정 + if self.instance.생년월일: + self.fields['birth_date_display'].initial = self.instance.생년월일.strftime('%Y-%m-%d') + else: + self.fields['birth_date_display'].initial = '설정되지 않음' + + # AMP내직책 설정 (TITLE 필드) + self.fields['amp_title_display'].initial = self.instance.TITLE or '설정되지 않음' def save(self, commit=True): # Person 모델 저장 (User 모델은 수정하지 않음) @@ -234,10 +270,6 @@ class PasswordChangeStep1Form(forms.Form): return cleaned_data - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user', None) - super().__init__(*args, **kwargs) - class PasswordChangeStep2Form(forms.Form): """비밀번호 변경 2단계: 새 비밀번호 입력""" new_password1 = forms.CharField( @@ -267,3 +299,40 @@ class PasswordChangeStep2Form(forms.Form): raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.') return cleaned_data + +class WithdrawalRequestForm(forms.ModelForm): + """회원탈퇴 요청 폼""" + + confirm_withdrawal = forms.BooleanField( + required=True, + label='위 주의사항을 모두 확인했으며, 회원탈퇴를 요청합니다', + widget=forms.CheckboxInput(attrs={ + 'class': 'w-4 h-4 text-red-600 bg-gray-700 border-gray-600 rounded focus:ring-red-500 focus:ring-2' + }), + error_messages={ + 'required': '탈퇴 확인을 체크해주세요' + } + ) + + class Meta: + model = WithdrawalRequest + fields = [] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + def save(self, commit=True): + withdrawal_request = super().save(commit=False) + + if self.user: + withdrawal_request.user = self.user + try: + withdrawal_request.person = Person.objects.get(user=self.user) + except Person.DoesNotExist: + raise forms.ValidationError('사용자의 Person 정보를 찾을 수 없습니다.') + + if commit: + withdrawal_request.save() + + return withdrawal_request \ No newline at end of file diff --git a/C_accounts/templates/C_accounts/force_password_set.html b/C_accounts/templates/C_accounts/force_password_set.html index da0cb5c..a6299c8 100644 --- a/C_accounts/templates/C_accounts/force_password_set.html +++ b/C_accounts/templates/C_accounts/force_password_set.html @@ -58,7 +58,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
-

신라대학교 AMP 제8기

+ 신라대학교 AMP 제8기
{% if user.is_authenticated %}
diff --git a/C_accounts/templates/C_accounts/password_change_logged_in.html b/C_accounts/templates/C_accounts/password_change_logged_in.html index 229324a..ac4155a 100644 --- a/C_accounts/templates/C_accounts/password_change_logged_in.html +++ b/C_accounts/templates/C_accounts/password_change_logged_in.html @@ -58,7 +58,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
-

신라대학교 AMP 제8기

+ 신라대학교 AMP 제8기
{% if user.is_authenticated %}
diff --git a/C_accounts/templates/C_accounts/profile_edit.html b/C_accounts/templates/C_accounts/profile_edit.html index 92c8f3c..9eed0c2 100644 --- a/C_accounts/templates/C_accounts/profile_edit.html +++ b/C_accounts/templates/C_accounts/profile_edit.html @@ -54,11 +54,10 @@ input[type="file"]::-webkit-file-upload-button:hover { } -
-

신라대학교 AMP 제8기

+ 신라대학교 AMP 제8기
{% if user.is_authenticated %}
@@ -75,7 +74,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
-
- +
{% elif request.session.authenticated %} 로그아웃 @@ -155,10 +132,10 @@ input[type="file"]::-webkit-file-upload-button:hover {
-
+
-

프로필 수정

-

개인 정보를 수정하세요

+

프로필 수정

+

개인 정보를 수정하세요

{% if messages %} @@ -181,70 +158,58 @@ input[type="file"]::-webkit-file-upload-button:hover {
{% endif %} - -
- - {{ form.full_name }} -
- -
- - -
- - {% if form.instance.생년월일 %} -
- - -
- {% endif %} - - {% if form.instance.TITLE %} -
- - -
- {% endif %} - - -
- - {{ form.소속 }} -
-
- - {{ form.직책 }} -
-
- - {{ form.주소 }} -
-
- - {{ form.사진 }} + +
- {% if form.instance.사진 and form.instance.사진.url %} -
- 프로필 사진 미리보기 -
- {% else %} -
- 프로필 사진 미리보기 -
- {% endif %} +
+ + {{ form.full_name }} +
+ +
+ + {{ form.phone_display }} +
+ +
+ + {{ form.birth_date_display }} +
+ +
+ + {{ form.amp_title_display }} +
- -
-

검색 키워드

-

다른 사람들이 당신을 찾을 수 있도록 키워드를 설정하세요

+ +
-
+
+ + {{ form.소속 }} +
+ +
+ + {{ form.직책 }} +
+ +
+ + {{ form.주소 }} +
+ +
+ {{ form.keyword1 }}
+ +
+ + {{ form.소개글 }} +

최대 200자까지 입력 가능합니다.

+
diff --git a/C_accounts/templates/C_accounts/withdrawal_request.html b/C_accounts/templates/C_accounts/withdrawal_request.html new file mode 100644 index 0000000..d5ccc15 --- /dev/null +++ b/C_accounts/templates/C_accounts/withdrawal_request.html @@ -0,0 +1,98 @@ + + + + + + 회원탈퇴 요청 | 신라 AMP + + + + + + +
+
+ 신라 AMP +

회원탈퇴 요청

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

탈퇴 시 주의사항

+
+

• 회원탈퇴 시 계정이 완전히 삭제됩니다

+

• 탈퇴 후 재가입 시 기존 정보가 초기화됩니다

+

• 탈퇴 승인은 관리자가 처리합니다

+
+
+
+
+ + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + + {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {% for error in errors %} +
{{ error }}
+ {% endfor %} + {% endfor %} +
+ {% endif %} + + +
+
+ {{ form.confirm_withdrawal }} +
+ +

체크 시 탈퇴 요청이 관리자에게 전송됩니다.

+
+
+
+ + +
+ + + + 취소 + +
+
+ +
+ + + diff --git a/C_accounts/templatetags/__pycache__/form_filters.cpython-313.pyc b/C_accounts/templatetags/__pycache__/form_filters.cpython-313.pyc index 452b5af..34216f2 100644 Binary files a/C_accounts/templatetags/__pycache__/form_filters.cpython-313.pyc and b/C_accounts/templatetags/__pycache__/form_filters.cpython-313.pyc differ diff --git a/C_accounts/urls.py b/C_accounts/urls.py index c3e9313..928dcec 100644 --- a/C_accounts/urls.py +++ b/C_accounts/urls.py @@ -10,4 +10,5 @@ urlpatterns = [ path('password_reset/', views.password_reset, name='password_reset'), path('password_change_logged_in/', views.password_change_logged_in, name='password_change_logged_in'), path('force_password_set/', views.force_password_set, name='force_password_set'), + path('withdrawal_request/', views.withdrawal_request, name='withdrawal_request'), ] diff --git a/C_accounts/views.py b/C_accounts/views.py index c6879ea..f6b1458 100644 --- a/C_accounts/views.py +++ b/C_accounts/views.py @@ -5,10 +5,14 @@ from django.contrib.auth import get_user_model from django.http import JsonResponse from .forms import ( ProfileFullEditForm, PasswordChangeStep1Form, PasswordChangeStep2Form, - PasswordResetStep1Form, PasswordChangeLoggedInForm, ForcePasswordSetForm + PasswordResetStep1Form, PasswordChangeLoggedInForm, ForcePasswordSetForm, + WithdrawalRequestForm ) -from B_main.models import Person +from B_main.models import Person, WithdrawalRequest +from A_core.sms_utils import send_verification_sms +from B_main.log_utils import log_profile_update, log_password_change, log_phone_verification, log_withdrawal_request import random +import time User = get_user_model() @@ -25,7 +29,43 @@ def profile_edit(request): if request.method == 'POST': form = ProfileFullEditForm(request.POST, request.FILES, user=request.user, instance=person) if form.is_valid(): + # 변경된 필드와 변경 전/후 값 추적 + changed_fields = [] + field_changes = {} + + if form.has_changed(): + changed_fields = form.changed_data + + # 각 변경된 필드의 이전 값과 새 값 기록 + for field_name in changed_fields: + # 한국어 필드명으로 매핑 + field_display_names = { + 'keyword1': '검색키워드', + '소개글': '소개글', + } + display_name = field_display_names.get(field_name, field_name) + + # 이전 값 (form.initial에서 가져오기) + old_value = form.initial.get(field_name, '') + # 새 값 (cleaned_data에서 가져오기) + new_value = form.cleaned_data.get(field_name, '') + + # 빈 값 처리 + if old_value is None: + old_value = '' + if new_value is None: + new_value = '' + + field_changes[display_name] = { + 'old': str(old_value), + 'new': str(new_value) + } + form.save() + + # 프로필 수정 로그 기록 + log_profile_update(request, request.user, changed_fields, field_changes) + messages.success(request, '프로필이 성공적으로 업데이트되었습니다.') return redirect('accounts:custom_profile_edit') else: @@ -58,15 +98,23 @@ def password_change(request): form1 = PasswordChangeStep1Form(request.POST, user=request.user) if form1.is_valid(): phone = form1.cleaned_data['phone'] - # 인증번호 생성 (실제로는 SMS 발송) + # 인증번호 생성 및 실제 SMS 발송 verification_code = str(random.randint(100000, 999999)) - print(f"[DEBUG] 인증번호: {verification_code}") # 실제로는 SMS 발송 - request.session['password_change_code'] = verification_code - request.session['password_change_phone'] = phone - request.session['password_change_step'] = 1 - message = '인증번호가 발송되었습니다.' - code_sent = True + # 실제 SMS 발송 + sms_result = send_verification_sms(phone, verification_code) + + if sms_result['success']: + request.session['password_change_code'] = verification_code + request.session['password_change_phone'] = phone + request.session['password_change_step'] = 1 + request.session['password_change_code_sent_at'] = int(time.time()) + message = '인증번호가 발송되었습니다.' + code_sent = True + print(f"[DEBUG] 비밀번호 변경 SMS 발송 성공: {phone} - {verification_code}") + else: + error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.' + print(f"[DEBUG] 비밀번호 변경 SMS 발송 실패: {sms_result['error']}") else: error = '전화번호를 확인해주세요.' elif action == 'verify_code': @@ -74,8 +122,13 @@ def password_change(request): if form1.is_valid(): input_code = form1.cleaned_data['verification_code'] stored_code = request.session.get('password_change_code') + code_sent_at = request.session.get('password_change_code_sent_at', 0) + current_time = int(time.time()) - if input_code == stored_code: + # 인증번호 만료 시간 체크 (3분) + if current_time - code_sent_at > 180: + error = '인증번호가 만료되었습니다. 다시 발송해주세요.' + elif input_code == stored_code: request.session['password_change_verified'] = True request.session['password_change_step'] = 2 return redirect('accounts:password_change') @@ -90,7 +143,24 @@ def password_change(request): 'step': 1, 'form1': form1, 'code_sent': code_sent, 'error': error, 'message': message }) - elif step == 2 and verified and phone: + elif step == 2: + # 세션이 만료되어 인증 정보가 없는 경우 + if not verified or not phone: + # 세션 초기화 + request.session['password_change_step'] = 1 + request.session['password_change_verified'] = False + for key in ['password_change_code', 'password_change_phone', 'password_change_code_sent_at']: + request.session.pop(key, None) + + form1 = PasswordChangeStep1Form(user=request.user) + return render(request, 'C_accounts/password_change.html', { + 'step': 1, + 'form1': form1, + 'code_sent': False, + 'error': '세션이 만료되었습니다. 다시 인증해주세요.', + 'message': None + }) + if request.method == 'POST': form2 = PasswordChangeStep2Form(request.POST) if form2.is_valid(): @@ -98,6 +168,9 @@ def password_change(request): request.user.set_password(new_password) request.user.save() + # 비밀번호 변경 로그 기록 + log_password_change(request, request.user) + # 세션 정리 del request.session['password_change_step'] del request.session['password_change_code'] @@ -146,15 +219,23 @@ def password_reset(request): form1 = PasswordResetStep1Form(request.POST) if form1.is_valid(): phone = form1.cleaned_data['phone'] - # 인증번호 생성 (실제로는 SMS 발송) + # 인증번호 생성 및 실제 SMS 발송 verification_code = str(random.randint(100000, 999999)) - print(f"[DEBUG] 비밀번호 찾기 인증번호: {verification_code}") # 실제로는 SMS 발송 - request.session['password_reset_code'] = verification_code - request.session['password_reset_phone'] = phone - request.session['password_reset_step'] = 1 - message = '인증번호가 발송되었습니다.' - code_sent = True + # 실제 SMS 발송 + sms_result = send_verification_sms(phone, verification_code) + + if sms_result['success']: + request.session['password_reset_code'] = verification_code + request.session['password_reset_phone'] = phone + request.session['password_reset_step'] = 1 + request.session['password_reset_code_sent_at'] = int(time.time()) + message = '인증번호가 발송되었습니다.' + code_sent = True + print(f"[DEBUG] 비밀번호 찾기 SMS 발송 성공: {phone} - {verification_code}") + else: + error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.' + print(f"[DEBUG] 비밀번호 찾기 SMS 발송 실패: {sms_result['error']}") else: error = '전화번호를 확인해주세요.' elif action == 'verify_code': @@ -162,8 +243,13 @@ def password_reset(request): if form1.is_valid(): input_code = form1.cleaned_data['verification_code'] stored_code = request.session.get('password_reset_code') + code_sent_at = request.session.get('password_reset_code_sent_at', 0) + current_time = int(time.time()) - if input_code == stored_code: + # 인증번호 만료 시간 체크 (3분) + if current_time - code_sent_at > 180: + error = '인증번호가 만료되었습니다. 다시 발송해주세요.' + elif input_code == stored_code: request.session['password_reset_verified'] = True request.session['password_reset_step'] = 2 return redirect('accounts:password_reset') @@ -178,7 +264,24 @@ def password_reset(request): 'step': 1, 'form1': form1, 'code_sent': code_sent, 'error': error, 'message': message }) - elif step == 2 and verified and phone: + elif step == 2: + # 세션이 만료되어 인증 정보가 없는 경우 + if not verified or not phone: + # 세션 초기화 + request.session['password_reset_step'] = 1 + request.session['password_reset_verified'] = False + for key in ['password_reset_code', 'password_reset_phone', 'password_reset_code_sent_at']: + request.session.pop(key, None) + + form1 = PasswordResetStep1Form() + return render(request, 'C_accounts/password_reset.html', { + 'step': 1, + 'form1': form1, + 'code_sent': False, + 'error': '세션이 만료되었습니다. 다시 인증해주세요.', + 'message': None + }) + if request.method == 'POST': form2 = ForcePasswordSetForm(request.POST) if form2.is_valid(): @@ -225,6 +328,9 @@ def password_change_logged_in(request): request.user.set_password(new_password) request.user.save() + # 비밀번호 변경 로그 기록 + log_password_change(request, request.user) + messages.success(request, '비밀번호가 성공적으로 변경되었습니다.') return redirect('accounts:custom_profile_edit') else: @@ -251,6 +357,9 @@ def force_password_set(request): request.user.set_password(new_password) request.user.save() + # 비밀번호 변경 로그 기록 + log_password_change(request, request.user) + # 비밀번호 설정 필요 플래그 해제 person.비밀번호설정필요 = False person.save() @@ -269,3 +378,44 @@ def force_password_set(request): return render(request, 'C_accounts/force_password_set.html', {'form': form}) +@login_required +def withdrawal_request(request): + """회원탈퇴 요청 뷰""" + + # 이미 탈퇴 요청이 있는지 확인 + existing_request = WithdrawalRequest.objects.filter( + user=request.user, + status='PENDING' + ).first() + + if existing_request: + messages.info(request, '이미 탈퇴 요청이 진행 중입니다. 관리자 승인을 기다려주세요.') + return redirect('accounts:custom_profile_edit') + + if request.method == 'POST': + form = WithdrawalRequestForm(request.POST, user=request.user) + if form.is_valid(): + withdrawal_request = form.save() + + # 탈퇴 요청 로그 기록 + log_withdrawal_request(request, request.user, withdrawal_request.id) + + # 백그라운드에서 관리자에게 이메일 발송 + try: + from B_main.email_utils import send_withdrawal_request_notification + send_withdrawal_request_notification( + user=request.user, + person=request.user.person, + reason=withdrawal_request.reason + ) + except Exception as e: + print(f"[EMAIL_ERROR] 탈퇴 요청 이메일 발송 실패: {e}") + + messages.success(request, '탈퇴 요청이 접수되었습니다. 관리자 승인 후 처리됩니다.') + return redirect('accounts:custom_profile_edit') + else: + form = WithdrawalRequestForm(user=request.user) + + return render(request, 'C_accounts/withdrawal_request.html', {'form': form}) + + diff --git a/db.sqlite3 b/db.sqlite3 index b5b3a33..83b9ba4 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/media/profile_photos/Bongsu_TwLuh2Z.jpg b/media/profile_photos/Bongsu_TwLuh2Z.jpg new file mode 100644 index 0000000..d646adf Binary files /dev/null and b/media/profile_photos/Bongsu_TwLuh2Z.jpg differ diff --git a/naver_cloud_sms_config.txt b/naver_cloud_sms_config.txt new file mode 100644 index 0000000..7a16a93 --- /dev/null +++ b/naver_cloud_sms_config.txt @@ -0,0 +1,82 @@ +# 네이버 클라우드 플랫폼 SMS 설정 가이드 + +## 1. 네이버 클라우드 플랫폼 설정 + +### 1.1 네이버 클라우드 플랫폼 가입 +- https://www.ncloud.com/ 에서 회원가입 +- 본인인증 및 결제 수단 등록 + +### 1.2 SMS 서비스 활성화 +1. 네이버 클라우드 콘솔 접속 +2. AI·NAVER API > SENS > SMS 선택 +3. SMS 서비스 신청 및 활성화 + +### 1.3 프로젝트 생성 및 API 키 발급 +1. 프로젝트 생성 +2. IAM > Access Key Management에서 Access Key 생성 +3. Secret Key 확인 (생성 시에만 확인 가능) + +### 1.4 SMS 서비스 ID 확인 +1. SENS > SMS 서비스에서 Service ID 확인 +2. 발신번호 등록 (사전 승인된 번호만 사용 가능) + +## 2. 환경 변수 설정 + +프로젝트 루트에 `.env` 파일을 생성하고 다음 내용을 입력하세요: + +``` +# 네이버 클라우드 플랫폼 SMS 설정 +# 스크린샷에서 확인한 정보를 입력하세요 + +# Access Key ID (스크린샷의 "Access Key ID" 값) +NAVER_CLOUD_ACCESS_KEY=ncp_iam_BPAMKR1m30ZhNpesC6mm + +# Secret Key (스크린샷의 "Secret Key" 보기 버튼 클릭 후 확인한 값) +NAVER_CLOUD_SECRET_KEY=your_secret_key_here + +# SMS 서비스 ID (SENS > SMS 서비스에서 확인) +NAVER_CLOUD_SMS_SERVICE_ID=your_service_id_here + +# 발신번호 (사전 승인된 번호만 사용 가능) +NAVER_CLOUD_SMS_SENDER_PHONE=your_sender_phone_here + +# SMS 인증 설정 +SMS_VERIFICATION_TIMEOUT=180 +SMS_MAX_RETRY_COUNT=3 +``` + +## 3. 설정 값 설명 + +- `NAVER_CLOUD_ACCESS_KEY`: 네이버 클라우드 플랫폼에서 발급받은 Access Key +- `NAVER_CLOUD_SECRET_KEY`: 네이버 클라우드 플랫폼에서 발급받은 Secret Key +- `NAVER_CLOUD_SMS_SERVICE_ID`: SMS 서비스 ID (ncp:sms:kr:xxxxx:xxxxx 형식) +- `NAVER_CLOUD_SMS_SENDER_PHONE`: 사전 승인된 발신번호 (예: 01012345678) +- `SMS_VERIFICATION_TIMEOUT`: 인증번호 유효시간 (초, 기본값: 180초) +- `SMS_MAX_RETRY_COUNT`: 최대 재발송 횟수 (기본값: 3회) + +## 4. 발신번호 등록 + +### 4.1 일반 발신번호 +- 사업자등록증, 통신사 이용증명서 등 필요 +- 승인까지 1-2일 소요 + +### 4.2 080 번호 +- 별도 신청 및 승인 필요 +- 더 빠른 승인 가능 + +## 5. 테스트 + +설정 완료 후 다음 명령어로 테스트: + +```bash +python manage.py runserver +``` + +회원가입 또는 비밀번호 찾기에서 실제 SMS 발송 테스트 + +## 6. 주의사항 + +1. `.env` 파일은 절대 Git에 커밋하지 마세요 +2. 실제 운영 환경에서는 환경 변수로 설정하는 것을 권장합니다 +3. SMS 발송 비용이 발생합니다 (건당 약 20원) +4. 발신번호는 반드시 사전 승인된 번호만 사용 가능합니다 diff --git a/run b/run index 6633c13..bc763e7 100644 --- a/run +++ b/run @@ -1,9 +1,11 @@ rm -rf /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2/staticfiles python /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2/manage.py collectstatic gunicorn A_core.wsgi:application --chdir /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2 --bind=192.168.1.119:4271 --daemon - +gunicorn A_core.wsgi:application --chdir /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2 --bind=192.168.1.119:4271 --daemon --workers=5 --timeout=30 --preload ssh qhdtn6412@kmobsk.synology.me -p 6422 -ps aux |grep 192.168.1.119:4271 \ No newline at end of file +ps aux |grep 192.168.1.119:4271 + +pkill -9 -f "gunicorn .*4271" \ No newline at end of file diff --git a/staticfiles/account/js/account.js b/staticfiles/account/js/account.js deleted file mode 100644 index fbc5135..0000000 --- a/staticfiles/account/js/account.js +++ /dev/null @@ -1,20 +0,0 @@ -(function () { - const allauth = window.allauth = window.allauth || {} - - function manageEmailForm (o) { - const actions = document.getElementsByName('action_remove') - if (actions.length) { - actions[0].addEventListener('click', function (e) { - if (!window.confirm(o.i18n.confirmDelete)) { - e.preventDefault() - } - }) - } - } - - allauth.account = { - forms: { - manageEmailForm - } - } -})() diff --git a/staticfiles/account/js/onload.js b/staticfiles/account/js/onload.js deleted file mode 100644 index 1a224c9..0000000 --- a/staticfiles/account/js/onload.js +++ /dev/null @@ -1,12 +0,0 @@ -(function () { - document.addEventListener('DOMContentLoaded', function () { - Array.from(document.querySelectorAll('script[data-allauth-onload]')).forEach(scriptElt => { - const funcRef = scriptElt.dataset.allauthOnload - if (typeof funcRef === 'string' && funcRef.startsWith('allauth.')) { - const funcArg = JSON.parse(scriptElt.textContent) - const func = funcRef.split('.').reduce((acc, part) => acc && acc[part], window) - func(funcArg) - } - }) - }) -})() diff --git a/staticfiles/admin/css/autocomplete.css b/staticfiles/admin/css/autocomplete.css index 7478c2c..69c94e7 100644 --- a/staticfiles/admin/css/autocomplete.css +++ b/staticfiles/admin/css/autocomplete.css @@ -273,7 +273,3 @@ select.admin-autocomplete { display: block; padding: 6px; } - -.errors .select2-selection { - border: 1px solid var(--error-fg); -} diff --git a/staticfiles/admin/css/base.css b/staticfiles/admin/css/base.css index 3791043..93db7d0 100644 --- a/staticfiles/admin/css/base.css +++ b/staticfiles/admin/css/base.css @@ -13,7 +13,6 @@ html[data-theme="light"], --body-fg: #333; --body-bg: #fff; --body-quiet-color: #666; - --body-medium-color: #444; --body-loud-color: #000; --header-color: #ffc; @@ -23,11 +22,11 @@ html[data-theme="light"], --breadcrumbs-fg: #c4dce8; --breadcrumbs-link-fg: var(--body-bg); - --breadcrumbs-bg: #264b5d; + --breadcrumbs-bg: var(--primary); --link-fg: #417893; --link-hover-color: #036; - --link-selected-fg: var(--secondary); + --link-selected-fg: #5b80b2; --hairline-color: #e8e8e8; --border-color: #ccc; @@ -43,10 +42,10 @@ html[data-theme="light"], --selected-row: #ffc; --button-fg: #fff; - --button-bg: var(--secondary); - --button-hover-bg: #205067; - --default-button-bg: #205067; - --default-button-hover-bg: var(--secondary); + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; --close-button-bg: #747474; --close-button-hover-bg: #333; --delete-button-bg: #ba2121; @@ -57,6 +56,8 @@ html[data-theme="light"], --object-tools-hover-bg: var(--close-button-hover-bg); --font-family-primary: + -apple-system, + BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, @@ -85,8 +86,6 @@ html[data-theme="light"], "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - - color-scheme: light; } html, body { @@ -150,6 +149,7 @@ h1 { margin: 0 0 20px; font-weight: 300; font-size: 1.25rem; + color: var(--body-quiet-color); } h2 { @@ -165,7 +165,7 @@ h2.subhead { h3 { font-size: 0.875rem; margin: .8em 0 .3em 0; - color: var(--body-medium-color); + color: var(--body-quiet-color); font-weight: bold; } @@ -173,7 +173,6 @@ h4 { font-size: 0.75rem; margin: 1em 0 .8em 0; padding-bottom: 3px; - color: var(--body-medium-color); } h5 { @@ -220,10 +219,6 @@ fieldset { border-top: 1px solid var(--hairline-color); } -details summary { - cursor: pointer; -} - blockquote { font-size: 0.6875rem; color: #777; @@ -320,7 +315,7 @@ td, th { } th { - font-weight: 500; + font-weight: 600; text-align: left; } @@ -341,7 +336,7 @@ tfoot td { } thead th.required { - font-weight: bold; + color: var(--body-loud-color); } tr.alt { @@ -489,13 +484,8 @@ textarea { vertical-align: top; } -/* -Minifiers remove the default (text) "type" attribute from "input" HTML tags. -Add input:not([type]) to make the CSS stylesheet work the same. -*/ -input:not([type]), input[type=text], input[type=password], input[type=email], -input[type=url], input[type=number], input[type=tel], textarea, select, -.vTextField { +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { border: 1px solid var(--border-color); border-radius: 4px; padding: 5px 6px; @@ -504,13 +494,9 @@ input[type=url], input[type=number], input[type=tel], textarea, select, background-color: var(--body-bg); } -/* -Minifiers remove the default (text) "type" attribute from "input" HTML tags. -Add input:not([type]) to make the CSS stylesheet work the same. -*/ -input:not([type]):focus, input[type=text]:focus, input[type=password]:focus, -input[type=email]:focus, input[type=url]:focus, input[type=number]:focus, -input[type=tel]:focus, textarea:focus, select:focus, .vTextField:focus { +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { border-color: var(--body-quiet-color); } @@ -600,7 +586,7 @@ input[type=button][disabled].default { font-weight: 400; font-size: 0.8125rem; text-align: left; - background: var(--header-bg); + background: var(--primary); color: var(--header-link-color); } @@ -736,11 +722,6 @@ div.breadcrumbs a:focus, div.breadcrumbs a:hover { background: url(../img/icon-viewlink.svg) 0 1px no-repeat; } -.hidelink { - padding-left: 16px; - background: url(../img/icon-hidelink.svg) 0 1px no-repeat; -} - .addlink { padding-left: 16px; background: url(../img/icon-addlink.svg) 0 1px no-repeat; @@ -850,6 +831,10 @@ a.deletelink:focus, a.deletelink:hover { height: 100%; } +#container > div { + flex-shrink: 0; +} + #container > .main { display: flex; flex: 1 0 auto; @@ -894,10 +879,9 @@ a.deletelink:focus, a.deletelink:hover { margin-right: -300px; } -@media (forced-colors: active) { - #content-related { - border: 1px solid; - } +#footer { + clear: both; + padding: 10px; } /* COLUMN TYPES */ @@ -935,6 +919,7 @@ a.deletelink:focus, a.deletelink:hover { padding: 10px 40px; background: var(--header-bg); color: var(--header-color); + overflow: hidden; } #header a:link, #header a:visited, #logout-form button { @@ -945,17 +930,11 @@ a.deletelink:focus, a.deletelink:hover { text-decoration: underline; } -@media (forced-colors: active) { - #header { - border-bottom: 1px solid; - } -} - #branding { display: flex; } -#site-name { +#branding h1 { padding: 0; margin: 0; margin-inline-end: 20px; @@ -964,7 +943,7 @@ a.deletelink:focus, a.deletelink:hover { color: var(--header-branding-color); } -#site-name a:link, #site-name a:visited { +#branding h1 a:link, #branding h1 a:visited { color: var(--accent); } @@ -1121,7 +1100,6 @@ a.deletelink:focus, a.deletelink:hover { margin: 0; border-top: 1px solid var(--hairline-color); width: 100%; - box-sizing: border-box; } .paginator a:link, .paginator a:visited { @@ -1165,16 +1143,3 @@ a.deletelink:focus, a.deletelink:hover { .base-svgs { display: none; } - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0,0,0,0); - white-space: nowrap; - border: 0; - color: var(--body-fg); - background-color: var(--body-bg); -} diff --git a/staticfiles/admin/css/changelists.css b/staticfiles/admin/css/changelists.css index 005b776..a754513 100644 --- a/staticfiles/admin/css/changelists.css +++ b/staticfiles/admin/css/changelists.css @@ -139,12 +139,6 @@ margin: 0 0 0 30px; } -@media (forced-colors: active) { - #changelist-filter { - border: 1px solid; - } -} - #changelist-filter h2 { font-size: 0.875rem; text-transform: uppercase; @@ -221,9 +215,9 @@ color: var(--link-hover-color); } -#changelist-filter #changelist-filter-extra-actions { +#changelist-filter #changelist-filter-clear a { font-size: 0.8125rem; - margin-bottom: 10px; + padding-bottom: 10px; border-bottom: 1px solid var(--hairline-color); } @@ -271,15 +265,6 @@ background-color: var(--selected-row); } -@media (forced-colors: active) { - #changelist tbody tr.selected { - background-color: SelectedItem; - } - #changelist tbody tr:has(.action-select:checked) { - background-color: SelectedItem; - } -} - #changelist .actions { padding: 10px; background: var(--body-bg); diff --git a/staticfiles/admin/css/dark_mode.css b/staticfiles/admin/css/dark_mode.css index 65b58d0..6d08233 100644 --- a/staticfiles/admin/css/dark_mode.css +++ b/staticfiles/admin/css/dark_mode.css @@ -5,8 +5,7 @@ --body-fg: #eeeeee; --body-bg: #121212; - --body-quiet-color: #d0d0d0; - --body-medium-color: #e0e0e0; + --body-quiet-color: #e0e0e0; --body-loud-color: #ffffff; --breadcrumbs-link-fg: #e0e0e0; @@ -30,8 +29,6 @@ --close-button-bg: #333333; --close-button-hover-bg: #666666; - - color-scheme: dark; } } @@ -42,8 +39,7 @@ html[data-theme="dark"] { --body-fg: #eeeeee; --body-bg: #121212; - --body-quiet-color: #d0d0d0; - --body-medium-color: #e0e0e0; + --body-quiet-color: #e0e0e0; --body-loud-color: #ffffff; --breadcrumbs-link-fg: #e0e0e0; @@ -67,8 +63,6 @@ html[data-theme="dark"] { --close-button-bg: #333333; --close-button-hover-bg: #666666; - - color-scheme: dark; } /* THEME SWITCH */ @@ -84,8 +78,8 @@ html[data-theme="dark"] { .theme-toggle svg { vertical-align: middle; - height: 1.5rem; - width: 1.5rem; + height: 1rem; + width: 1rem; display: none; } @@ -128,3 +122,16 @@ html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { display: block; } + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/staticfiles/admin/css/forms.css b/staticfiles/admin/css/forms.css index c6ce788..9a8dad0 100644 --- a/staticfiles/admin/css/forms.css +++ b/staticfiles/admin/css/forms.css @@ -44,6 +44,7 @@ label { .required label, label.required { font-weight: bold; + color: var(--body-fg); } /* RADIO BUTTONS */ @@ -75,20 +76,6 @@ form ul.inline li { padding-right: 7px; } -/* FIELDSETS */ - -fieldset .fieldset-heading, -fieldset .inline-heading, -:not(.inline-related) .collapse summary { - border: 1px solid var(--header-bg); - margin: 0; - padding: 8px; - font-weight: 400; - font-size: 0.8125rem; - background: var(--header-bg); - color: var(--header-link-color); -} - /* ALIGNED FIELDSETS */ .aligned label { @@ -97,12 +84,14 @@ fieldset .inline-heading, min-width: 160px; width: 160px; word-wrap: break-word; + line-height: 1; } .aligned label:not(.vCheckboxLabel):after { content: ''; display: inline-block; vertical-align: middle; + height: 1.625rem; } .aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { @@ -169,10 +158,6 @@ form .aligned select + div.help { padding-left: 10px; } -form .aligned select option:checked { - background-color: var(--selected-row); -} - form .aligned ul li { list-style: none; } @@ -183,7 +168,11 @@ form .aligned table p { } .aligned .vCheckboxLabel { - padding: 1px 0 0 5px; + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; } .aligned .vCheckboxLabel + p.help, @@ -205,8 +194,14 @@ fieldset .fieldBox { width: 200px; } -form .wide p.help, +form .wide p, form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, form .wide div.help { padding-left: 50px; } @@ -220,16 +215,35 @@ form div.help ul { width: 450px; } -/* COLLAPSIBLE FIELDSETS */ +/* COLLAPSED FIELDSETS */ -.collapse summary .fieldset-heading, -.collapse summary .inline-heading { +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { background: transparent; - border: none; - color: currentColor; display: inline; - margin: 0; - padding: 0; + color: var(--link-fg); } /* MONOSPACE TEXTAREAS */ @@ -381,16 +395,14 @@ body.popup .submit-row { position: relative; } -.inline-related h4, -.inline-related:not(.tabular) .collapse summary { +.inline-related h3 { margin: 0; - color: var(--body-medium-color); + color: var(--body-quiet-color); padding: 5px; font-size: 0.8125rem; background: var(--darkened-bg); - border: 1px solid var(--hairline-color); - border-left-color: var(--darkened-bg); - border-right-color: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); } .inline-related h3 span.delete { @@ -409,6 +421,16 @@ body.popup .submit-row { width: 100%; } +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + .inline-group .tabular fieldset.module { border: none; } @@ -449,6 +471,17 @@ body.popup .submit-row { _width: 700px; } +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + .inline-group div.add-row, .inline-group .tabular tr.add-row td { color: var(--body-quiet-color); @@ -462,8 +495,11 @@ body.popup .submit-row { border-bottom: 1px solid var(--hairline-color); } +.inline-group ul.tools a.add, .inline-group div.add-row a, .inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; font-size: 0.75rem; } diff --git a/staticfiles/admin/css/login.css b/staticfiles/admin/css/login.css index 805a34b..389772f 100644 --- a/staticfiles/admin/css/login.css +++ b/staticfiles/admin/css/login.css @@ -21,7 +21,7 @@ } .login #content { - padding: 20px; + padding: 20px 20px 0; } .login #container { diff --git a/staticfiles/admin/css/nav_sidebar.css b/staticfiles/admin/css/nav_sidebar.css index 7eb0de9..f76e6ce 100644 --- a/staticfiles/admin/css/nav_sidebar.css +++ b/staticfiles/admin/css/nav_sidebar.css @@ -102,12 +102,6 @@ background: var(--selected-row); } -@media (forced-colors: active) { - #nav-sidebar .current-model { - background-color: SelectedItem; - } -} - .main > #nav-sidebar + .content { max-width: calc(100% - 23px); } diff --git a/staticfiles/admin/css/responsive.css b/staticfiles/admin/css/responsive.css index f0fcade..1d0a188 100644 --- a/staticfiles/admin/css/responsive.css +++ b/staticfiles/admin/css/responsive.css @@ -43,7 +43,7 @@ input[type="submit"], button { justify-content: flex-start; } - #site-name { + #branding h1 { margin: 0 0 8px; line-height: 1.2; } @@ -171,14 +171,9 @@ input[type="submit"], button { /* Forms */ label { - font-size: 1rem; + font-size: 0.875rem; } - /* - Minifiers remove the default (text) "type" attribute from "input" HTML - tags. Add input:not([type]) to make the CSS stylesheet work the same. - */ - .form-row input:not([type]), .form-row input[type=text], .form-row input[type=password], .form-row input[type=email], @@ -192,7 +187,7 @@ input[type="submit"], button { margin: 0; padding: 6px 8px; min-height: 2.25rem; - font-size: 1rem; + font-size: 0.875rem; } .form-row select { @@ -242,6 +237,22 @@ input[type="submit"], button { padding: 7px; } + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + /* Selector */ .selector { @@ -254,8 +265,12 @@ input[type="submit"], button { align-items: center; } + .selector .selector-filter label { + margin: 0 8px 0 0; + } + .selector .selector-filter input { - width: 100%; + width: auto; min-height: 0; flex: 1 1; } @@ -273,7 +288,30 @@ input[type="submit"], button { margin-bottom: 5px; } - .selector-chooseall, .selector-clearall { + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { align-self: center; } @@ -295,7 +333,10 @@ input[type="submit"], button { } .stacked ul.selector-chooser { + width: 52px; + height: 26px; padding: 0 2px; + margin: 15px auto; transform: none; } @@ -303,6 +344,42 @@ input[type="submit"], button { padding: 3px; } + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + .help-tooltip, .selector .help-icon { display: none; } @@ -355,7 +432,7 @@ input[type="submit"], button { padding: 15px 20px; } - .login #site-name { + .login #branding h1 { margin: 0; } @@ -387,10 +464,14 @@ input[type="submit"], button { @media (max-width: 767px) { /* Layout */ - #header, #content { + #header, #content, #footer { padding: 15px; } + #footer:empty { + padding: 0; + } + div.breadcrumbs { padding: 10px 15px; } @@ -501,6 +582,10 @@ input[type="submit"], button { padding-top: 15px; } + fieldset.collapsed .form-row { + display: none; + } + .aligned label { width: 100%; min-width: auto; @@ -585,7 +670,6 @@ input[type="submit"], button { .related-widget-wrapper .selector { order: 1; - flex: 1 0 auto; } .related-widget-wrapper > a { @@ -600,14 +684,23 @@ input[type="submit"], button { align-self: center; } + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + /* Selector */ .selector { flex-direction: column; - gap: 10px 0; + } + + .selector > * { + float: none; } .selector-available, .selector-chosen { + margin-bottom: 0; flex: 1 1 auto; } @@ -616,10 +709,12 @@ input[type="submit"], button { } .selector ul.selector-chooser { - display: flex; - width: 60px; - height: 30px; + display: block; + float: none; + width: 52px; + height: 26px; padding: 0 2px; + margin: 15px auto 20px; transform: none; } @@ -631,16 +726,16 @@ input[type="submit"], button { background-position: 0 0; } - :enabled.selector-remove:focus, :enabled.selector-remove:hover { - background-position: 0 -24px; + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; } .selector-add { - background-position: 0 -48px; + background-position: 0 -40px; } - :enabled.selector-add:focus, :enabled.selector-add:hover { - background-position: 0 -72px; + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; } /* Inlines */ diff --git a/staticfiles/admin/css/responsive_rtl.css b/staticfiles/admin/css/responsive_rtl.css index 5e8f5c5..31dc8ff 100644 --- a/staticfiles/admin/css/responsive_rtl.css +++ b/staticfiles/admin/css/responsive_rtl.css @@ -28,12 +28,23 @@ margin-left: 0; } + [dir="rtl"] .inline-group ul.tools a.add, [dir="rtl"] .inline-group div.add-row a, [dir="rtl"] .inline-group .tabular tr.add-row td a { padding: 8px 26px 8px 10px; background-position: calc(100% - 8px) 9px; } + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + [dir="rtl"] .object-tools li { float: right; } @@ -70,20 +81,4 @@ [dir="rtl"] .aligned .vCheckboxLabel { padding: 1px 5px 0 0; } - - [dir="rtl"] .selector-remove { - background-position: 0 0; - } - - [dir="rtl"] :enabled.selector-remove:focus, :enabled.selector-remove:hover { - background-position: 0 -24px; - } - - [dir="rtl"] .selector-add { - background-position: 0 -48px; - } - - [dir="rtl"] :enabled.selector-add:focus, :enabled.selector-add:hover { - background-position: 0 -72px; - } } diff --git a/staticfiles/admin/css/rtl.css b/staticfiles/admin/css/rtl.css index a2556d0..c349a93 100644 --- a/staticfiles/admin/css/rtl.css +++ b/staticfiles/admin/css/rtl.css @@ -13,7 +13,7 @@ th { margin-right: 1.5em; } -.viewlink, .addlink, .changelink, .hidelink { +.viewlink, .addlink, .changelink { padding-left: 0; padding-right: 16px; background-position: 100% 1px; @@ -151,7 +151,6 @@ form ul.inline li { form .aligned p.help, form .aligned div.help { - margin-left: 0; margin-right: 160px; padding-right: 10px; } @@ -165,13 +164,19 @@ form .aligned p.time div.help.timezonewarning { padding-right: 0; } -form .wide p.help, -form .wide ul.errorlist, -form .wide div.help { +form .wide p.help, form .wide div.help { padding-left: 0; padding-right: 50px; } +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + .submit-row { text-align: right; } @@ -197,7 +202,12 @@ fieldset .fieldBox { top: 0; left: auto; right: 10px; - background: url(../img/calendar-icons.svg) 0 -15px no-repeat; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; } .calendarnav-next { @@ -207,6 +217,11 @@ fieldset .fieldBox { background: url(../img/calendar-icons.svg) 0 0 no-repeat; } +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + .calendar caption, .calendarbox h2 { text-align: center; } @@ -220,36 +235,34 @@ fieldset .fieldBox { } .selector-add { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; - background-size: 24px auto; + background: url(../img/selector-icons.svg) 0 -64px no-repeat; } -:enabled.selector-add:focus, :enabled.selector-add:hover { - background-position: 0 -120px; +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; } .selector-remove { - background: url(../img/selector-icons.svg) 0 -144px no-repeat; - background-size: 24px auto; + background: url(../img/selector-icons.svg) 0 -96px no-repeat; } -:enabled.selector-remove:focus, :enabled.selector-remove:hover { - background-position: 0 -168px; +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; } -.selector-chooseall { +a.selector-chooseall { background: url(../img/selector-icons.svg) right -128px no-repeat; } -:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover { +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { background-position: 100% -144px; } -.selector-clearall { +a.selector-clearall { background: url(../img/selector-icons.svg) 0 -160px no-repeat; } -:enabled.selector-clearall:focus, :enabled.selector-clearall:hover { +a.active.selector-clearall:focus, a.active.selector-clearall:hover { background-position: 0 -176px; } @@ -283,11 +296,3 @@ form .form-row p.datetime { margin-left: inherit; margin-right: 2px; } - -.inline-group .tabular td.original p { - right: 0; -} - -.selector .selector-chooser { - margin: 0; -} diff --git a/staticfiles/admin/css/unusable_password_field.css b/staticfiles/admin/css/unusable_password_field.css deleted file mode 100644 index d46eb03..0000000 --- a/staticfiles/admin/css/unusable_password_field.css +++ /dev/null @@ -1,19 +0,0 @@ -/* Hide warnings fields if usable password is selected */ -form:has(#id_usable_password input[value="true"]:checked) .messagelist { - display: none; -} - -/* Hide password fields if unusable password is selected */ -form:has(#id_usable_password input[value="false"]:checked) .field-password1, -form:has(#id_usable_password input[value="false"]:checked) .field-password2 { - display: none; -} - -/* Select appropriate submit button */ -form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password { - display: none; -} - -form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password { - display: none; -} diff --git a/staticfiles/admin/css/widgets.css b/staticfiles/admin/css/widgets.css index 538af2e..1104e8b 100644 --- a/staticfiles/admin/css/widgets.css +++ b/staticfiles/admin/css/widgets.css @@ -1,33 +1,30 @@ /* SELECTOR (FILTER INTERFACE) */ .selector { + width: 800px; + float: left; display: flex; - flex: 1; - gap: 0 10px; } .selector select { + width: 380px; height: 17.2em; flex: 1 0 auto; - overflow: scroll; - width: 100%; } .selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; display: flex; flex-direction: column; - flex: 1 1; } -.selector-available-title, .selector-chosen-title { +.selector-available h2, .selector-chosen h2 { border: 1px solid var(--border-color); border-radius: 4px 4px 0 0; } -.selector .helptext { - font-size: 0.6875rem; -} - .selector-chosen .list-footer-display { border: 1px solid var(--border-color); border-top: none; @@ -43,25 +40,14 @@ color: var(--breadcrumbs-fg); } -.selector-chosen-title { - background: var(--secondary); +.selector-chosen h2 { + background: var(--primary); color: var(--header-link-color); - padding: 8px; } -.aligned .selector-chosen-title label { - color: var(--header-link-color); - width: 100%; -} - -.selector-available-title { +.selector .selector-available h2 { background: var(--darkened-bg); color: var(--body-quiet-color); - padding: 8px; -} - -.aligned .selector-available-title label { - width: 100%; } .selector .selector-filter { @@ -72,8 +58,6 @@ font-size: 0.625rem; margin: 0; text-align: left; - display: flex; - gap: 8px; } .selector .selector-filter label, @@ -88,16 +72,18 @@ min-width: auto; } -.selector-filter input { - flex-grow: 1; +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; } .selector ul.selector-chooser { align-self: center; - width: 30px; + width: 22px; background-color: var(--selected-bg); border-radius: 10px; - margin: 0; + margin: 0 5px; padding: 0; transform: translateY(-17px); } @@ -124,86 +110,82 @@ } .selector-add, .selector-remove { - width: 24px; - height: 24px; + width: 16px; + height: 16px; display: block; text-indent: -3000px; overflow: hidden; cursor: default; opacity: 0.55; - border: none; } -:enabled.selector-add, :enabled.selector-remove { +.active.selector-add, .active.selector-remove { opacity: 1; } -:enabled.selector-add:hover, :enabled.selector-remove:hover { +.active.selector-add:hover, .active.selector-remove:hover { cursor: pointer; } .selector-add { - background: url(../img/selector-icons.svg) 0 -144px no-repeat; - background-size: 24px auto; + background: url(../img/selector-icons.svg) 0 -96px no-repeat; } -:enabled.selector-add:focus, :enabled.selector-add:hover { - background-position: 0 -168px; +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; } .selector-remove { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; - background-size: 24px auto; + background: url(../img/selector-icons.svg) 0 -64px no-repeat; } -:enabled.selector-remove:focus, :enabled.selector-remove:hover { - background-position: 0 -120px; +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; } -.selector-chooseall, .selector-clearall { +a.selector-chooseall, a.selector-clearall { display: inline-block; height: 16px; text-align: left; - margin: 0 auto; + margin: 1px auto 3px; overflow: hidden; font-weight: bold; line-height: 16px; color: var(--body-quiet-color); text-decoration: none; opacity: 0.55; - border: none; } -:enabled.selector-chooseall:focus, :enabled.selector-clearall:focus, -:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover { +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { color: var(--link-fg); } -:enabled.selector-chooseall, :enabled.selector-clearall { +a.active.selector-chooseall, a.active.selector-clearall { opacity: 1; } -:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover { +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { cursor: pointer; } -.selector-chooseall { +a.selector-chooseall { padding: 0 18px 0 0; background: url(../img/selector-icons.svg) right -160px no-repeat; cursor: default; } -:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover { +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { background-position: 100% -176px; } -.selector-clearall { +a.selector-clearall { padding: 0 0 0 18px; background: url(../img/selector-icons.svg) 0 -128px no-repeat; cursor: default; } -:enabled.selector-clearall:focus, :enabled.selector-clearall:hover { +a.active.selector-clearall:focus, a.active.selector-clearall:hover { background-position: 0 -144px; } @@ -233,9 +215,8 @@ } .stacked ul.selector-chooser { - display: flex; - height: 30px; - width: 64px; + height: 22px; + width: 50px; margin: 0 0 10px 40%; background-color: #eee; border-radius: 10px; @@ -252,34 +233,32 @@ } .stacked .selector-add { - background: url(../img/selector-icons.svg) 0 -48px no-repeat; - background-size: 24px auto; + background: url(../img/selector-icons.svg) 0 -32px no-repeat; cursor: default; } -.stacked :enabled.selector-add { - background-position: 0 -48px; +.stacked .active.selector-add { + background-position: 0 -32px; cursor: pointer; } -.stacked :enabled.selector-add:focus, .stacked :enabled.selector-add:hover { - background-position: 0 -72px; +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; cursor: pointer; } .stacked .selector-remove { background: url(../img/selector-icons.svg) 0 0 no-repeat; - background-size: 24px auto; cursor: default; } -.stacked :enabled.selector-remove { +.stacked .active.selector-remove { background-position: 0 0px; cursor: pointer; } -.stacked :enabled.selector-remove:focus, .stacked :enabled.selector-remove:hover { - background-position: 0 -24px; +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; cursor: pointer; } @@ -335,30 +314,28 @@ table p.datetime { position: relative; display: inline-block; vertical-align: middle; - height: 24px; - width: 24px; + height: 16px; + width: 16px; overflow: hidden; } .datetimeshortcuts .clock-icon { background: url(../img/icon-clock.svg) 0 0 no-repeat; - background-size: 24px auto; } .datetimeshortcuts a:focus .clock-icon, .datetimeshortcuts a:hover .clock-icon { - background-position: 0 -24px; + background-position: 0 -16px; } .datetimeshortcuts .date-icon { background: url(../img/icon-calendar.svg) 0 0 no-repeat; - background-size: 24px auto; top: -1px; } .datetimeshortcuts a:focus .date-icon, .datetimeshortcuts a:hover .date-icon { - background-position: 0 -24px; + background-position: 0 -16px; } .timezonewarning { @@ -470,7 +447,7 @@ span.clearable-file-input label { } .calendar td.selected a { - background: var(--secondary); + background: var(--primary); color: var(--button-fg); } @@ -538,26 +515,36 @@ span.clearable-file-input label { background: url(../img/calendar-icons.svg) 0 0 no-repeat; } +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + .calendarnav-next { right: 10px; - background: url(../img/calendar-icons.svg) 0 -15px no-repeat; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; } .calendar-cancel { margin: 0; padding: 4px 0; font-size: 0.75rem; - background: var(--close-button-bg); + background: #eee; border-top: 1px solid var(--border-color); - color: var(--button-fg); + color: var(--body-fg); } .calendar-cancel:focus, .calendar-cancel:hover { - background: var(--close-button-hover-bg); + background: #ddd; } .calendar-cancel a { - color: var(--button-fg); + color: black; display: block; } @@ -577,10 +564,9 @@ ul.timelist, .timelist li { float: right; text-indent: -9999px; background: url(../img/inline-delete.svg) 0 0 no-repeat; - width: 1.5rem; - height: 1.5rem; + width: 16px; + height: 16px; border: 0px none; - margin-bottom: .25rem; } .inline-deletelink:focus, .inline-deletelink:hover { @@ -589,21 +575,26 @@ ul.timelist, .timelist li { /* RELATED WIDGET WRAPPER */ .related-widget-wrapper { - display: flex; - gap: 0 10px; - flex-grow: 1; - flex-wrap: wrap; - margin-bottom: 5px; + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ } .related-widget-wrapper-link { - opacity: .6; - filter: grayscale(1); + opacity: 0.3; } .related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { opacity: 1; - filter: grayscale(0); +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; } /* GIS MAPS */ diff --git a/staticfiles/admin/img/README.txt b/staticfiles/admin/img/README.txt index bf81f35..4eb2e49 100644 --- a/staticfiles/admin/img/README.txt +++ b/staticfiles/admin/img/README.txt @@ -1,4 +1,4 @@ -All icons are taken from Font Awesome (https://fontawesome.com/) project. +All icons are taken from Font Awesome (http://fontawesome.io/) project. The Font Awesome font is licensed under the SIL OFL 1.1: - https://scripts.sil.org/OFL diff --git a/staticfiles/admin/img/calendar-icons.svg b/staticfiles/admin/img/calendar-icons.svg index 04c0274..dbf21c3 100644 --- a/staticfiles/admin/img/calendar-icons.svg +++ b/staticfiles/admin/img/calendar-icons.svg @@ -1,63 +1,14 @@ - - - - - - + + + + - - + + - - + + + + diff --git a/staticfiles/admin/img/icon-addlink.svg b/staticfiles/admin/img/icon-addlink.svg index 8d5c6a3..e004fb1 100644 --- a/staticfiles/admin/img/icon-addlink.svg +++ b/staticfiles/admin/img/icon-addlink.svg @@ -1,3 +1,3 @@ - + diff --git a/staticfiles/admin/img/icon-changelink.svg b/staticfiles/admin/img/icon-changelink.svg index 592b093..bbb137a 100644 --- a/staticfiles/admin/img/icon-changelink.svg +++ b/staticfiles/admin/img/icon-changelink.svg @@ -1,3 +1,3 @@ - + diff --git a/staticfiles/admin/img/icon-hidelink.svg b/staticfiles/admin/img/icon-hidelink.svg deleted file mode 100644 index 2a8b404..0000000 --- a/staticfiles/admin/img/icon-hidelink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/staticfiles/admin/img/inline-delete.svg b/staticfiles/admin/img/inline-delete.svg index 8751150..17d1ad6 100644 --- a/staticfiles/admin/img/inline-delete.svg +++ b/staticfiles/admin/img/inline-delete.svg @@ -1,3 +1,3 @@ - + diff --git a/staticfiles/admin/img/search.svg b/staticfiles/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/staticfiles/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..94b54b7 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,117 @@ +{% load static %} + + + + + + 페이지를 찾을 수 없습니다 - 신라대학교 AMP 제8기 + + + + + + +
+
+ + + + +
+
+ + + +
+

404

+
+ + +
+

+ 페이지를 찾을 수 없습니다 +

+

+ 요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다.
+ URL을 다시 확인하거나 메인 페이지로 이동해주세요. +

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

신라대학교 AMP 제8기

+

문제가 지속되면 관리자에게 문의해주세요.

+
+
+
+ + + + + diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..cfe245c --- /dev/null +++ b/templates/500.html @@ -0,0 +1,116 @@ +{% load static %} + + + + + + 서버 오류 - 신라대학교 AMP 제8기 + + + + + + +
+
+ + + + +
+
+ + + +
+

500

+
+ + +
+

+ 서버 내부 오류 +

+

+ 서버에서 오류가 발생했습니다.
+ 잠시 후 다시 시도해주시거나 관리자에게 문의해주세요. +

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

신라대학교 AMP 제8기

+

문제가 지속되면 관리자에게 문의해주세요.

+
+
+
+ + + + + diff --git a/templates/account/login.html b/templates/account/login.html index 4f3cb65..12ecd68 100644 --- a/templates/account/login.html +++ b/templates/account/login.html @@ -28,12 +28,14 @@

계정에 로그인하세요

- + {% if messages %} {% for message in messages %} -
- {{ message }} -
+ {% if message.tags == 'error' %} +
+ {{ message }} +
+ {% endif %} {% endfor %} {% endif %} diff --git a/templates/privacy_policy.html b/templates/privacy_policy.html index 9b7feeb..6022045 100644 --- a/templates/privacy_policy.html +++ b/templates/privacy_policy.html @@ -3,7 +3,7 @@ - 개인정보처리방침 | 신라 AMP + 개인정보처리방침 | 신라 AMP 8기 원우회

개인정보처리방침

-

신라 AMP('신라 AMP'이하 '신라 AMP')은 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

+

신라 AMP 8기 원우회('신라 AMP 8기 원우회'이하 '원우회')은 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

1. 개인정보의 처리 목적

-

신라 AMP은 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

+

원우회는 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

  • 회원 가입의사 확인, 회원에 대한 서비스 제공에 따른 본인 식별·인증
  • 회원자격 유지·관리, 서비스 이용에 따른 본인확인
  • -
  • 신라 AMP 제8기 수강생들 간의 정보 공유 및 커뮤니케이션
  • +
  • 신라 AMP 제8기 수강생들 간의 연락처, 소속정보 등 개인정보 공유를 통한 네트워킹 지원
  • +
  • 회원 간 커뮤니케이션 및 정보 교류 서비스 제공
  • 서비스 이용에 대한 통계 및 분석

2. 개인정보의 처리 및 보유 기간

-

① 신라 AMP은 정보주체로부터 개인정보를 수집할 때 동의 받은 개인정보 보유·이용기간 또는 법령에 따른 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

+

① 원우회는 정보주체로부터 개인정보를 수집할 때 동의 받은 개인정보 보유·이용기간 또는 법령에 따른 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

② 구체적인 개인정보 처리 및 보유 기간은 다음과 같습니다.

  • 회원 가입 및 관리: 회원가입 및 서비스 이용을 통한 관리
  • 보유 기간: 회원 탈퇴 시, 즉시 삭제
  • +
  • 회원 간 정보 공유: 회원 가입 시부터 탈퇴 시까지
-

3. 정보주체와 법정대리인의 권리·의무 및 그 행사방법

+

3. 개인정보의 제3자 제공

+
+

① 원우회는 정보주체의 동의가 있는 경우 개인정보를 제3자에게 제공합니다.

+

② 회원 간 정보 공유를 위한 제3자 제공 현황:

+
    +
  • 제공받는 자: 신라 AMP 제8기 수강생 회원
  • +
  • 제공목적: 회원 간 네트워킹, 연락 및 정보 교류
  • +
  • 제공항목: 이름, 생년월일, 소속, 직책, 연락처(전화번호), 주소, 사진 등 (회원이 공개 동의한 항목에 한함)
  • +
  • 제공받는 자의 보유·이용기간: 정보주체가 삭제 요구 시까지 또는 서비스 종료 시까지
  • +
+

③ 회원은 언제든지 개인정보 제공에 대한 동의를 철회할 수 있으며, 동의 철회 시 해당 정보의 제3자 제공을 중단합니다.

+
+ +

4. 정보주체와 법정대리인의 권리·의무 및 그 행사방법

이용자는 개인정보주체로써 다음과 같은 권리를 행사할 수 있습니다.

-

① 정보주체는 신라 AMP에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다.

+

① 정보주체는 원우회에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다.

  • 개인정보 열람요구
  • 오류 등이 있을 경우 정정 요구
  • 삭제요구
  • 처리정지 요구
  • +
  • 개인정보 제3자 제공 동의 철회
  • +
  • 특정 회원에 대한 개인정보 공개 범위 조정
-

4. 처리하는 개인정보의 항목

-

① 신라 AMP은 다음의 개인정보 항목을 처리하고 있습니다.

+

5. 처리하는 개인정보의 항목

+

① 원우회는 다음의 개인정보 항목을 처리하고 있습니다.

-

신라 AMP에서 수집하는 개인정보 항목

-

신라 AMP 회원 가입 시, 제공 동의를 해주시는 개인정보 수집 항목입니다.

+

원우회에서 수집하는 개인정보 항목

+

원우회 회원 가입 시, 제공 동의를 해주시는 개인정보 수집 항목입니다.

■ 회원 가입 시(회원)

    -
  • 필수항목: 이름, 전화번호, 비밀번호
  • -
  • 선택항목: 이메일, 소속, 직책, 주소, 생년월일, 사진, 키워드
  • -
  • 수집목적: 신라 AMP 회원관리 및 수강생 간 정보 공유
  • +
  • 필수항목: 이름, 생년월일, 소속, 직책, 연락처(전화번호), 주소, 사진, 비밀번호
  • +
  • 수집목적: 원우회 회원관리 및 수강생 간 정보 공유
  • 보유기간: 회원 탈퇴 또는 동의철회 시 지체없이 파기
+ +
+

■ 회원 활동 중 추가 수집 항목

+
    +
  • 선택항목: 이메일, 키워드 등 개인이 입력하는 추가 정보
  • +
  • 수집목적: 회원 간 정보 교류 및 네트워킹 지원
  • +
  • 보유기간: 회원 탈퇴 또는 삭제 요청 시 지체없이 파기
  • +
+
+ +
+

■ 개인정보 공개 설정

+

회원은 다른 회원에게 공개할 개인정보 항목을 선택할 수 있으며, 언제든지 공개 범위를 변경할 수 있습니다.

+
-

② 신라 AMP은 만 14세 미만 아동의 개인정보를 보호하기 위하여 회원가입은 만14세 이상만 가능하도록 함으로써 아동의 개인정보를 수집하지 않습니다.

+

② 원우회는 만 14세 미만 아동의 개인정보를 보호하기 위하여 회원가입은 만14세 이상만 가능하도록 함으로써 아동의 개인정보를 수집하지 않습니다.

-

5. 개인정보의 파기

-

신라 AMP은 원칙적으로 개인정보 처리목적이 달성된 경우에는 지체없이 해당 개인정보를 파기합니다. 파기의 절차, 기한 및 방법은 다음과 같습니다.

+

6. 개인정보의 파기

+

원우회는 원칙적으로 개인정보 처리목적이 달성된 경우에는 지체없이 해당 개인정보를 파기합니다. 파기의 절차, 기한 및 방법은 다음과 같습니다.

- 파기절차

이용자가 입력한 정보는 목적 달성 후 별도의 DB에 옮겨져(종이의 경우 별도의 서류) 내부 방침 및 기타 관련 법령에 따라 일정기간 저장된 후 혹은 즉시 파기됩니다. 이 때, DB로 옮겨진 개인정보는 법률에 의한 경우가 아니고서는 다른 목적으로 이용되지 않습니다.

@@ -133,8 +173,25 @@

- 파기기한

이용자의 개인정보는 개인정보의 보유기간이 경과된 경우에는 보유기간의 종료일로부터 5일 이내에, 개인정보의 처리 목적 달성, 해당 서비스의 폐지, 사업의 종료 등 그 개인정보가 불필요하게 되었을 때에는 개인정보의 처리가 불필요한 것으로 인정되는 날로부터 5일 이내에 그 개인정보를 파기합니다.

-

6. 개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항

-

① 신라 AMP은 개별적인 맞춤서비스를 제공하기 위해 이용정보를 저장하고 수시로 불러오는 '쿠키(cookie)'를 사용합니다.

+

7. 개인정보 오남용 방지 및 제재 조치

+
+

① 회원은 다른 회원의 개인정보를 다음 용도로만 사용할 수 있습니다:

+
    +
  • 신라 AMP 관련 업무적 연락 및 네트워킹
  • +
  • 개인적 친분 관계 형성을 위한 연락
  • +
+

② 다음과 같은 행위는 금지되며, 위반 시 회원 자격이 박탈될 수 있습니다:

+
    +
  • 다른 회원의 개인정보를 제3자에게 제공하는 행위
  • +
  • 마케팅, 광고, 스팸 목적의 무단 연락
  • +
  • 개인정보를 수집, 저장하여 데이터베이스를 구축하는 행위
  • +
  • 기타 개인정보를 오남용하는 행위
  • +
+

③ 개인정보 오남용 신고는 개인정보 보호책임자에게 연락하시기 바랍니다.

+
+ +

8. 개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항

+

① 원우회는 개별적인 맞춤서비스를 제공하기 위해 이용정보를 저장하고 수시로 불러오는 '쿠키(cookie)'를 사용합니다.

② 쿠키는 웹사이트를 운영하는데 이용되는 서버(https)가 이용자의 컴퓨터 브라우저에게 보내는 소량의 정보이며 이용자들의 PC 컴퓨터내의 하드디스크에 저장되기도 합니다.

  • 쿠키의 사용 목적: 이용자가 방문한 각 서비스와 웹 사이트들에 대한 방문 및 이용형태, 인기 검색어, 보안접속 여부, 등을 파악하여 이용자에게 최적화된 정보 제공을 위해 사용됩니다.
  • @@ -142,30 +199,34 @@
  • 쿠키 저장을 거부할 경우 맞춤형 서비스 이용에 어려움이 발생할 수 있습니다.
-

7. 개인정보 보호책임자

-

① 신라 AMP은 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.

+

9. 개인정보 보호책임자

+

① 원우회는 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.

+

▶ 개인정보 관리주체

+

성명: 이승규
+ 직책: 8기 초대 원우회장

+

▶ 개인정보 보호책임자

-

성명: 신라 AMP 관리자
- 직책: 관리자
- 연락처: admin@sillaamp.com

+

성명: 김봉수
+ 직책: 사이트 관리자
+ 연락처: vba@kakao.com

-

② 신라 AMP의 서비스를 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자로 문의하실 수 있습니다.

-

신라 AMP은 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.

+

② 원우회의 서비스를 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자로 문의하실 수 있습니다.

+

원우회는 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.

-

8. 개인정보 처리방침 변경

+

10. 개인정보 처리방침 변경

① 이 개인정보처리방침은 시행일로부터 적용되며, 법령 및 방침에 따른 변경내용의 추가, 삭제 및 정정이 있는 경우에는 변경사항의 시행 7일 전부터 공지사항을 통하여 고지할 것입니다.

-

9. 개인정보의 안전성 확보 조치

-

신라 AMP은 개인정보보호법 제29조에 따라 다음과 같이 안전성 확보에 필요한 기술적/관리적 및 물리적 조치를 하고 있습니다.

+

11. 개인정보의 안전성 확보 조치

+

원우회는 개인정보보호법 제29조에 따라 다음과 같이 안전성 확보에 필요한 기술적/관리적 및 물리적 조치를 하고 있습니다.

① 개인정보 취급 직원의 최소화 및 교육

개인정보를 취급하는 직원을 지정하고 담당자에 한정시켜 최소화 하여 개인정보를 관리하는 대책을 시행하고 있습니다.

② 해킹 등에 대비한 기술적 대책

-

신라 AMP은 해킹이나 컴퓨터 바이러스 등에 의한 개인정보 유출 및 훼손을 막기 위하여 보안프로그램을 설치하고 주기적인 갱신·점검을 하며 외부로부터 접근이 통제된 구역에 시스템을 설치하고 기술적/물리적으로 감시 및 차단하고 있습니다.

+

원우회는 해킹이나 컴퓨터 바이러스 등에 의한 개인정보 유출 및 훼손을 막기 위하여 보안프로그램을 설치하고 주기적인 갱신·점검을 하며 외부로부터 접근이 통제된 구역에 시스템을 설치하고 기술적/물리적으로 감시 및 차단하고 있습니다.

③ 개인정보의 암호화

이용자의 개인정보는 비밀번호는 암호화 되어 저장 및 관리되고 있어, 본인만이 알 수 있으며 중요한 데이터는 파일 및 전송 데이터를 암호화 하거나 파일 잠금 기능을 사용하는 등의 별도 보안기능을 사용하고 있습니다.

@@ -176,8 +237,8 @@

⑤ 개인정보에 대한 접근 제한

개인정보를 처리하는 데이터베이스시스템에 대한 접근권한의 부여,변경,말소를 통하여 개인정보에 대한 접근통제를 위하여 필요한 조치를 하고 있으며 침입차단시스템을 이용하여 외부로부터의 무단 접근을 통제하고 있습니다.

-

10. 정보주체의 권익침해에 대한 구제방법

-

아래의 기관은 신라 AMP과는 별개의 기관으로서, 신라 AMP의 자체적인 개인정보 불만처리, 피해구제 결과에 만족하지 못하시거나 보다 자세한 도움이 필요하시면 문의하여 주시기 바랍니다.

+

12. 정보주체의 권익침해에 대한 구제방법

+

아래의 기관은 원우회과는 별개의 기관으로서, 원우회의 자체적인 개인정보 불만처리, 피해구제 결과에 만족하지 못하시거나 보다 자세한 도움이 필요하시면 문의하여 주시기 바랍니다.

▶ 개인정보 침해신고센터 (한국인터넷진흥원 운영)

@@ -204,9 +265,9 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/templates/privacy_policy_backup.html b/templates/privacy_policy_backup.html new file mode 100644 index 0000000..1fd18fa --- /dev/null +++ b/templates/privacy_policy_backup.html @@ -0,0 +1,212 @@ + + + + + + 개인정보처리방침 | 신라 AMP + + + +
+

ㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁ개인정보처리방침

+ +

신라 AMP('신라 AMP'이하 '신라 AMP')은 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

+ +

1. 개인정보의 처리 목적

+

신라 AMP은 다음의 목적을 위하여 개인정보를 처리하고 있으며, 다음의 목적 이외의 용도로는 이용하지 않습니다.

+
    +
  • 회원 가입의사 확인, 회원에 대한 서비스 제공에 따른 본인 식별·인증
  • +
  • 회원자격 유지·관리, 서비스 이용에 따른 본인확인
  • +
  • 신라 AMP 제8기 수강생들 간의 정보 공유 및 커뮤니케이션
  • +
  • 서비스 이용에 대한 통계 및 분석
  • +
+ +

2. 개인정보의 처리 및 보유 기간

+

① 신라 AMP은 정보주체로부터 개인정보를 수집할 때 동의 받은 개인정보 보유·이용기간 또는 법령에 따른 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

+

② 구체적인 개인정보 처리 및 보유 기간은 다음과 같습니다.

+
    +
  • 회원 가입 및 관리: 회원가입 및 서비스 이용을 통한 관리
  • +
  • 보유 기간: 회원 탈퇴 시, 즉시 삭제
  • +
+ +

3. 정보주체와 법정대리인의 권리·의무 및 그 행사방법

+

이용자는 개인정보주체로써 다음과 같은 권리를 행사할 수 있습니다.

+

① 정보주체는 신라 AMP에 대해 언제든지 다음 각 호의 개인정보 보호 관련 권리를 행사할 수 있습니다.

+
    +
  • 개인정보 열람요구
  • +
  • 오류 등이 있을 경우 정정 요구
  • +
  • 삭제요구
  • +
  • 처리정지 요구
  • +
+ +

4. 처리하는 개인정보의 항목

+

① 신라 AMP은 다음의 개인정보 항목을 처리하고 있습니다.

+ +
+

신라 AMP에서 수집하는 개인정보 항목

+

신라 AMP 회원 가입 시, 제공 동의를 해주시는 개인정보 수집 항목입니다.

+ +

■ 회원 가입 시(회원)

+
    +
  • 필수항목: 이름, 전화번호, 비밀번호
  • +
  • 선택항목: 이메일, 소속, 직책, 주소, 생년월일, 사진, 키워드
  • +
  • 수집목적: 신라 AMP 회원관리 및 수강생 간 정보 공유
  • +
  • 보유기간: 회원 탈퇴 또는 동의철회 시 지체없이 파기
  • +
+
+ +

② 신라 AMP은 만 14세 미만 아동의 개인정보를 보호하기 위하여 회원가입은 만14세 이상만 가능하도록 함으로써 아동의 개인정보를 수집하지 않습니다.

+ +

5. 개인정보의 파기

+

신라 AMP은 원칙적으로 개인정보 처리목적이 달성된 경우에는 지체없이 해당 개인정보를 파기합니다. 파기의 절차, 기한 및 방법은 다음과 같습니다.

+ +

- 파기절차

+

이용자가 입력한 정보는 목적 달성 후 별도의 DB에 옮겨져(종이의 경우 별도의 서류) 내부 방침 및 기타 관련 법령에 따라 일정기간 저장된 후 혹은 즉시 파기됩니다. 이 때, DB로 옮겨진 개인정보는 법률에 의한 경우가 아니고서는 다른 목적으로 이용되지 않습니다.

+ +

- 파기기한

+

이용자의 개인정보는 개인정보의 보유기간이 경과된 경우에는 보유기간의 종료일로부터 5일 이내에, 개인정보의 처리 목적 달성, 해당 서비스의 폐지, 사업의 종료 등 그 개인정보가 불필요하게 되었을 때에는 개인정보의 처리가 불필요한 것으로 인정되는 날로부터 5일 이내에 그 개인정보를 파기합니다.

+ +

6. 개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항

+

① 신라 AMP은 개별적인 맞춤서비스를 제공하기 위해 이용정보를 저장하고 수시로 불러오는 '쿠키(cookie)'를 사용합니다.

+

② 쿠키는 웹사이트를 운영하는데 이용되는 서버(https)가 이용자의 컴퓨터 브라우저에게 보내는 소량의 정보이며 이용자들의 PC 컴퓨터내의 하드디스크에 저장되기도 합니다.

+
    +
  • 쿠키의 사용 목적: 이용자가 방문한 각 서비스와 웹 사이트들에 대한 방문 및 이용형태, 인기 검색어, 보안접속 여부, 등을 파악하여 이용자에게 최적화된 정보 제공을 위해 사용됩니다.
  • +
  • 쿠키의 설치·운영 및 거부: 웹브라우저 상단의 도구>인터넷 옵션>개인정보 메뉴의 옵션 설정을 통해 쿠키 저장을 거부 할 수 있습니다.
  • +
  • 쿠키 저장을 거부할 경우 맞춤형 서비스 이용에 어려움이 발생할 수 있습니다.
  • +
+ +

7. 개인정보 보호책임자

+

① 신라 AMP은 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.

+ +
+

▶ 개인정보 보호책임자

+

성명: 신라 AMP 관리자
+ 직책: 관리자
+ 연락처: admin@sillaamp.com

+
+ +

② 신라 AMP의 서비스를 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자로 문의하실 수 있습니다.

+

신라 AMP은 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.

+ +

8. 개인정보 처리방침 변경

+

① 이 개인정보처리방침은 시행일로부터 적용되며, 법령 및 방침에 따른 변경내용의 추가, 삭제 및 정정이 있는 경우에는 변경사항의 시행 7일 전부터 공지사항을 통하여 고지할 것입니다.

+ +

9. 개인정보의 안전성 확보 조치

+

신라 AMP은 개인정보보호법 제29조에 따라 다음과 같이 안전성 확보에 필요한 기술적/관리적 및 물리적 조치를 하고 있습니다.

+ +

① 개인정보 취급 직원의 최소화 및 교육

+

개인정보를 취급하는 직원을 지정하고 담당자에 한정시켜 최소화 하여 개인정보를 관리하는 대책을 시행하고 있습니다.

+ +

② 해킹 등에 대비한 기술적 대책

+

신라 AMP은 해킹이나 컴퓨터 바이러스 등에 의한 개인정보 유출 및 훼손을 막기 위하여 보안프로그램을 설치하고 주기적인 갱신·점검을 하며 외부로부터 접근이 통제된 구역에 시스템을 설치하고 기술적/물리적으로 감시 및 차단하고 있습니다.

+ +

③ 개인정보의 암호화

+

이용자의 개인정보는 비밀번호는 암호화 되어 저장 및 관리되고 있어, 본인만이 알 수 있으며 중요한 데이터는 파일 및 전송 데이터를 암호화 하거나 파일 잠금 기능을 사용하는 등의 별도 보안기능을 사용하고 있습니다.

+ +

④ 접속기록의 보관 및 위변조 방지

+

개인정보처리시스템에 접속한 기록을 최소 6개월 이상 보관, 관리하고 있으며, 접속 기록이 위변조 및 도난, 분실되지 않도록 보안기능 사용하고 있습니다.

+ +

⑤ 개인정보에 대한 접근 제한

+

개인정보를 처리하는 데이터베이스시스템에 대한 접근권한의 부여,변경,말소를 통하여 개인정보에 대한 접근통제를 위하여 필요한 조치를 하고 있으며 침입차단시스템을 이용하여 외부로부터의 무단 접근을 통제하고 있습니다.

+ +

10. 정보주체의 권익침해에 대한 구제방법

+

아래의 기관은 신라 AMP과는 별개의 기관으로서, 신라 AMP의 자체적인 개인정보 불만처리, 피해구제 결과에 만족하지 못하시거나 보다 자세한 도움이 필요하시면 문의하여 주시기 바랍니다.

+ +
+

▶ 개인정보 침해신고센터 (한국인터넷진흥원 운영)

+
    +
  • 소관업무: 개인정보 침해사실 신고, 상담 신청
  • +
  • 홈페이지: privacy.kisa.or.kr
  • +
  • 전화: (국번없이) 118
  • +
  • 주소: (58324) 전남 나주시 진흥길 9(빛가람동 301-2) 3층 개인정보침해신고센터
  • +
+ +

▶ 개인정보 분쟁조정위원회

+
    +
  • 소관업무: 개인정보 분쟁조정신청, 집단분쟁조정 (민사적 해결)
  • +
  • 홈페이지: www.kopico.go.kr
  • +
  • 전화: (국번없이) 1833-6972
  • +
  • 주소: (03171)서울특별시 종로구 세종대로 209 정부서울청사 4층
  • +
+ +

▶ 대검찰청 사이버범죄수사단

+

전화: 02-3480-3573 (www.spo.go.kr)

+ +

▶ 경찰청 사이버안전국

+

전화: 182 (http://cyberbureau.police.go.kr)

+
+ + +
+ + \ No newline at end of file