문자인증 등 추가작업 - 20250825

This commit is contained in:
CPABONG 2025-08-23 19:00:21 +09:00
parent 6ca64d533d
commit 0761716ef5
118 changed files with 3292 additions and 792 deletions

18
.env Normal file
View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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'

166
A_core/sms_utils.py Normal file
View File

@ -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)

View File

@ -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<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
# 정적 파일 직접 서빙 (프로덕션 환경)
re_path(r'^static/(?P<path>.*)$', 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'

10
A_core/views.py Normal file
View File

@ -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'))

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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('<strong>{}</strong>', obj.person.이름)
elif obj.user:
return format_html('<span style="color: #666;">{}</span>', obj.user.username)
else:
return format_html('<span style="color: #ccc;">익명</span>')
사용자명.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(
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px;">{}</span>',
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 = ['<div style="font-family: monospace; background: #f8f9fa; padding: 10px; border-radius: 5px;">']
for field_name, change_data in changes.items():
old_value = change_data.get('old', '')
new_value = change_data.get('new', '')
html_parts.append(f'<div style="margin-bottom: 8px;">')
html_parts.append(f'<strong>{field_name}:</strong><br>')
html_parts.append(f'<span style="color: #dc3545;">이전: "{old_value}"</span><br>')
html_parts.append(f'<span style="color: #28a745;">이후: "{new_value}"</span>')
html_parts.append('</div>')
html_parts.append('</div>')
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('<strong>{}</strong> ({})', obj.person.이름, obj.user.username)
else:
# 탈퇴 승인된 경우 백업 데이터에서 정보 가져오기
username = obj.backup_data.get('user_info', {}).get('username', '탈퇴됨')
return format_html('<strong>{}</strong> (<span style="color: #dc3545;">{}</span>)', 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(
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px;">{}</span>',
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

View File

@ -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

252
B_main/email_utils.py Normal file
View File

@ -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

View File

@ -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}")

232
B_main/log_utils.py Normal file
View File

@ -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'
}
)

View File

@ -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

View File

@ -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='가입일시'),
),
]

View File

@ -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='가입일시'),
),
]

View File

@ -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;"
),
]

View File

@ -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='소개글'),
),
]

View File

@ -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')],
},
),
]

View File

@ -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')],
},
),
]

View File

@ -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='탈퇴 요청자'),
),
]

View File

@ -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')})"

19
B_main/signals.py Normal file
View File

@ -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)

View File

