문자인증 등 추가작업 - 20250825
This commit is contained in:
parent
6ca64d533d
commit
0761716ef5
18
.env
Normal file
18
.env
Normal 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.
BIN
A_core/__pycache__/sms_utils.cpython-313.pyc
Normal file
BIN
A_core/__pycache__/sms_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
A_core/__pycache__/sms_utils.cpython-38.pyc
Normal file
BIN
A_core/__pycache__/sms_utils.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
A_core/__pycache__/views.cpython-38.pyc
Normal file
BIN
A_core/__pycache__/views.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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
166
A_core/sms_utils.py
Normal 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)
|
||||||
@ -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
10
A_core/views.py
Normal 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.
BIN
B_main/__pycache__/email_utils.cpython-38.pyc
Normal file
BIN
B_main/__pycache__/email_utils.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
B_main/__pycache__/log_utils.cpython-38.pyc
Normal file
BIN
B_main/__pycache__/log_utils.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
B_main/__pycache__/signals.cpython-38.pyc
Normal file
BIN
B_main/__pycache__/signals.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
B_main/__pycache__/withdrawal_utils.cpython-38.pyc
Normal file
BIN
B_main/__pycache__/withdrawal_utils.cpython-38.pyc
Normal file
Binary file not shown.
208
B_main/admin.py
208
B_main/admin.py
@ -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
|
||||||
@ -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
252
B_main/email_utils.py
Normal 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
|
||||||
@ -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
232
B_main/log_utils.py
Normal 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'
|
||||||
|
}
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
|||||||
18
B_main/migrations/0011_person_가입일시.py
Normal file
18
B_main/migrations/0011_person_가입일시.py
Normal 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='가입일시'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
B_main/migrations/0012_alter_person_가입일시.py
Normal file
18
B_main/migrations/0012_alter_person_가입일시.py
Normal 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='가입일시'),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
B_main/migrations/0013_add_database_indexes.py
Normal file
38
B_main/migrations/0013_add_database_indexes.py
Normal 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;"
|
||||||
|
),
|
||||||
|
]
|
||||||
18
B_main/migrations/0014_person_소개글.py
Normal file
18
B_main/migrations/0014_person_소개글.py
Normal 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='소개글'),
|
||||||
|
),
|
||||||
|
]
|
||||||
37
B_main/migrations/0015_accesslog.py
Normal file
37
B_main/migrations/0015_accesslog.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
37
B_main/migrations/0016_withdrawalrequest.py
Normal file
37
B_main/migrations/0016_withdrawalrequest.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
21
B_main/migrations/0017_alter_withdrawalrequest_user.py
Normal file
21
B_main/migrations/0017_alter_withdrawalrequest_user.py
Normal 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='탈퇴 요청자'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
B_main/migrations/__pycache__/0011_person_가입일시.cpython-313.pyc
Normal file
BIN
B_main/migrations/__pycache__/0011_person_가입일시.cpython-313.pyc
Normal file
Binary file not shown.
BIN
B_main/migrations/__pycache__/0011_person_가입일시.cpython-38.pyc
Normal file
BIN
B_main/migrations/__pycache__/0011_person_가입일시.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
B_main/migrations/__pycache__/0014_person_소개글.cpython-38.pyc
Normal file
BIN
B_main/migrations/__pycache__/0014_person_소개글.cpython-38.pyc
Normal file
Binary file not shown.
BIN
B_main/migrations/__pycache__/0015_accesslog.cpython-38.pyc
Normal file
BIN
B_main/migrations/__pycache__/0015_accesslog.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
19
B_main/signals.py
Normal 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)
|
||||||
@ -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">×</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>
|
||||||
@ -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>
|
|
||||||
@ -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'),
|
||||||
]
|
]
|
||||||
126
B_main/views.py
126
B_main/views.py
@ -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})
|
||||||
|
|
||||||
@ -273,14 +243,12 @@ def withdraw(request):
|
|||||||
person.user = None
|
person.user = None
|
||||||
person.save()
|
person.save()
|
||||||
|
|
||||||
# User 객체 삭제 (전화번호 계정 삭제)
|
# User 객체 삭제 (전화번호 계정 삭제)
|
||||||
user_phone = request.user.username
|
user_phone = request.user.username
|
||||||
request.user.delete()
|
request.user.delete()
|
||||||
|
|
||||||
# 로그아웃
|
# 로그아웃
|
||||||
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))
|
||||||
request.session['signup_code'] = code
|
|
||||||
request.session['signup_name'] = name
|
# 실제 SMS 발송
|
||||||
request.session['signup_phone'] = phone
|
sms_result = send_verification_sms(phone, code)
|
||||||
request.session['signup_verified'] = False
|
|
||||||
print(f"[DEBUG] 인증번호 발송: {name} ({phone}) - {code}")
|
if sms_result['success']:
|
||||||
return render(request, 'B_main/signup.html', {
|
request.session['signup_code'] = code
|
||||||
'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.'
|
request.session['signup_name'] = name
|
||||||
})
|
request.session['signup_phone'] = phone
|
||||||
|
request.session['signup_verified'] = False
|
||||||
|
request.session['signup_code_sent_at'] = int(time.time())
|
||||||
|
|
||||||
|
# 전화번호 인증 로그 기록
|
||||||
|
log_phone_verification(request, phone)
|
||||||
|
|
||||||
|
return render(request, 'B_main/signup.html', {
|
||||||
|
'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
return render(request, 'B_main/signup.html', {
|
||||||
|
'step': 1, 'form1': form, 'code_sent': False,
|
||||||
|
'error': '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'
|
||||||
|
})
|
||||||
else:
|
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
277
B_main/withdrawal_utils.py
Normal 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
|
||||||
BIN
C_accounts/__pycache__/adapter.cpython-38.pyc
Normal file
BIN
C_accounts/__pycache__/adapter.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
|
||||||
{{ form.full_name }}
|
|
||||||
</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.full_name.label }}</label>
|
||||||
<input type="text" value="{{ user.username }}" readonly
|
{{ form.full_name }}
|
||||||
class="w-full px-4 py-3 rounded-xl bg-gray-600 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if form.instance.생년월일 %}
|
|
||||||
<div>
|
|
||||||
<label class="block mb-1 text-sm text-gray-300">생년월일</label>
|
|
||||||
<input type="text" value="{{ form.instance.생년월일|date:'Y-m-d' }}" readonly
|
|
||||||
class="w-full px-4 py-3 rounded-xl bg-gray-600 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
|
|
||||||
</div>
|
</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.phone_display.label }}</label>
|
||||||
<label class="block mb-1 text-sm text-gray-300">TITLE</label>
|
{{ form.phone_display }}
|
||||||
<input type="text" value="{{ form.instance.TITLE }}" readonly
|
|
||||||
class="w-full px-4 py-3 rounded-xl bg-gray-600 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- 편집 가능한 필드들 -->
|
<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 for="{{ form.소속.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.소속.label }}</label>
|
{{ form.birth_date_display }}
|
||||||
{{ form.소속 }}
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.직책.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.직책.label }}</label>
|
|
||||||
{{ form.직책 }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.주소.id_for_label }}" class="block mb-1 text-sm text-gray-300">{{ form.주소.label }}</label>
|
|
||||||
{{ form.주소 }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.사진.id_for_label }}" class="block mb-1 text-sm text-gray-300">프로필 사진</label>
|
|
||||||
{{ form.사진 }}
|
|
||||||
|
|
||||||
{% if form.instance.사진 and form.instance.사진.url %}
|
<div class="mb-4">
|
||||||
<div class="mt-2">
|
<label class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.amp_title_display.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" />
|
{{ form.amp_title_display }}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- 키워드 섹션 -->
|
<!-- 편집 가능한 필드들 (파란색 테두리) -->
|
||||||
<div class="border-t border-gray-600 pt-4">
|
<div class="border-t border-gray-300 dark:border-gray-600 pt-6">
|
||||||
<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">
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.소속.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.소속.label }}</label>
|
||||||
|
{{ form.소속 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.직책.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.직책.label }}</label>
|
||||||
|
{{ form.직책 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.주소.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.주소.label }}</label>
|
||||||
|
{{ form.주소 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.keyword1.id_for_label }}" class="block mb-1 text-sm text-gray-700 dark:text-gray-300">{{ form.keyword1.label }}</label>
|
||||||
{{ form.keyword1 }}
|
{{ 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,97 +219,145 @@ 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">
|
비밀번호 변경
|
||||||
비밀번호 변경
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
|
||||||
</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">
|
||||||
</a>
|
<h3 class="text-sm font-medium text-red-700 dark:text-red-300 mb-2">회원탈퇴</h3>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-200 mb-4">
|
||||||
|
탈퇴 시 계정이 삭제되고 개인정보가 원본 데이터로 복원됩니다.
|
||||||
|
관리자 승인 후 처리되며, 탈퇴 후 재가입 시 기존 정보가 초기화됩니다.
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'accounts:withdrawal_request' %}"
|
||||||
|
class="inline-block px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition duration-200"
|
||||||
|
onclick="return confirm('정말로 회원탈퇴를 요청하시겠습니까?')">
|
||||||
|
회원탈퇴 요청
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 (file) {
|
if (fileInput) {
|
||||||
const reader = new FileReader();
|
fileInput.addEventListener('change', function(e) {
|
||||||
reader.onload = function(ev) {
|
const file = e.target.files && e.target.files[0];
|
||||||
document.getElementById('profile-preview').src = ev.target.result;
|
if (file) {
|
||||||
};
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(file);
|
reader.onload = function(ev) {
|
||||||
|
const preview = document.getElementById('profile-preview');
|
||||||
|
if (preview) {
|
||||||
|
preview.src = ev.target.result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 통합된 스크립트 - DOMContentLoaded 이벤트 한 번만 사용
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// ===== 햄버거 메뉴 토글 스크립트 =====
|
||||||
|
const mobileMenuBtn = document.getElementById('mobile-menu-button');
|
||||||
|
const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown');
|
||||||
|
|
||||||
|
if (mobileMenuBtn && mobileMenuDropdown) {
|
||||||
|
mobileMenuBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
mobileMenuDropdown.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 외부 클릭 시 닫기
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!mobileMenuDropdown.classList.contains('hidden')) {
|
||||||
|
mobileMenuDropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 클릭 시 닫히지 않도록
|
||||||
|
mobileMenuDropdown.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 다크모드 토글 스크립트 (메인 페이지와 동일한 로직) =====
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
|
||||||
|
const lightIcon = document.getElementById('theme-toggle-light-icon');
|
||||||
|
const darkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||||
|
const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile');
|
||||||
|
const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile');
|
||||||
|
|
||||||
|
// 저장된 테마 확인 (메인 페이지와 동일)
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
|
||||||
|
// 아이콘 초기 설정 함수 (메인 페이지와 동일)
|
||||||
|
function updateIcons() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
// 다크모드일 때 - 태양 아이콘 표시
|
||||||
|
if (lightIcon) lightIcon.classList.remove('hidden');
|
||||||
|
if (darkIcon) darkIcon.classList.add('hidden');
|
||||||
|
if (lightIconMobile) lightIconMobile.classList.remove('hidden');
|
||||||
|
if (darkIconMobile) darkIconMobile.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
// 라이트모드일 때 - 달 아이콘 표시
|
||||||
|
if (lightIcon) lightIcon.classList.add('hidden');
|
||||||
|
if (darkIcon) darkIcon.classList.remove('hidden');
|
||||||
|
if (lightIconMobile) lightIconMobile.classList.add('hidden');
|
||||||
|
if (darkIconMobile) darkIconMobile.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 아이콘 설정
|
||||||
|
updateIcons();
|
||||||
|
|
||||||
|
// 테마 토글 함수 (메인 페이지와 동일)
|
||||||
|
function toggleTheme() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
// 라이트 모드로 전환
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
} else {
|
||||||
|
// 다크 모드로 전환
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데스크탑 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', toggleTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일 토글 버튼 클릭 이벤트 (메인 페이지와 동일)
|
||||||
|
if (themeToggleMobile) {
|
||||||
|
themeToggleMobile.addEventListener('click', toggleTheme);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 다크모드 토글 스크립트
|
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
|
||||||
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
|
|
||||||
const lightIcon = document.getElementById('theme-toggle-light-icon');
|
|
||||||
const darkIcon = document.getElementById('theme-toggle-dark-icon');
|
|
||||||
const lightIconMobile = document.getElementById('theme-toggle-light-icon-mobile');
|
|
||||||
const darkIconMobile = document.getElementById('theme-toggle-dark-icon-mobile');
|
|
||||||
|
|
||||||
// 아이콘 초기 설정 함수
|
|
||||||
function updateIcons() {
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
|
|
||||||
if (isDark) {
|
|
||||||
// 다크모드일 때
|
|
||||||
if (lightIcon) lightIcon.classList.remove('hidden');
|
|
||||||
if (darkIcon) darkIcon.classList.add('hidden');
|
|
||||||
if (lightIconMobile) lightIconMobile.classList.remove('hidden');
|
|
||||||
if (darkIconMobile) darkIconMobile.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
// 라이트모드일 때
|
|
||||||
if (lightIcon) lightIcon.classList.add('hidden');
|
|
||||||
if (darkIcon) darkIcon.classList.remove('hidden');
|
|
||||||
if (lightIconMobile) lightIconMobile.classList.add('hidden');
|
|
||||||
if (darkIconMobile) darkIconMobile.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 초기 아이콘 설정
|
|
||||||
updateIcons();
|
|
||||||
|
|
||||||
// 테마 토글 함수
|
|
||||||
function toggleTheme() {
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
|
|
||||||
if (isDark) {
|
|
||||||
// 라이트 모드로 전환
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
} else {
|
|
||||||
// 다크 모드로 전환
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데스크탑 토글 버튼 클릭 이벤트
|
|
||||||
if (themeToggle) {
|
|
||||||
themeToggle.addEventListener('click', toggleTheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모바일 토글 버튼 클릭 이벤트
|
|
||||||
if (themeToggleMobile) {
|
|
||||||
themeToggleMobile.addEventListener('click', toggleTheme);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
98
C_accounts/templates/C_accounts/withdrawal_request.html
Normal file
98
C_accounts/templates/C_accounts/withdrawal_request.html
Normal 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>
|
||||||
Binary file not shown.
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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 발송
|
|
||||||
|
|
||||||
request.session['password_change_code'] = verification_code
|
# 실제 SMS 발송
|
||||||
request.session['password_change_phone'] = phone
|
sms_result = send_verification_sms(phone, verification_code)
|
||||||
request.session['password_change_step'] = 1
|
|
||||||
message = '인증번호가 발송되었습니다.'
|
if sms_result['success']:
|
||||||
code_sent = True
|
request.session['password_change_code'] = verification_code
|
||||||
|
request.session['password_change_phone'] = phone
|
||||||
|
request.session['password_change_step'] = 1
|
||||||
|
request.session['password_change_code_sent_at'] = int(time.time())
|
||||||
|
message = '인증번호가 발송되었습니다.'
|
||||||
|
code_sent = True
|
||||||
|
print(f"[DEBUG] 비밀번호 변경 SMS 발송 성공: {phone} - {verification_code}")
|
||||||
|
else:
|
||||||
|
error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'
|
||||||
|
print(f"[DEBUG] 비밀번호 변경 SMS 발송 실패: {sms_result['error']}")
|
||||||
else:
|
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 발송
|
|
||||||
|
|
||||||
request.session['password_reset_code'] = verification_code
|
# 실제 SMS 발송
|
||||||
request.session['password_reset_phone'] = phone
|
sms_result = send_verification_sms(phone, verification_code)
|
||||||
request.session['password_reset_step'] = 1
|
|
||||||
message = '인증번호가 발송되었습니다.'
|
if sms_result['success']:
|
||||||
code_sent = True
|
request.session['password_reset_code'] = verification_code
|
||||||
|
request.session['password_reset_phone'] = phone
|
||||||
|
request.session['password_reset_step'] = 1
|
||||||
|
request.session['password_reset_code_sent_at'] = int(time.time())
|
||||||
|
message = '인증번호가 발송되었습니다.'
|
||||||
|
code_sent = True
|
||||||
|
print(f"[DEBUG] 비밀번호 찾기 SMS 발송 성공: {phone} - {verification_code}")
|
||||||
|
else:
|
||||||
|
error = '인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'
|
||||||
|
print(f"[DEBUG] 비밀번호 찾기 SMS 발송 실패: {sms_result['error']}")
|
||||||
else:
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
BIN
media/profile_photos/Bongsu_TwLuh2Z.jpg
Normal file
BIN
media/profile_photos/Bongsu_TwLuh2Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
82
naver_cloud_sms_config.txt
Normal file
82
naver_cloud_sms_config.txt
Normal 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
4
run
@ -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"
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
@ -273,7 +273,3 @@ select.admin-autocomplete {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errors .select2-selection {
|
|
||||||
border: 1px solid var(--error-fg);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user