문자인증 등 추가작업 - 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 from pathlib import Path
import os
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# 환경 변수 로드
load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
@ -71,8 +76,10 @@ AUTHENTICATION_BACKENDS = [
'allauth.account.auth_backends.AuthenticationBackend', 'allauth.account.auth_backends.AuthenticationBackend',
] ]
# 성능 최적화를 위한 미들웨어 순서 조정
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.middleware.cache.UpdateCacheMiddleware', # 캐시 미들웨어 (상단)
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@ -81,6 +88,7 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware', 'allauth.account.middleware.AccountMiddleware',
'C_accounts.middleware.ForcePasswordSetMiddleware', # 강제 비밀번호 설정 미들웨어 'C_accounts.middleware.ForcePasswordSetMiddleware', # 강제 비밀번호 설정 미들웨어
'django.middleware.cache.FetchFromCacheMiddleware', # 캐시 미들웨어 (하단)
] ]
ROOT_URLCONF = 'A_core.urls' ROOT_URLCONF = 'A_core.urls'
@ -149,12 +157,18 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
] ]
# 정적 파일 찾기 설정 (프로덕션 환경)
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
@ -164,17 +178,56 @@ MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATIC_URL = '/static/'
# 로그인/회원가입 redirect 설정 # 로그인/회원가입 redirect 설정
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/accounts/login/' ACCOUNT_LOGOUT_REDIRECT_URL = '/accounts/login/'
# 전화번호로 로그인 (username 사용) # 전화번호로 로그인 (username 사용) - 최신 allauth 설정
ACCOUNT_AUTHENTICATION_METHOD = 'username' # 'email' → 'username' ACCOUNT_LOGIN_METHODS = {'username'} # username으로 로그인
ACCOUNT_SIGNUP_FIELDS = ['username', 'password1', 'password2'] # 회원가입 필드
ACCOUNT_USERNAME_REQUIRED = True # username 필드 사용 (전화번호) ACCOUNT_USERNAME_REQUIRED = True # username 필드 사용 (전화번호)
ACCOUNT_EMAIL_REQUIRED = False # email 필수 아님 ACCOUNT_EMAIL_REQUIRED = False # email 필수 아님
ACCOUNT_USER_MODEL_USERNAME_FIELD = 'username' # 사용자 모델의 username 필드 활성화 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 = [ urlpatterns = [
path('admin/', admin.site.urls), path('kmobsk/', admin.site.urls),
# allauth 비밀번호 재설정을 커스텀 시스템으로 리다이렉트 # allauth 비밀번호 재설정을 커스텀 시스템으로 리다이렉트
path('accounts/password/reset/', RedirectView.as_view(url='/accounts/password_reset/', permanent=False), name='account_reset_password'), path('accounts/password/reset/', RedirectView.as_view(url='/accounts/password_reset/', permanent=False), name='account_reset_password'),
path('accounts/', include('allauth.urls')), # allauth 기본 URL path('accounts/', include('allauth.urls')), # allauth 기본 URL
@ -34,8 +34,13 @@ urlpatterns = [
urlpatterns += [ urlpatterns += [
re_path(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}), 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.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django import forms 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 PersonAdminForm(forms.ModelForm):
class Meta: class Meta:
@ -16,10 +20,10 @@ class PersonAdminForm(forms.ModelForm):
@admin.register(Person) @admin.register(Person)
class PersonAdmin(admin.ModelAdmin): class PersonAdmin(admin.ModelAdmin):
form = PersonAdminForm form = PersonAdminForm
list_display = ['SEQUENCE', '이름', '소속', '직책', '연락처', 'user', '모든사람보기권한', '비밀번호설정필요', '사진'] list_display = ['SEQUENCE', '이름', '소속', '직책', '연락처', 'user', '모든사람보기권한', '비밀번호설정필요', '가입일시', '사진']
list_filter = ['모든사람보기권한', '비밀번호설정필요', '소속', '직책'] list_filter = ['모든사람보기권한', '비밀번호설정필요', '소속', '직책']
search_fields = ['이름', '소속', '직책', '연락처', 'keyword1'] search_fields = ['이름', '소속', '직책', '연락처', 'keyword1']
readonly_fields = ['수정일시', '사진미리보기'] readonly_fields = ['수정일시', '사진미리보기', '가입일시']
list_editable = ['SEQUENCE'] list_editable = ['SEQUENCE']
list_display_links = ['이름'] list_display_links = ['이름']
ordering = ['이름'] ordering = ['이름']
@ -35,7 +39,7 @@ class PersonAdmin(admin.ModelAdmin):
'fields': ('사진', '사진미리보기') 'fields': ('사진', '사진미리보기')
}), }),
('설정', { ('설정', {
'fields': ('모든사람보기권한', '비밀번호설정필요', 'TITLE', 'SEQUENCE', 'keyword1') 'fields': ('모든사람보기권한', '비밀번호설정필요', 'TITLE', 'SEQUENCE', 'keyword1', '가입일시')
}), }),
) )
@ -82,3 +86,199 @@ class PersonAdmin(admin.ModelAdmin):
def has_view_permission(self, request, obj=None): def has_view_permission(self, request, obj=None):
return request.user.is_superuser 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): class BMainConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'B_main' 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( privacy_agreement = forms.BooleanField(
required=True, required=True,
label='정보공개 및 개인정보처리방침 동의', label='정보공개 및 개인정보처리방침 동의',
error_messages={
'required': '회원가입을 계속하기 위해서 정보공개 등에 동의해주세요'
},
widget=forms.CheckboxInput(attrs={ 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' '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: if password1 and password2 and password1 != password2:
raise forms.ValidationError('비밀번호가 일치하지 않습니다.') raise forms.ValidationError('비밀번호가 일치하지 않습니다.')
if not privacy_agreement:
raise forms.ValidationError('정보공개 및 개인정보처리방침 동의는 필수입니다.')
return cleaned_data return cleaned_data
def save(self, name, phone, request, commit=True): def save(self, name, phone, request, commit=True):
@ -200,13 +200,16 @@ class Step2AccountForm(forms.Form):
existing_person = None existing_person = None
if existing_person: if existing_person:
# 기존 미가입 Person이 있으면 user 연결 # 기존 미가입 Person이 있으면 user 연결하고 가입일시 설정
from django.utils import timezone
existing_person.user = user existing_person.user = user
existing_person.가입일시 = timezone.now()
existing_person.save() existing_person.save()
print(f"[DEBUG] 기존 Person 업데이트: {name} (user 연결)") print(f"[DEBUG] 기존 Person 업데이트: {name} (user 연결, 가입일시 기록)")
return user return user
else: else:
# 기존 Person이 없으면 새로 생성 # 기존 Person이 없으면 새로 생성하고 가입일시 설정
from django.utils import timezone
Person.objects.create( Person.objects.create(
user=user, user=user,
이름=name, 이름=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 return user
except Exception as e: except Exception as e:
print(f"[DEBUG] 사용자 생성 중 오류: {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})") print(f"이미 존재하는 Person: {name} ({phone})")
continue continue
# 김봉수, 김태형만 보이게 설정, 나머지는 안보이게 설정
show_in_main = name in ['김봉수', '김태형']
# 새 Person 생성 # 새 Person 생성
person = Person.objects.create( person = Person.objects.create(
이름=name, 이름=name,
@ -131,8 +128,7 @@ def create_persons_from_peopleinfo():
주소=address, 주소=address,
사진=photo, 사진=photo,
TITLE=title, TITLE=title,
SEQUENCE=sequence, SEQUENCE=sequence
보일지여부=show_in_main
) )
created_count += 1 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) SEQUENCE = models.IntegerField(blank=True, null=True)
모든사람보기권한 = models.BooleanField(default=False, verbose_name='모든 사람 보기 권한', help_text='True인 경우 모든 사람을 볼 수 있고, False인 경우 회원가입한 사람만 볼 수 있습니다.') 모든사람보기권한 = models.BooleanField(default=False, verbose_name='모든 사람 보기 권한', help_text='True인 경우 모든 사람을 볼 수 있고, False인 경우 회원가입한 사람만 볼 수 있습니다.')
keyword1 = models.CharField(max_length=50, blank=True, null=True, verbose_name='검색 키워드', help_text='다른 사람들이 당신을 찾을 수 있도록 키워드를 입력하세요 (예: 회계감사)') 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.BooleanField(default=False, verbose_name='비밀번호 설정 필요', help_text='True인 경우 사용자가 메인페이지 접근 시 비밀번호 설정 페이지로 리다이렉트됩니다.')
가입일시 = models.DateTimeField(null=True, blank=True, verbose_name='가입일시', help_text='회원가입을 완료한 날짜와 시간')
class Meta: class Meta:
verbose_name = '사람' verbose_name = '사람'
@ -30,3 +32,85 @@ class Person(models.Model):
def __str__(self): def __str__(self):
return 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="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 --> <!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6"> <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"> <div class="space-x-4 text-sm">
{% if user.is_authenticated %} {% 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"> <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>
</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"> <div class="mb-6">
@ -131,11 +157,17 @@
placeholder="검색..." 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" 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-get="/search/"
hx-trigger="keyup changed delay:500ms" hx-trigger="keyup changed delay:300ms"
hx-target="#card-container" hx-target="#card-container"
hx-include="#search-input" hx-include="#search-input"
hx-indicator="#loading-indicator"
autocomplete="off" 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> </div>
<!-- 카드 목록 --> <!-- 카드 목록 -->
@ -146,6 +178,15 @@
</div> </div>
</div> </div>
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: flex;
}
</style>
<script> <script>
// 다크모드 토글 스크립트 // 다크모드 토글 스크립트
const themeToggle = document.getElementById('theme-toggle'); const themeToggle = document.getElementById('theme-toggle');
@ -207,5 +248,89 @@
themeToggleMobile.addEventListener('click', toggleTheme); themeToggleMobile.addEventListener('click', toggleTheme);
} }
</script> </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> </body>
</html> </html>

View File

@ -7,14 +7,14 @@
src="{{ person.사진.url }}" src="{{ person.사진.url }}"
alt="{{ person.이름 }}" 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" 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 %} {% else %}
<img <img
src="{% static 'B_main/images/default_user.png' %}" src="{% static 'B_main/images/default_user.png' %}"
alt="{{ person.이름 }}" 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" 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 %} {% endif %}
{% if person.이름 %} {% if person.이름 %}
@ -79,45 +79,3 @@
</div> </div>
</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>

View File

@ -12,4 +12,5 @@ urlpatterns = [
path('session_logout/', views.session_logout, name='session_logout'), path('session_logout/', views.session_logout, name='session_logout'),
path('signup/', views.signup_view, name='signup'), path('signup/', views.signup_view, name='signup'),
path('privacy-policy/', views.privacy_policy, name='privacy_policy'), 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.shortcuts import get_object_or_404
from django.db.models import Q, Case, When, Value, IntegerField from django.db.models import Q, Case, When, Value, IntegerField
from django.contrib.auth import login, logout 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 random
import json import json
import time
def password_required(request): def password_required(request):
PASSWORD = '1110' # 실제 비밀번호 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: if request.user.is_authenticated:
next_url = request.GET.get("next", "/") next_url = request.GET.get("next", "/")
if not next_url: if not next_url:
next_url = "/" next_url = "/"
print(f"[DEBUG] User is authenticated, redirecting to: {next_url}")
return redirect(next_url) return redirect(next_url)
if request.method == "POST": if request.method == "POST":
@ -47,33 +45,27 @@ def password_required(request):
# 인증 검사 함수 # 인증 검사 함수
def check_authentication(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: if request.user.is_authenticated:
print(f"[DEBUG] User is authenticated, allowing access")
return None return None
# 세션 인증이 된 사용자도 통과 # 세션 인증이 된 사용자도 통과
if request.session.get("authenticated"): if request.session.get("authenticated"):
print(f"[DEBUG] Session is authenticated, allowing access")
return None return None
# 둘 다 안 된 경우에만 비밀번호 페이지로 리다이렉트 # 둘 다 안 된 경우에만 비밀번호 페이지로 리다이렉트
print(f"[DEBUG] No authentication found, redirecting to password page")
return redirect(f"/accounts/login/?next={request.path}") return redirect(f"/accounts/login/?next={request.path}")
def main(request): def main(request):
print('def main(request):')
auth_check = check_authentication(request) auth_check = check_authentication(request)
if auth_check: if auth_check:
return auth_check return auth_check
# 메인 페이지 접속 로그 기록
log_main_access(request)
# 현재 사용자의 Person 정보 가져오기 # 현재 사용자의 Person 정보 가져오기
current_user_person = None current_user_person = None
if request.user.is_authenticated: if request.user.is_authenticated:
@ -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: 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.모든사람보기권한: elif current_user_person and not current_user_person.모든사람보기권한:
# 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시
base_filter = base_filter.filter(user__isnull=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( people = base_filter.annotate(
@ -113,13 +98,6 @@ def main(request):
) )
).order_by('sequence_order', 'SEQUENCE', '이름') ).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}) return render(request, 'B_main/main.htm', {'people': people})
@ -130,7 +108,6 @@ def search_people(request):
return auth_check return auth_check
query = request.GET.get('q', '') query = request.GET.get('q', '')
print(f"[DEBUG] 검색 쿼리: '{query}'")
# 현재 사용자의 Person 정보 가져오기 # 현재 사용자의 Person 정보 가져오기
current_user_person = None current_user_person = None
@ -144,19 +121,12 @@ def search_people(request):
base_filter = Person.objects.all() 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: 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.모든사람보기권한: elif current_user_person and not current_user_person.모든사람보기권한:
# 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시
base_filter = base_filter.filter(user__isnull=False) base_filter = base_filter.filter(user__isnull=False)
print(f"[DEBUG] 검색 - 회원가입자만 표시 모드: {current_user_person.이름}")
else:
print(f"[DEBUG] 검색 - 모든 사람 표시 모드 (모든사람보기권한: {current_user_person.모든사람보기권한})")
if query: if query:
# 이름, 소속, 직책, 키워드로 검색 # 이름, 소속, 직책, 키워드로 검색
@ -179,9 +149,6 @@ def search_people(request):
output_field=IntegerField(), output_field=IntegerField(),
) )
).order_by('sequence_order', 'SEQUENCE', '이름') ).order_by('sequence_order', 'SEQUENCE', '이름')
print(f"[DEBUG] 검색 결과: {people.count()}")
for person in people:
print(f"[DEBUG] - {person.이름} (소속: {person.소속}, 직책: {person.직책})")
else: else:
# 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬
people = base_filter.filter( people = base_filter.filter(
@ -195,7 +162,10 @@ def search_people(request):
output_field=IntegerField(), output_field=IntegerField(),
) )
).order_by('sequence_order', 'SEQUENCE', '이름') ).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}) return render(request, 'B_main/partials/card_list.htm', {'people': people})
@ -279,8 +249,6 @@ def withdraw(request):
# 로그아웃 # 로그아웃
logout(request) logout(request)
print(f"[DEBUG] 회원탈퇴 완료: {user_phone} (User 삭제, Person 연결 해제)")
return JsonResponse({'success': True}) return JsonResponse({'success': True})
except Person.DoesNotExist: except Person.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Person 정보를 찾을 수 없습니다.'}) return JsonResponse({'success': False, 'error': 'Person 정보를 찾을 수 없습니다.'})
@ -326,14 +294,29 @@ def signup_view(request):
# 폼 검증에서 이미 허가되지 않은 사용자 체크를 했으므로 여기서는 제거 # 폼 검증에서 이미 허가되지 않은 사용자 체크를 했으므로 여기서는 제거
code = str(random.randint(100000, 999999)) code = str(random.randint(100000, 999999))
# 실제 SMS 발송
sms_result = send_verification_sms(phone, code)
if sms_result['success']:
request.session['signup_code'] = code request.session['signup_code'] = code
request.session['signup_name'] = name request.session['signup_name'] = name
request.session['signup_phone'] = phone request.session['signup_phone'] = phone
request.session['signup_verified'] = False request.session['signup_verified'] = False
print(f"[DEBUG] 인증번호 발송: {name} ({phone}) - {code}") request.session['signup_code_sent_at'] = int(time.time())
# 전화번호 인증 로그 기록
log_phone_verification(request, phone)
return render(request, 'B_main/signup.html', { return render(request, 'B_main/signup.html', {
'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.' '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: else:
# 폼 에러 메시지 확인 # 폼 에러 메시지 확인
error_message = '입력 정보를 확인해주세요.' error_message = '입력 정보를 확인해주세요.'
@ -352,6 +335,16 @@ def signup_view(request):
if form.is_valid(): if form.is_valid():
verification_code = form.cleaned_data['verification_code'] verification_code = form.cleaned_data['verification_code']
session_code = request.session.get('signup_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: if verification_code and verification_code == session_code:
# 인증 성공 # 인증 성공
request.session['signup_verified'] = True 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}) return render(request, 'B_main/signup.html', {'step': 1, 'form1': form, 'code_sent': False})
# 2단계: 이메일, 비밀번호, 비밀번호 확인 # 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': if request.method == 'POST':
form2 = Step2AccountForm(request.POST) form2 = Step2AccountForm(request.POST)
if form2.is_valid(): if form2.is_valid():
user = form2.save(name, phone, request) user = form2.save(name, phone, request)
# 회원가입 로그 기록
log_signup(request, user)
login(request, user, backend='django.contrib.auth.backends.ModelBackend') login(request, user, backend='django.contrib.auth.backends.ModelBackend')
# 세션 정리 # 세션 정리
for key in ['signup_code', 'signup_name', 'signup_phone', 'signup_verified', 'signup_step']: for key in ['signup_code', 'signup_name', 'signup_phone', 'signup_verified', 'signup_step']:
@ -392,3 +405,8 @@ def signup_view(request):
def privacy_policy(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 import forms
from django.contrib.auth import get_user_model 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 random
import re import re
@ -19,61 +19,97 @@ class CustomFileInput(forms.FileInput):
return context return context
class ProfileFullEditForm(forms.ModelForm): class ProfileFullEditForm(forms.ModelForm):
# 통합된 이름 필드 (편집 불가능) # 읽기 전용 필드들
full_name = forms.CharField( full_name = forms.CharField(
label="이름", label="이름",
required=False, required=False,
widget=forms.TextInput(attrs={ 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', 'readonly': 'readonly',
'placeholder': '이름' '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: class Meta:
model = Person model = Person
fields = [ fields = [
'소속', '직책', '주소', '사진', 'keyword1' '소속', '직책', '주소', 'keyword1', '소개글'
] ]
widgets = { widgets = {
'소속': forms.TextInput(attrs={ '소속': 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': '소속' 'placeholder': '소속 (예: 신라대학교 회계학과)'
}), }),
'직책': forms.TextInput(attrs={ '직책': 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': '직책' 'placeholder': '직책 (예: 교수, 학생, 직원)'
}), }),
'주소': forms.TextInput(attrs={ '주소': 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': '주소' '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={ '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': '검색 키워드 (예: 회계감사)' '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): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
super().__init__(*args, **kwargs) 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: if self.instance and self.instance.pk:
# 기존 Person 인스턴스의 데이터로 초기화 # 이름 설정 (Person 모델의 이름 필드 사용)
for field_name in self.fields: self.fields['full_name'].initial = self.instance.이름
if field_name == 'full_name':
continue # 전화번호 설정 (Person 모델의 연락처 필드 사용)
if hasattr(self.instance, field_name): self.fields['phone_display'].initial = self.instance.연락처
self.fields[field_name].initial = getattr(self.instance, field_name)
# 생년월일 설정
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): def save(self, commit=True):
# Person 모델 저장 (User 모델은 수정하지 않음) # Person 모델 저장 (User 모델은 수정하지 않음)
@ -234,10 +270,6 @@ class PasswordChangeStep1Form(forms.Form):
return cleaned_data return cleaned_data
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
class PasswordChangeStep2Form(forms.Form): class PasswordChangeStep2Form(forms.Form):
"""비밀번호 변경 2단계: 새 비밀번호 입력""" """비밀번호 변경 2단계: 새 비밀번호 입력"""
new_password1 = forms.CharField( new_password1 = forms.CharField(
@ -267,3 +299,40 @@ class PasswordChangeStep2Form(forms.Form):
raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.') raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.')
return cleaned_data 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="max-w-5xl mx-auto px-4 py-4">
<div class="flex justify-between items-center"> <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"> <div class="space-x-4 text-sm">
{% if user.is_authenticated %} {% 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"> <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="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 --> <!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6"> <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"> <div class="space-x-4 text-sm">
{% if user.is_authenticated %} {% 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"> <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> </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="max-w-5xl mx-auto px-4 py-8">
<!-- 헤더와 다크모드 토글 --> <!-- 헤더와 다크모드 토글 -->
<div class="flex justify-between items-center mb-6"> <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"> <div class="space-x-4 text-sm">
{% if user.is_authenticated %} {% 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"> <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"> <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"> <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> <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"> <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> <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"> <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> <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> </button>
</div> </div>
</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> </div>
{% elif request.session.authenticated %} {% elif request.session.authenticated %}
<a href="{% url 'session_logout' %}" class="text-red-400 hover:text-red-500">로그아웃</a> <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="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"> <div class="text-center mb-6">
<h2 class="text-2xl font-bold tracking-tight text-white">프로필 수정</h2> <h2 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">프로필 수정</h2>
<p class="text-sm text-gray-400 mt-2">개인 정보를 수정하세요</p> <p class="text-sm text-gray-600 dark:text-gray-400 mt-2">개인 정보를 수정하세요</p>
</div> </div>
{% if messages %} {% if messages %}
@ -181,70 +158,58 @@ input[type="file"]::-webkit-file-upload-button:hover {
</div> </div>
{% endif %} {% endif %}
<!-- 편집 불가능한 필드들 (표시만) --> <!-- 편집 불가능한 필드들 (회색 배경) -->
<div> <div class="mb-6">
<label class="block mb-1 text-sm text-gray-300">이름</label>
<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 }} {{ form.full_name }}
</div> </div>
<div> <div class="mb-4">
<label class="block mb-1 text-sm text-gray-300">전화번호</label> <label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.phone_display.label }}</label>
<input type="text" value="{{ user.username }}" readonly {{ form.phone_display }}
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> </div>
{% if form.instance.생년월일 %} <div class="mb-4">
<div> <label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.birth_date_display.label }}</label>
<label class="block mb-1 text-sm text-gray-300">생년월일</label> {{ form.birth_date_display }}
<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> </div>
{% endif %}
{% if form.instance.TITLE %} <div class="mb-4">
<div> <label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.amp_title_display.label }}</label>
<label class="block mb-1 text-sm text-gray-300">TITLE</label> {{ form.amp_title_display }}
<input type="text" value="{{ form.instance.TITLE }}" readonly </div>
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> </div>
{% endif %}
<!-- 편집 가능한 필드들 --> <!-- 편집 가능한 필드들 (파란색 테두리) -->
<div> <div class="border-t border-gray-300 dark:border-gray-600 pt-6">
<label for="{{ form.소속.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.소속.label }}</label>
<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.소속 }} {{ form.소속 }}
</div> </div>
<div>
<label for="{{ form.직책.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.직책.label }}</label> <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.직책 }} {{ form.직책 }}
</div> </div>
<div>
<label for="{{ form.주소.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.주소.label }}</label> <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.주소 }} {{ form.주소 }}
</div> </div>
<div>
<label for="{{ form.사진.id_for_label }}" class="block mb-1 text-sm text-gray-300">프로필 사진</label>
{{ form.사진 }}
{% if form.instance.사진 and form.instance.사진.url %} <div class="mb-4">
<div class="mt-2"> <label for="{{ form.keyword1.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.keyword1.label }}</label>
<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>
<!-- 키워드 섹션 -->
<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="mb-3">
{{ form.keyword1 }} {{ form.keyword1 }}
</div> </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> </div>
<button type="submit" <button type="submit"
@ -254,9 +219,8 @@ input[type="file"]::-webkit-file-upload-button:hover {
</form> </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="text-center">
<div class="space-y-3"> <div class="space-y-3">
<a href="{% url 'accounts:password_change_logged_in' %}" <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"> 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">
@ -266,30 +230,75 @@ input[type="file"]::-webkit-file-upload-button:hover {
</div> </div>
</div> </div>
<div class="mt-6 text-center text-sm"> <!-- 회원탈퇴 섹션 -->
<a href="{% url 'main' %}" class="text-blue-400 hover:text-blue-500 transition"> <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> </a>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// 사진 업로드 시 미리보기 // 사진 업로드 시 미리보기 (필드가 없으면 안전하게 건너뜀)
document.querySelector('input[type=file][name$=사진]').addEventListener('change', function(e) { (function() {
const file = e.target.files[0]; 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) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(ev) { reader.onload = function(ev) {
document.getElementById('profile-preview').src = ev.target.result; const preview = document.getElementById('profile-preview');
if (preview) {
preview.src = ev.target.result;
}
}; };
reader.readAsDataURL(file); 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 themeToggle = document.getElementById('theme-toggle');
const themeToggleMobile = document.getElementById('theme-toggle-mobile'); const themeToggleMobile = document.getElementById('theme-toggle-mobile');
const lightIcon = document.getElementById('theme-toggle-light-icon'); const lightIcon = document.getElementById('theme-toggle-light-icon');
@ -297,18 +306,21 @@ input[type="file"]::-webkit-file-upload-button:hover {
const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile'); const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile');
const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile'); const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile');
// 아이콘 초기 설정 함수 // 저장된 테마 확인 (메인 페이지와 동일)
const savedTheme = localStorage.getItem('theme');
// 아이콘 초기 설정 함수 (메인 페이지와 동일)
function updateIcons() { function updateIcons() {
const isDark = document.documentElement.classList.contains('dark'); const isDark = document.documentElement.classList.contains('dark');
if (isDark) { if (isDark) {
// 다크모드일 때 // 다크모드일 때 - 태양 아이콘 표시
if (lightIcon) lightIcon.classList.remove('hidden'); if (lightIcon) lightIcon.classList.remove('hidden');
if (darkIcon) darkIcon.classList.add('hidden'); if (darkIcon) darkIcon.classList.add('hidden');
if (lightIconMobile) lightIconMobile.classList.remove('hidden'); if (lightIconMobile) lightIconMobile.classList.remove('hidden');
if (darkIconMobile) darkIconMobile.classList.add('hidden'); if (darkIconMobile) darkIconMobile.classList.add('hidden');
} else { } else {
// 라이트모드일 때 // 라이트모드일 때 - 달 아이콘 표시
if (lightIcon) lightIcon.classList.add('hidden'); if (lightIcon) lightIcon.classList.add('hidden');
if (darkIcon) darkIcon.classList.remove('hidden'); if (darkIcon) darkIcon.classList.remove('hidden');
if (lightIconMobile) lightIconMobile.classList.add('hidden'); if (lightIconMobile) lightIconMobile.classList.add('hidden');
@ -319,7 +331,7 @@ input[type="file"]::-webkit-file-upload-button:hover {
// 초기 아이콘 설정 // 초기 아이콘 설정
updateIcons(); updateIcons();
// 테마 토글 함수 // 테마 토글 함수 (메인 페이지와 동일)
function toggleTheme() { function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark'); const isDark = document.documentElement.classList.contains('dark');
@ -336,15 +348,16 @@ input[type="file"]::-webkit-file-upload-button:hover {
updateIcons(); updateIcons();
} }
// 데스크탑 토글 버튼 클릭 이벤트 // 데스크탑 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
if (themeToggle) { if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme); themeToggle.addEventListener('click', toggleTheme);
} }
// 모바일 토글 버튼 클릭 이벤트 // 모바일 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
if (themeToggleMobile) { if (themeToggleMobile) {
themeToggleMobile.addEventListener('click', toggleTheme); themeToggleMobile.addEventListener('click', toggleTheme);
} }
});
</script> </script>
</body> </body>
</html> </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_reset/', views.password_reset, name='password_reset'),
path('password_change_logged_in/', views.password_change_logged_in, name='password_change_logged_in'), 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('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 django.http import JsonResponse
from .forms import ( from .forms import (
ProfileFullEditForm, PasswordChangeStep1Form, PasswordChangeStep2Form, 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 random
import time
User = get_user_model() User = get_user_model()
@ -25,7 +29,43 @@ def profile_edit(request):
if request.method == 'POST': if request.method == 'POST':
form = ProfileFullEditForm(request.POST, request.FILES, user=request.user, instance=person) form = ProfileFullEditForm(request.POST, request.FILES, user=request.user, instance=person)
if form.is_valid(): 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() form.save()
# 프로필 수정 로그 기록
log_profile_update(request, request.user, changed_fields, field_changes)
messages.success(request, '프로필이 성공적으로 업데이트되었습니다.') messages.success(request, '프로필이 성공적으로 업데이트되었습니다.')
return redirect('accounts:custom_profile_edit') return redirect('accounts:custom_profile_edit')
else: else:
@ -58,15 +98,23 @@ def password_change(request):
form1 = PasswordChangeStep1Form(request.POST, user=request.user) form1 = PasswordChangeStep1Form(request.POST, user=request.user)
if form1.is_valid(): if form1.is_valid():
phone = form1.cleaned_data['phone'] phone = form1.cleaned_data['phone']
# 인증번호 생성 (실제로는 SMS 발송) # 인증번호 생성 및 실제 SMS 발송
verification_code = str(random.randint(100000, 999999)) verification_code = str(random.randint(100000, 999999))
print(f"[DEBUG] 인증번호: {verification_code}") # 실제로는 SMS 발송
# 실제 SMS 발송
sms_result = send_verification_sms(phone, verification_code)
if sms_result['success']:
request.session['password_change_code'] = verification_code request.session['password_change_code'] = verification_code
request.session['password_change_phone'] = phone request.session['password_change_phone'] = phone
request.session['password_change_step'] = 1 request.session['password_change_step'] = 1
request.session['password_change_code_sent_at'] = int(time.time())
message = '인증번호가 발송되었습니다.' message = '인증번호가 발송되었습니다.'
code_sent = True code_sent = True
print(f"[DEBUG] 비밀번호 변경 SMS 발송 성공: {phone} - {verification_code}")
else:
error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'
print(f"[DEBUG] 비밀번호 변경 SMS 발송 실패: {sms_result['error']}")
else: else:
error = '전화번호를 확인해주세요.' error = '전화번호를 확인해주세요.'
elif action == 'verify_code': elif action == 'verify_code':
@ -74,8 +122,13 @@ def password_change(request):
if form1.is_valid(): if form1.is_valid():
input_code = form1.cleaned_data['verification_code'] input_code = form1.cleaned_data['verification_code']
stored_code = request.session.get('password_change_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_verified'] = True
request.session['password_change_step'] = 2 request.session['password_change_step'] = 2
return redirect('accounts:password_change') 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 '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': if request.method == 'POST':
form2 = PasswordChangeStep2Form(request.POST) form2 = PasswordChangeStep2Form(request.POST)
if form2.is_valid(): if form2.is_valid():
@ -98,6 +168,9 @@ def password_change(request):
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
# 비밀번호 변경 로그 기록
log_password_change(request, request.user)
# 세션 정리 # 세션 정리
del request.session['password_change_step'] del request.session['password_change_step']
del request.session['password_change_code'] del request.session['password_change_code']
@ -146,15 +219,23 @@ def password_reset(request):
form1 = PasswordResetStep1Form(request.POST) form1 = PasswordResetStep1Form(request.POST)
if form1.is_valid(): if form1.is_valid():
phone = form1.cleaned_data['phone'] phone = form1.cleaned_data['phone']
# 인증번호 생성 (실제로는 SMS 발송) # 인증번호 생성 및 실제 SMS 발송
verification_code = str(random.randint(100000, 999999)) verification_code = str(random.randint(100000, 999999))
print(f"[DEBUG] 비밀번호 찾기 인증번호: {verification_code}") # 실제로는 SMS 발송
# 실제 SMS 발송
sms_result = send_verification_sms(phone, verification_code)
if sms_result['success']:
request.session['password_reset_code'] = verification_code request.session['password_reset_code'] = verification_code
request.session['password_reset_phone'] = phone request.session['password_reset_phone'] = phone
request.session['password_reset_step'] = 1 request.session['password_reset_step'] = 1
request.session['password_reset_code_sent_at'] = int(time.time())
message = '인증번호가 발송되었습니다.' message = '인증번호가 발송되었습니다.'
code_sent = True code_sent = True
print(f"[DEBUG] 비밀번호 찾기 SMS 발송 성공: {phone} - {verification_code}")
else:
error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'
print(f"[DEBUG] 비밀번호 찾기 SMS 발송 실패: {sms_result['error']}")
else: else:
error = '전화번호를 확인해주세요.' error = '전화번호를 확인해주세요.'
elif action == 'verify_code': elif action == 'verify_code':
@ -162,8 +243,13 @@ def password_reset(request):
if form1.is_valid(): if form1.is_valid():
input_code = form1.cleaned_data['verification_code'] input_code = form1.cleaned_data['verification_code']
stored_code = request.session.get('password_reset_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_verified'] = True
request.session['password_reset_step'] = 2 request.session['password_reset_step'] = 2
return redirect('accounts:password_reset') 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 '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': if request.method == 'POST':
form2 = ForcePasswordSetForm(request.POST) form2 = ForcePasswordSetForm(request.POST)
if form2.is_valid(): if form2.is_valid():
@ -225,6 +328,9 @@ def password_change_logged_in(request):
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
# 비밀번호 변경 로그 기록
log_password_change(request, request.user)
messages.success(request, '비밀번호가 성공적으로 변경되었습니다.') messages.success(request, '비밀번호가 성공적으로 변경되었습니다.')
return redirect('accounts:custom_profile_edit') return redirect('accounts:custom_profile_edit')
else: else:
@ -251,6 +357,9 @@ def force_password_set(request):
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
# 비밀번호 변경 로그 기록
log_password_change(request, request.user)
# 비밀번호 설정 필요 플래그 해제 # 비밀번호 설정 필요 플래그 해제
person.비밀번호설정필요 = False person.비밀번호설정필요 = False
person.save() person.save()
@ -269,3 +378,44 @@ def force_password_set(request):
return render(request, 'C_accounts/force_password_set.html', {'form': form}) 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. 발신번호는 반드시 사전 승인된 번호만 사용 가능합니다

4
run
View File

@ -1,9 +1,11 @@
rm -rf /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2/staticfiles rm -rf /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2/staticfiles
python /volume1/docker/Python/bongsite_django/sillaAMP_contact_V2/manage.py collectstatic 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
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 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; display: block;
padding: 6px; 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-fg: #333;
--body-bg: #fff; --body-bg: #fff;
--body-quiet-color: #666; --body-quiet-color: #666;
--body-medium-color: #444;
--body-loud-color: #000; --body-loud-color: #000;
--header-color: #ffc; --header-color: #ffc;
@ -23,11 +22,11 @@ html[data-theme="light"],
--breadcrumbs-fg: #c4dce8; --breadcrumbs-fg: #c4dce8;
--breadcrumbs-link-fg: var(--body-bg); --breadcrumbs-link-fg: var(--body-bg);
--breadcrumbs-bg: #264b5d; --breadcrumbs-bg: var(--primary);
--link-fg: #417893; --link-fg: #417893;
--link-hover-color: #036; --link-hover-color: #036;
--link-selected-fg: var(--secondary); --link-selected-fg: #5b80b2;
--hairline-color: #e8e8e8; --hairline-color: #e8e8e8;
--border-color: #ccc; --border-color: #ccc;
@ -43,10 +42,10 @@ html[data-theme="light"],
--selected-row: #ffc; --selected-row: #ffc;
--button-fg: #fff; --button-fg: #fff;
--button-bg: var(--secondary); --button-bg: var(--primary);
--button-hover-bg: #205067; --button-hover-bg: #609ab6;
--default-button-bg: #205067; --default-button-bg: var(--secondary);
--default-button-hover-bg: var(--secondary); --default-button-hover-bg: #205067;
--close-button-bg: #747474; --close-button-bg: #747474;
--close-button-hover-bg: #333; --close-button-hover-bg: #333;
--delete-button-bg: #ba2121; --delete-button-bg: #ba2121;
@ -57,6 +56,8 @@ html[data-theme="light"],
--object-tools-hover-bg: var(--close-button-hover-bg); --object-tools-hover-bg: var(--close-button-hover-bg);
--font-family-primary: --font-family-primary:
-apple-system,
BlinkMacSystemFont,
"Segoe UI", "Segoe UI",
system-ui, system-ui,
Roboto, Roboto,
@ -85,8 +86,6 @@ html[data-theme="light"],
"Segoe UI Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Segoe UI Symbol",
"Noto Color Emoji"; "Noto Color Emoji";
color-scheme: light;
} }
html, body { html, body {
@ -150,6 +149,7 @@ h1 {
margin: 0 0 20px; margin: 0 0 20px;
font-weight: 300; font-weight: 300;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--body-quiet-color);
} }
h2 { h2 {
@ -165,7 +165,7 @@ h2.subhead {
h3 { h3 {
font-size: 0.875rem; font-size: 0.875rem;
margin: .8em 0 .3em 0; margin: .8em 0 .3em 0;
color: var(--body-medium-color); color: var(--body-quiet-color);
font-weight: bold; font-weight: bold;
} }
@ -173,7 +173,6 @@ h4 {
font-size: 0.75rem; font-size: 0.75rem;
margin: 1em 0 .8em 0; margin: 1em 0 .8em 0;
padding-bottom: 3px; padding-bottom: 3px;
color: var(--body-medium-color);
} }
h5 { h5 {
@ -220,10 +219,6 @@ fieldset {
border-top: 1px solid var(--hairline-color); border-top: 1px solid var(--hairline-color);
} }
details summary {
cursor: pointer;
}
blockquote { blockquote {
font-size: 0.6875rem; font-size: 0.6875rem;
color: #777; color: #777;
@ -320,7 +315,7 @@ td, th {
} }
th { th {
font-weight: 500; font-weight: 600;
text-align: left; text-align: left;
} }
@ -341,7 +336,7 @@ tfoot td {
} }
thead th.required { thead th.required {
font-weight: bold; color: var(--body-loud-color);
} }
tr.alt { tr.alt {
@ -489,13 +484,8 @@ textarea {
vertical-align: top; vertical-align: top;
} }
/* input[type=text], input[type=password], input[type=email], input[type=url],
Minifiers remove the default (text) "type" attribute from "input" HTML tags. input[type=number], input[type=tel], textarea, select, .vTextField {
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 {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
padding: 5px 6px; padding: 5px 6px;
@ -504,13 +494,9 @@ input[type=url], input[type=number], input[type=tel], textarea, select,
background-color: var(--body-bg); background-color: var(--body-bg);
} }
/* input[type=text]:focus, input[type=password]:focus, input[type=email]:focus,
Minifiers remove the default (text) "type" attribute from "input" HTML tags. input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus,
Add input:not([type]) to make the CSS stylesheet work the same. textarea:focus, select:focus, .vTextField:focus {
*/
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 {
border-color: var(--body-quiet-color); border-color: var(--body-quiet-color);
} }
@ -600,7 +586,7 @@ input[type=button][disabled].default {
font-weight: 400; font-weight: 400;
font-size: 0.8125rem; font-size: 0.8125rem;
text-align: left; text-align: left;
background: var(--header-bg); background: var(--primary);
color: var(--header-link-color); 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; 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 { .addlink {
padding-left: 16px; padding-left: 16px;
background: url(../img/icon-addlink.svg) 0 1px no-repeat; background: url(../img/icon-addlink.svg) 0 1px no-repeat;
@ -850,6 +831,10 @@ a.deletelink:focus, a.deletelink:hover {
height: 100%; height: 100%;
} }
#container > div {
flex-shrink: 0;
}
#container > .main { #container > .main {
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;
@ -894,10 +879,9 @@ a.deletelink:focus, a.deletelink:hover {
margin-right: -300px; margin-right: -300px;
} }
@media (forced-colors: active) { #footer {
#content-related { clear: both;
border: 1px solid; padding: 10px;
}
} }
/* COLUMN TYPES */ /* COLUMN TYPES */
@ -935,6 +919,7 @@ a.deletelink:focus, a.deletelink:hover {
padding: 10px 40px; padding: 10px 40px;
background: var(--header-bg); background: var(--header-bg);
color: var(--header-color); color: var(--header-color);
overflow: hidden;
} }
#header a:link, #header a:visited, #logout-form button { #header a:link, #header a:visited, #logout-form button {
@ -945,17 +930,11 @@ a.deletelink:focus, a.deletelink:hover {
text-decoration: underline; text-decoration: underline;
} }
@media (forced-colors: active) {
#header {
border-bottom: 1px solid;
}
}
#branding { #branding {
display: flex; display: flex;
} }
#site-name { #branding h1 {
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-inline-end: 20px; margin-inline-end: 20px;
@ -964,7 +943,7 @@ a.deletelink:focus, a.deletelink:hover {
color: var(--header-branding-color); color: var(--header-branding-color);
} }
#site-name a:link, #site-name a:visited { #branding h1 a:link, #branding h1 a:visited {
color: var(--accent); color: var(--accent);
} }
@ -1121,7 +1100,6 @@ a.deletelink:focus, a.deletelink:hover {
margin: 0; margin: 0;
border-top: 1px solid var(--hairline-color); border-top: 1px solid var(--hairline-color);
width: 100%; width: 100%;
box-sizing: border-box;
} }
.paginator a:link, .paginator a:visited { .paginator a:link, .paginator a:visited {
@ -1165,16 +1143,3 @@ a.deletelink:focus, a.deletelink:hover {
.base-svgs { .base-svgs {
display: none; 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; margin: 0 0 0 30px;
} }
@media (forced-colors: active) {
#changelist-filter {
border: 1px solid;
}
}
#changelist-filter h2 { #changelist-filter h2 {
font-size: 0.875rem; font-size: 0.875rem;
text-transform: uppercase; text-transform: uppercase;
@ -221,9 +215,9 @@
color: var(--link-hover-color); color: var(--link-hover-color);
} }
#changelist-filter #changelist-filter-extra-actions { #changelist-filter #changelist-filter-clear a {
font-size: 0.8125rem; font-size: 0.8125rem;
margin-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color);
} }
@ -271,15 +265,6 @@
background-color: var(--selected-row); 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 { #changelist .actions {
padding: 10px; padding: 10px;
background: var(--body-bg); background: var(--body-bg);

View File

@ -5,8 +5,7 @@
--body-fg: #eeeeee; --body-fg: #eeeeee;
--body-bg: #121212; --body-bg: #121212;
--body-quiet-color: #d0d0d0; --body-quiet-color: #e0e0e0;
--body-medium-color: #e0e0e0;
--body-loud-color: #ffffff; --body-loud-color: #ffffff;
--breadcrumbs-link-fg: #e0e0e0; --breadcrumbs-link-fg: #e0e0e0;
@ -30,8 +29,6 @@
--close-button-bg: #333333; --close-button-bg: #333333;
--close-button-hover-bg: #666666; --close-button-hover-bg: #666666;
color-scheme: dark;
} }
} }
@ -42,8 +39,7 @@ html[data-theme="dark"] {
--body-fg: #eeeeee; --body-fg: #eeeeee;
--body-bg: #121212; --body-bg: #121212;
--body-quiet-color: #d0d0d0; --body-quiet-color: #e0e0e0;
--body-medium-color: #e0e0e0;
--body-loud-color: #ffffff; --body-loud-color: #ffffff;
--breadcrumbs-link-fg: #e0e0e0; --breadcrumbs-link-fg: #e0e0e0;
@ -67,8 +63,6 @@ html[data-theme="dark"] {
--close-button-bg: #333333; --close-button-bg: #333333;
--close-button-hover-bg: #666666; --close-button-hover-bg: #666666;
color-scheme: dark;
} }
/* THEME SWITCH */ /* THEME SWITCH */
@ -84,8 +78,8 @@ html[data-theme="dark"] {
.theme-toggle svg { .theme-toggle svg {
vertical-align: middle; vertical-align: middle;
height: 1.5rem; height: 1rem;
width: 1.5rem; width: 1rem;
display: none; 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 { html[data-theme="light"] .theme-toggle svg.theme-icon-when-light {
display: block; 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 { .required label, label.required {
font-weight: bold; font-weight: bold;
color: var(--body-fg);
} }
/* RADIO BUTTONS */ /* RADIO BUTTONS */
@ -75,20 +76,6 @@ form ul.inline li {
padding-right: 7px; 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 FIELDSETS */
.aligned label { .aligned label {
@ -97,12 +84,14 @@ fieldset .inline-heading,
min-width: 160px; min-width: 160px;
width: 160px; width: 160px;
word-wrap: break-word; word-wrap: break-word;
line-height: 1;
} }
.aligned label:not(.vCheckboxLabel):after { .aligned label:not(.vCheckboxLabel):after {
content: ''; content: '';
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
height: 1.625rem;
} }
.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { .aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
@ -169,10 +158,6 @@ form .aligned select + div.help {
padding-left: 10px; padding-left: 10px;
} }
form .aligned select option:checked {
background-color: var(--selected-row);
}
form .aligned ul li { form .aligned ul li {
list-style: none; list-style: none;
} }
@ -183,7 +168,11 @@ form .aligned table p {
} }
.aligned .vCheckboxLabel { .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, .aligned .vCheckboxLabel + p.help,
@ -205,8 +194,14 @@ fieldset .fieldBox {
width: 200px; width: 200px;
} }
form .wide p.help, form .wide p,
form .wide ul.errorlist, 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 { form .wide div.help {
padding-left: 50px; padding-left: 50px;
} }
@ -220,16 +215,35 @@ form div.help ul {
width: 450px; width: 450px;
} }
/* COLLAPSIBLE FIELDSETS */ /* COLLAPSED FIELDSETS */
.collapse summary .fieldset-heading, fieldset.collapsed * {
.collapse summary .inline-heading { 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; background: transparent;
border: none;
color: currentColor;
display: inline; display: inline;
margin: 0; color: var(--link-fg);
padding: 0;
} }
/* MONOSPACE TEXTAREAS */ /* MONOSPACE TEXTAREAS */
@ -381,16 +395,14 @@ body.popup .submit-row {
position: relative; position: relative;
} }
.inline-related h4, .inline-related h3 {
.inline-related:not(.tabular) .collapse summary {
margin: 0; margin: 0;
color: var(--body-medium-color); color: var(--body-quiet-color);
padding: 5px; padding: 5px;
font-size: 0.8125rem; font-size: 0.8125rem;
background: var(--darkened-bg); background: var(--darkened-bg);
border: 1px solid var(--hairline-color); border-top: 1px solid var(--hairline-color);
border-left-color: var(--darkened-bg); border-bottom: 1px solid var(--hairline-color);
border-right-color: var(--darkened-bg);
} }
.inline-related h3 span.delete { .inline-related h3 span.delete {
@ -409,6 +421,16 @@ body.popup .submit-row {
width: 100%; 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 { .inline-group .tabular fieldset.module {
border: none; border: none;
} }
@ -449,6 +471,17 @@ body.popup .submit-row {
_width: 700px; _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 div.add-row,
.inline-group .tabular tr.add-row td { .inline-group .tabular tr.add-row td {
color: var(--body-quiet-color); color: var(--body-quiet-color);
@ -462,8 +495,11 @@ body.popup .submit-row {
border-bottom: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color);
} }
.inline-group ul.tools a.add,
.inline-group div.add-row a, .inline-group div.add-row a,
.inline-group .tabular tr.add-row td 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; font-size: 0.75rem;
} }

View File

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

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