@ -26,7 +26,7 @@
<div class="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">신라대학교 AMP 제8기</h1>
<a href="{% url 'main' %}" class="text-3xl font-bold hover:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer">신라대학교 AMP 제8기</a>
<div class="space-x-4 text-sm">
{% if user.is_authenticated %}
<div class="flex flex-col items-end sm:items-center sm:flex-row sm:space-x-4 space-y-1 sm:space-y-0">
@ -121,6 +121,32 @@
</div>
</div>
<!-- Django 메시지 표시 -->
{% if messages %}
{% for message in messages %}
<div class="mb-4 p-4 rounded-lg {% if message.tags == 'success' %}bg-green-600 text-white{% elif message.tags == 'error' %}bg-red-600 text-white{% elif message.tags == 'warning' %}bg-yellow-600 text-white{% else %}bg-blue-600 text-white{% endif %} shadow-lg message-alert">
<div class="flex items-center justify-between">
<span>{{ message }}</span>
<button onclick="this.parentElement.parentElement.remove()" class="ml-4 text-white hover:text-gray-200 font-bold text-lg">&times;</button>
</div>
</div>
{% endfor %}
<script>
// 메시지 자동 제거 (3초 후)
document.addEventListener('DOMContentLoaded', function() {
const messageAlerts = document.querySelectorAll('.message-alert');
messageAlerts.forEach(function(alert) {
setTimeout(function() {
alert.style.transition = 'opacity 0.5s ease-out';
alert.style.opacity = '0';
setTimeout(function() {
alert.remove();
}, 500);
}, 3000);
});
});
</script>
{% endif %}
<!-- 검색창 -->
<div class="mb-6">
@ -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"
>
<!-- 로딩 인디케이터 -->
<div id="loading-indicator" class="htmx-indicator flex justify-center items-center py-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span class="ml-2 text-gray-600 dark:text-gray-400">검색 중...</span>
</div>
</div>
<!-- 카드 목록 -->
@ -146,6 +178,15 @@
</div>
</div>
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: flex;
}
</style>
<script>
// 다크모드 토글 스크립트
const themeToggle = document.getElementById('theme-toggle');
@ -207,5 +248,89 @@
themeToggleMobile.addEventListener('click', toggleTheme);
}
</script>
<!-- 👤 프로필 모달 컴포넌트 -->
<div id="profile-modal" class="fixed inset-0 bg-black bg-opacity-70 dark:bg-black dark:bg-opacity-80 z-50 flex items-center justify-center hidden transition-opacity duration-300" onclick="closeProfileModal()">
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6" onclick="event.stopPropagation()">
<!-- 닫기 버튼 -->
<button
onclick="closeProfileModal()"
class="absolute top-4 right-4 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
aria-label="닫기"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<!-- 프로필 내용 -->
<div class="text-center">
<!-- 이름 -->
<h2 id="modal-name" class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4"></h2>
<!-- 사진 -->
<div class="mb-6">
<img id="modal-photo" src="" alt="" class="max-w-full max-h-96 object-contain rounded-lg border-2 border-gray-200 dark:border-gray-600 mx-auto shadow-lg cursor-pointer" onclick="closeProfileModal()">
</div>
<!-- 소개글 -->
<div id="modal-intro-section" class="hidden">
<div id="modal-intro" class="text-gray-600 dark:text-gray-400 text-sm leading-relaxed bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-left"></div>
</div>
<!-- 소개글이 없을 때 메시지 -->
<div id="modal-no-intro" class="text-gray-500 dark:text-gray-400 text-sm italic text-left">
아직 소개글이 없습니다.
</div>
</div>
</div>
</div>
<script>
function openProfileModal(name, photoSrc, intro) {
const modal = document.getElementById('profile-modal');
const modalName = document.getElementById('modal-name');
const modalPhoto = document.getElementById('modal-photo');
const modalIntro = document.getElementById('modal-intro');
const modalIntroSection = document.getElementById('modal-intro-section');
const modalNoIntro = document.getElementById('modal-no-intro');
// 이름 설정
modalName.textContent = name;
// 사진 설정
modalPhoto.src = photoSrc;
modalPhoto.alt = name + '의 사진';
// 소개글 설정
if (intro && intro.trim() !== '') {
modalIntro.textContent = intro;
modalIntroSection.classList.remove('hidden');
modalNoIntro.classList.add('hidden');
} else {
modalIntroSection.classList.add('hidden');
modalNoIntro.classList.remove('hidden');
}
// 모달 표시
modal.classList.remove('hidden');
// 스크롤 방지
document.body.style.overflow = 'hidden';
}
function closeProfileModal() {
const modal = document.getElementById('profile-modal');
modal.classList.add('hidden');
// 스크롤 복원
document.body.style.overflow = 'auto';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeProfileModal();
}
});
</script>
</body>
</html>

View File

@ -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 %}
<img
src="{% static 'B_main/images/default_user.png' %}"
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 }}')"
>
{% endif %}
{% if person.이름 %}
@ -78,46 +78,4 @@
</div>
</div>
</div>
</div>
<!-- 📸 모달 컴포넌트 -->
<div id="image-modal" class="fixed inset-0 bg-black bg-opacity-70 dark:bg-black dark:bg-opacity-80 z-50 flex items-center justify-center hidden transition-opacity duration-300" onclick="closeModal()">
<div class="relative">
<img id="modal-image" src="" class="max-w-[90vh] h-[60vh] w-auto rounded-lg border-4 border-white dark:border-gray-300 shadow-xl" alt="확대 이미지">
<!-- 닫기 버튼 -->
<button
onclick="closeModal()"
class="absolute top-2 right-2 bg-white dark:bg-gray-800 text-gray-800 dark:text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="닫기"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<script>
function openModal(src) {
const modal = document.getElementById('image-modal');
const modalImg = document.getElementById('modal-image');
modalImg.src = src;
modal.classList.remove('hidden');
// 스크롤 방지
document.body.style.overflow = 'hidden';
}
function closeModal() {
const modal = document.getElementById('image-modal');
modal.classList.add('hidden');
// 스크롤 복원
document.body.style.overflow = 'auto';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});
</script>
</div>

View File

@ -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'),
]

View File

@ -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')
return render(request, 'privacy_policy.html')
def test_500_error(request):
"""500 에러 페이지 테스트용 뷰"""
# 강제로 에러를 발생시킵니다
raise Exception("500 에러 페이지 테스트를 위한 의도적인 에러입니다.")

277
B_main/withdrawal_utils.py Normal file
View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -58,7 +58,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
<!-- 헤더 -->
<div class="max-w-5xl mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">신라대학교 AMP 제8기</h1>
<a href="{% url 'main' %}" class="text-3xl font-bold hover:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer">신라대학교 AMP 제8기</a>
<div class="space-x-4 text-sm">
{% if user.is_authenticated %}
<div class="flex flex-col items-end sm:items-center sm:flex-row sm:space-x-4 space-y-1 sm:space-y-0">

View File

@ -58,7 +58,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
<div class="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">신라대학교 AMP 제8기</h1>
<a href="{% url 'main' %}" class="text-3xl font-bold hover:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer">신라대학교 AMP 제8기</a>
<div class="space-x-4 text-sm">
{% if user.is_authenticated %}
<div class="flex flex-col items-end sm:items-center sm:flex-row sm:space-x-4 space-y-1 sm:space-y-0">

View File

@ -54,11 +54,10 @@ input[type="file"]::-webkit-file-upload-button:hover {
}
</style>
<div class="bg-gray-200 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-300">
<div class="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">신라대학교 AMP 제8기</h1>
<a href="{% url 'main' %}" class="text-3xl font-bold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 cursor-pointer">신라대학교 AMP 제8기</a>
<div class="space-x-4 text-sm">
{% if user.is_authenticated %}
<div class="flex flex-col items-end sm:items-center sm:flex-row sm:space-x-4 space-y-1 sm:space-y-0">
@ -75,7 +74,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
<!-- 모바일: 다크모드 토글 버튼과 햄버거 버튼을 가로로 배치 -->
<div class="sm:hidden flex items-center space-x-2">
<!-- 모바일: 다크모드 토글 버튼 (햄버거 버튼 왼쪽) -->
<button id="theme-toggle-mobile" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" aria-label="테마 변경">
<button id="theme-toggle-mobile" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-300" aria-label="테마 변경">
<!-- 라이트 모드 아이콘 (다크모드일 때 보임) -->
<svg id="theme-toggle-light-icon-mobile" class="w-5 h-5 text-gray-800 dark:text-gray-200" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
@ -108,7 +107,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
<div class="hidden sm:flex items-center space-x-3 mt-1 sm:mt-0">
<a href="{% url 'account_logout' %}" class="text-red-400 hover:text-red-500">로그아웃</a>
<!-- 데스크탑: 다크모드 토글 버튼 (로그아웃 오른쪽) -->
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" aria-label="테마 변경">
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-300" aria-label="테마 변경">
<!-- 라이트 모드 아이콘 (다크모드일 때 보임) -->
<svg id="theme-toggle-light-icon" class="w-5 h-5 text-gray-800 dark:text-gray-200" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
@ -120,29 +119,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
</button>
</div>
</div>
<script>
// 햄버거 메뉴 토글 스크립트
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('mobile-menu-button');
const menu = document.getElementById('mobile-menu-dropdown');
if (btn && menu) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
menu.classList.toggle('hidden');
});
// 메뉴 외부 클릭 시 닫기
document.addEventListener('click', function(e) {
if (!menu.classList.contains('hidden')) {
menu.classList.add('hidden');
}
});
// 메뉴 클릭 시 닫히지 않도록
menu.addEventListener('click', function(e) {
e.stopPropagation();
});
}
});
</script>
</div>
{% elif request.session.authenticated %}
<a href="{% url 'session_logout' %}" class="text-red-400 hover:text-red-500">로그아웃</a>
@ -155,10 +132,10 @@ input[type="file"]::-webkit-file-upload-button:hover {
<!-- 프로필 수정 폼 -->
<div class="flex justify-center">
<div class="bg-gray-800 bg-opacity-70 backdrop-blur-lg p-8 rounded-2xl shadow-2xl w-full max-w-md transition-all">
<div class="bg-white dark:bg-gray-800 bg-opacity-95 dark:bg-opacity-70 backdrop-blur-lg p-8 rounded-2xl shadow-2xl w-full max-w-md border border-gray-200 dark:border-gray-700 transition-colors duration-300">
<div class="text-center mb-6">
<h2 class="text-2xl font-bold tracking-tight text-white">프로필 수정</h2>
<p class="text-sm text-gray-400 mt-2">개인 정보를 수정하세요</p>
<h2 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">프로필 수정</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">개인 정보를 수정하세요</p>
</div>
{% if messages %}
@ -181,70 +158,58 @@ input[type="file"]::-webkit-file-upload-button:hover {
</div>
{% endif %}
<!-- 편집 불가능한 필드들 (표시만) -->
<div>
<label class="block mb-1 text-sm text-gray-300">이름</label>
{{ form.full_name }}
</div>
<div>
<label class="block mb-1 text-sm text-gray-300">전화번호</label>
<input type="text" value="{{ user.username }}" readonly
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">
</div>
{% if form.instance.생년월일 %}
<div>
<label class="block mb-1 text-sm text-gray-300">생년월일</label>
<input type="text" value="{{ form.instance.생년월일|date:'Y-m-d' }}" readonly
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">
</div>
{% endif %}
{% if form.instance.TITLE %}
<div>
<label class="block mb-1 text-sm text-gray-300">TITLE</label>
<input type="text" value="{{ form.instance.TITLE }}" readonly
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">
</div>
{% endif %}
<!-- 편집 가능한 필드들 -->
<div>
<label for="{{ form.소속.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.소속.label }}</label>
{{ form.소속 }}
</div>
<div>
<label for="{{ form.직책.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.직책.label }}</label>
{{ form.직책 }}
</div>
<div>
<label for="{{ form.주소.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.주소.label }}</label>
{{ form.주소 }}
</div>
<div>
<label for="{{ form.사진.id_for_label }}" class="block mb-1 text-sm text-gray-300">프로필 사진</label>
{{ form.사진 }}
<!-- 편집 불가능한 필드들 (회색 배경) -->
<div class="mb-6">
{% if form.instance.사진 and form.instance.사진.url %}
<div class="mt-2">
<img id="profile-preview" src="{{ form.instance.사진.url }}" alt="프로필 사진 미리보기" class="w-24 h-24 rounded-full object-cover border-2 border-gray-500" />
</div>
{% else %}
<div class="mt-2">
<img id="profile-preview" src="/static/B_main/images/default_user.png" alt="프로필 사진 미리보기" class="w-24 h-24 rounded-full object-cover border-2 border-gray-500" />
</div>
{% endif %}
<div class="mb-4">
<label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.full_name.label }}</label>
{{ form.full_name }}
</div>
<div class="mb-4">
<label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.phone_display.label }}</label>
{{ form.phone_display }}
</div>
<div class="mb-4">
<label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.birth_date_display.label }}</label>
{{ form.birth_date_display }}
</div>
<div class="mb-4">
<label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.amp_title_display.label }}</label>
{{ form.amp_title_display }}
</div>
</div>
<!-- 키워드 섹션 -->
<div class="border-t border-gray-600 pt-4">
<h3 class="text-lg font-semibold text-blue-400 mb-3">검색 키워드</h3>
<p class="text-sm text-gray-400 mb-4">다른 사람들이 당신을 찾을 수 있도록 키워드를 설정하세요</p>
<!-- 편집 가능한 필드들 (파란색 테두리) -->
<div class="border-t border-gray-300 dark:border-gray-600 pt-6">
<div class="mb-3">
<div class="mb-4">
<label for="{{ form.소속.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.소속.label }}</label>
{{ form.소속 }}
</div>
<div class="mb-4">
<label for="{{ form.직책.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.직책.label }}</label>
{{ form.직책 }}
</div>
<div class="mb-4">
<label for="{{ form.주소.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.주소.label }}</label>
{{ form.주소 }}
</div>
<div class="mb-4">
<label for="{{ form.keyword1.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.keyword1.label }}</label>
{{ form.keyword1 }}
</div>
<div class="mb-4">
<label for="{{ form.소개글.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.소개글.label }}</label>
{{ form.소개글 }}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">최대 200자까지 입력 가능합니다.</p>
</div>
</div>
<button type="submit"
@ -254,97 +219,145 @@ input[type="file"]::-webkit-file-upload-button:hover {
</form>
<!-- 비밀번호 변경 섹션 -->
<div class="mt-6 pt-6 border-t border-gray-600">
<div class="mt-6 pt-6 border-t border-gray-300 dark:border-gray-600">
<div class="text-center">
<div class="space-y-3">
<a href="{% url 'accounts:password_change_logged_in' %}"
class="block w-full px-6 py-2 bg-orange-600 hover:bg-orange-700 rounded-lg text-white font-medium text-sm transition duration-200 shadow-md hover:shadow-lg">
비밀번호 변경
</a>
</div>
<div class="space-y-3">
<a href="{% url 'accounts:password_change_logged_in' %}"
class="block w-full px-6 py-2 bg-orange-600 hover:bg-orange-700 rounded-lg text-white font-medium text-sm transition duration-200 shadow-md hover:shadow-lg">
비밀번호 변경
</a>
</div>
</div>
</div>
<div class="mt-6 text-center text-sm">
<a href="{% url 'main' %}" class="text-blue-400 hover:text-blue-500 transition">
메인으로 돌아가기
</a>
<!-- 회원탈퇴 섹션 -->
<div class="mt-8 pt-6 border-t border-gray-300 dark:border-gray-600">
<div class="bg-red-50 dark:bg-red-900 bg-opacity-50 dark:bg-opacity-20 border border-red-300 dark:border-red-600 p-4 rounded-xl">
<h3 class="text-sm font-medium text-red-700 dark:text-red-300 mb-2">회원탈퇴</h3>
<p class="text-xs text-red-600 dark:text-red-200 mb-4">
탈퇴 시 계정이 삭제되고 개인정보가 원본 데이터로 복원됩니다.
관리자 승인 후 처리되며, 탈퇴 후 재가입 시 기존 정보가 초기화됩니다.
</p>
<a href="{% url 'accounts:withdrawal_request' %}"
class="inline-block px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition duration-200"
onclick="return confirm('정말로 회원탈퇴를 요청하시겠습니까?')">
회원탈퇴 요청
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// 사진 업로드 시 미리보기
document.querySelector('input[type=file][name$=사진]').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(ev) {
document.getElementById('profile-preview').src = ev.target.result;
};
reader.readAsDataURL(file);
// 사진 업로드 시 미리보기 (필드가 없으면 안전하게 건너뜀)
(function() {
const fileInput = document.querySelector('input[type=file][name$=사진]');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const file = e.target.files && e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(ev) {
const preview = document.getElementById('profile-preview');
if (preview) {
preview.src = ev.target.result;
}
};
reader.readAsDataURL(file);
}
});
}
})();
// 통합된 스크립트 - DOMContentLoaded 이벤트 한 번만 사용
document.addEventListener('DOMContentLoaded', function() {
// ===== 햄버거 메뉴 토글 스크립트 =====
const mobileMenuBtn = document.getElementById('mobile-menu-button');
const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown');
if (mobileMenuBtn && mobileMenuDropdown) {
mobileMenuBtn.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenuDropdown.classList.toggle('hidden');
});
// 메뉴 외부 클릭 시 닫기
document.addEventListener('click', function(e) {
if (!mobileMenuDropdown.classList.contains('hidden')) {
mobileMenuDropdown.classList.add('hidden');
}
});
// 메뉴 클릭 시 닫히지 않도록
mobileMenuDropdown.addEventListener('click', function(e) {
e.stopPropagation();
});
}
// ===== 다크모드 토글 스크립트 (메인 페이지와 동일한 로직) =====
const themeToggle = document.getElementById('theme-toggle');
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
const lightIcon = document.getElementById('theme-toggle-light-icon');
const darkIcon = document.getElementById('theme-toggle-dark-icon');
const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile');
const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile');
// 저장된 테마 확인 (메인 페이지와 동일)
const savedTheme = localStorage.getItem('theme');
// 아이콘 초기 설정 함수 (메인 페이지와 동일)
function updateIcons() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
// 다크모드일 때 - 태양 아이콘 표시
if (lightIcon) lightIcon.classList.remove('hidden');
if (darkIcon) darkIcon.classList.add('hidden');
if (lightIconMobile) lightIconMobile.classList.remove('hidden');
if (darkIconMobile) darkIconMobile.classList.add('hidden');
} else {
// 라이트모드일 때 - 달 아이콘 표시
if (lightIcon) lightIcon.classList.add('hidden');
if (darkIcon) darkIcon.classList.remove('hidden');
if (lightIconMobile) lightIconMobile.classList.add('hidden');
if (darkIconMobile) darkIconMobile.classList.remove('hidden');
}
}
// 초기 아이콘 설정
updateIcons();
// 테마 토글 함수 (메인 페이지와 동일)
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
// 라이트 모드로 전환
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
// 다크 모드로 전환
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
updateIcons();
}
// 데스크탑 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// 모바일 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
if (themeToggleMobile) {
themeToggleMobile.addEventListener('click', toggleTheme);
}
});
// 다크모드 토글 스크립트
const themeToggle = document.getElementById('theme-toggle');
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
const lightIcon = document.getElementById('theme-toggle-light-icon');
const darkIcon = document.getElementById('theme-toggle-dark-icon');
const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile');
const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile');
// 아이콘 초기 설정 함수
function updateIcons() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
// 다크모드일 때
if (lightIcon) lightIcon.classList.remove('hidden');
if (darkIcon) darkIcon.classList.add('hidden');
if (lightIconMobile) lightIconMobile.classList.remove('hidden');
if (darkIconMobile) darkIconMobile.classList.add('hidden');
} else {
// 라이트모드일 때
if (lightIcon) lightIcon.classList.add('hidden');
if (darkIcon) darkIcon.classList.remove('hidden');
if (lightIconMobile) lightIconMobile.classList.add('hidden');
if (darkIconMobile) darkIconMobile.classList.remove('hidden');
}
}
// 초기 아이콘 설정
updateIcons();
// 테마 토글 함수
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
// 라이트 모드로 전환
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
// 다크 모드로 전환
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
updateIcons();
}
// 데스크탑 토글 버튼 클릭 이벤트
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// 모바일 토글 버튼 클릭 이벤트
if (themeToggleMobile) {
themeToggleMobile.addEventListener('click', toggleTheme);
}
</script>
</body>
</html>

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>회원탈퇴 요청 | 신라 AMP</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif']
}
}
}
};
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-gradient-to-br from-gray-900 via-gray-800 to-black text-white min-h-screen flex items-center justify-center px-4 font-sans">
<div class="bg-gray-800 bg-opacity-70 backdrop-blur-lg p-8 rounded-2xl shadow-2xl w-full max-w-md transition-all">
<div class="text-center mb-6">
<a href="{% url 'main' %}" class="text-3xl font-bold tracking-tight text-white hover:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer">신라 AMP</a>
<p class="text-sm text-gray-400 mt-2">회원탈퇴 요청</p>
</div>
<!-- 경고 메시지 -->
<div class="bg-red-600 bg-opacity-20 border border-red-500 p-4 rounded-xl mb-6">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
</div>
<div>
<h3 class="text-sm font-medium text-red-300 mb-1">탈퇴 시 주의사항</h3>
<div class="text-xs text-red-200 space-y-1">
<p>• 회원탈퇴 시 계정이 완전히 삭제됩니다</p>
<p>• 탈퇴 후 재가입 시 기존 정보가 초기화됩니다</p>
<p>• 탈퇴 승인은 관리자가 처리합니다</p>
</div>
</div>
</div>
</div>
{% if messages %}
{% for message in messages %}
<div class="p-4 rounded-lg mb-4 {% if message.tags == 'error' %}bg-red-600 text-white{% elif message.tags == 'success' %}bg-green-600 text-white{% else %}bg-blue-600 text-white{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<form method="POST" class="space-y-6">
{% csrf_token %}
{% if form.errors %}
<div class="text-red-400 text-sm mb-4">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}
<!-- 탈퇴 확인 체크박스 -->
<div class="bg-gray-700 bg-opacity-50 p-4 rounded-xl border border-gray-600">
<div class="flex items-start space-x-3">
{{ form.confirm_withdrawal }}
<div class="flex-1">
<label for="{{ form.confirm_withdrawal.id_for_label }}" class="block text-sm font-medium text-white cursor-pointer">
위 주의사항을 모두 확인했으며, 회원탈퇴를 요청합니다
</label>
<p class="text-xs text-gray-400 mt-1">체크 시 탈퇴 요청이 관리자에게 전송됩니다.</p>
</div>
</div>
</div>
<!-- 버튼들 -->
<div class="space-y-3">
<button type="submit" class="w-full py-3 bg-red-600 hover:bg-red-700 active:bg-red-800 rounded-xl text-white font-semibold text-base transition duration-200 shadow-md hover:shadow-lg">
탈퇴 요청 제출
</button>
<a href="{% url 'accounts:custom_profile_edit' %}" class="block w-full py-3 bg-gray-600 hover:bg-gray-700 active:bg-gray-800 rounded-xl text-white font-semibold text-base text-center transition duration-200 shadow-md hover:shadow-lg">
취소
</a>
</div>
</form>
</div>
</body>
</html>

View File

@ -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'),
]

View File

@ -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})

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -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. 발신번호는 반드시 사전 승인된 번호만 사용 가능합니다

6
run
View File

@ -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
ps aux |grep 192.168.1.119:4271
pkill -9 -f "gunicorn .*4271"

View File

@ -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
}
}
})()

View File

@ -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)
}
})
})
})()

View File

@ -273,7 +273,3 @@ select.admin-autocomplete {
display: block;
padding: 6px;
}
.errors .select2-selection {
border: 1px solid var(--error-fg);
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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;
}

View File

@ -21,7 +21,7 @@
}
.login #content {
padding: 20px;
padding: 20px 20px 0;
}
.login #container {

Some files were not shown because too many files have changed in this diff Show More