commit 1693501d1cd615781669f71268f4b1dcb7e7dd19 Author: CPABONG Date: Sun Aug 3 10:28:43 2025 +0900 Initial commit: 신라대학교 AMP 유저관리버전 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..2f9677f --- /dev/null +++ b/.cursorrules @@ -0,0 +1,16 @@ +# Cursor AI 설정 규칙 + +## 터미널 명령어 자동 실행 +- 사용자가 conda activate, python manage.py, pip install 등의 터미널 명령어를 요청하면 자동으로 실행 +- 별도 확인 없이 즉시 명령어 실행 +- Django 프로젝트 관련 명령어 (makemigrations, migrate, runserver 등) 자동 실행 + +## 프로젝트 컨텍스트 +- Django 웹 애플리케이션 (SillaAMP_user) +- Python 3.8 환경 사용 +- 현재 작업 디렉토리: C:\Users\CPABONG\Desktop\SillaAMP_user + +## 코딩 스타일 +- Python/Django 코드 작성 시 PEP 8 준수 +- 한글 주석 사용 가능 +- 명확하고 읽기 쉬운 코드 작성 \ No newline at end of file diff --git a/A_core/__init__.py b/A_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/A_core/__pycache__/__init__.cpython-38.pyc b/A_core/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..4e52d85 Binary files /dev/null and b/A_core/__pycache__/__init__.cpython-38.pyc differ diff --git a/A_core/__pycache__/settings.cpython-38.pyc b/A_core/__pycache__/settings.cpython-38.pyc new file mode 100644 index 0000000..34c35e1 Binary files /dev/null and b/A_core/__pycache__/settings.cpython-38.pyc differ diff --git a/A_core/__pycache__/urls.cpython-38.pyc b/A_core/__pycache__/urls.cpython-38.pyc new file mode 100644 index 0000000..a321963 Binary files /dev/null and b/A_core/__pycache__/urls.cpython-38.pyc differ diff --git a/A_core/__pycache__/wsgi.cpython-38.pyc b/A_core/__pycache__/wsgi.cpython-38.pyc new file mode 100644 index 0000000..e7acbed Binary files /dev/null and b/A_core/__pycache__/wsgi.cpython-38.pyc differ diff --git a/A_core/asgi.py b/A_core/asgi.py new file mode 100644 index 0000000..6aaec3a --- /dev/null +++ b/A_core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for A_core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') + +application = get_asgi_application() diff --git a/A_core/settings.py b/A_core/settings.py new file mode 100644 index 0000000..165b793 --- /dev/null +++ b/A_core/settings.py @@ -0,0 +1,172 @@ +""" +Django settings for A_core project. + +Generated by 'django-admin startproject' using Django 4.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-kst@+h&50%!m$(d!l*qbb0l7f@z#@#me__yye^$5kg%0m%1=im' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + # Django 기본 앱들 + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # 필수: django.contrib.sites + 'django.contrib.sites', + + # allauth 관련 앱들 (순서 중요) + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + + # 소셜 로그인 제공자 (필요한 경우 추가) + # 'allauth.socialaccount.providers.google', + + + # 프로젝트 앱들 + 'B_main', + 'C_accounts', +] + +SITE_ID = 1 + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', + 'C_accounts.middleware.ForcePasswordSetMiddleware', # 강제 비밀번호 설정 미들웨어 +] + +ROOT_URLCONF = 'A_core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], # 이 줄이 반드시 있어야 함 + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'A_core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +STATIC_URL = '/static/' + + + +# 로그인/회원가입 redirect 설정 +LOGIN_REDIRECT_URL = '/' +ACCOUNT_LOGOUT_REDIRECT_URL = '/accounts/login/' + +# 전화번호로 로그인 (username 사용) +ACCOUNT_AUTHENTICATION_METHOD = 'username' # 'email' → 'username' +ACCOUNT_USERNAME_REQUIRED = True # username 필드 사용 (전화번호) +ACCOUNT_EMAIL_REQUIRED = False # email 필수 아님 +ACCOUNT_USER_MODEL_USERNAME_FIELD = 'username' # 사용자 모델의 username 필드 활성화 + diff --git a/A_core/urls.py b/A_core/urls.py new file mode 100644 index 0000000..e50d00a --- /dev/null +++ b/A_core/urls.py @@ -0,0 +1,29 @@ +""" +URL configuration for A_core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.views.generic import RedirectView + + +urlpatterns = [ + path('admin/', admin.site.urls), + # allauth 비밀번호 재설정을 커스텀 시스템으로 리다이렉트 + path('accounts/password/reset/', RedirectView.as_view(url='/accounts/password_reset/', permanent=False), name='account_reset_password'), + path('accounts/', include('allauth.urls')), # allauth 기본 URL + path('accounts/', include('C_accounts.urls')), # 커스텀 계정 URL + path('', include('B_main.urls')), +] diff --git a/A_core/wsgi.py b/A_core/wsgi.py new file mode 100644 index 0000000..0e689b4 --- /dev/null +++ b/A_core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for A_core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') + +application = get_wsgi_application() diff --git a/B_main/__init__.py b/B_main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/B_main/__pycache__/__init__.cpython-38.pyc b/B_main/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..a77d467 Binary files /dev/null and b/B_main/__pycache__/__init__.cpython-38.pyc differ diff --git a/B_main/__pycache__/admin.cpython-38.pyc b/B_main/__pycache__/admin.cpython-38.pyc new file mode 100644 index 0000000..ff21fd5 Binary files /dev/null and b/B_main/__pycache__/admin.cpython-38.pyc differ diff --git a/B_main/__pycache__/apps.cpython-38.pyc b/B_main/__pycache__/apps.cpython-38.pyc new file mode 100644 index 0000000..6e7b005 Binary files /dev/null and b/B_main/__pycache__/apps.cpython-38.pyc differ diff --git a/B_main/__pycache__/forms.cpython-38.pyc b/B_main/__pycache__/forms.cpython-38.pyc new file mode 100644 index 0000000..ad80524 Binary files /dev/null and b/B_main/__pycache__/forms.cpython-38.pyc differ diff --git a/B_main/__pycache__/models.cpython-38.pyc b/B_main/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000..102cc08 Binary files /dev/null and b/B_main/__pycache__/models.cpython-38.pyc differ diff --git a/B_main/__pycache__/peopleinfo.cpython-38.pyc b/B_main/__pycache__/peopleinfo.cpython-38.pyc new file mode 100644 index 0000000..09036ec Binary files /dev/null and b/B_main/__pycache__/peopleinfo.cpython-38.pyc differ diff --git a/B_main/__pycache__/phonelist.cpython-38.pyc b/B_main/__pycache__/phonelist.cpython-38.pyc new file mode 100644 index 0000000..9c13f5d Binary files /dev/null and b/B_main/__pycache__/phonelist.cpython-38.pyc differ diff --git a/B_main/__pycache__/urls.cpython-38.pyc b/B_main/__pycache__/urls.cpython-38.pyc new file mode 100644 index 0000000..78d1067 Binary files /dev/null and b/B_main/__pycache__/urls.cpython-38.pyc differ diff --git a/B_main/__pycache__/views.cpython-38.pyc b/B_main/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000..dbe18d9 Binary files /dev/null and b/B_main/__pycache__/views.cpython-38.pyc differ diff --git a/B_main/admin.py b/B_main/admin.py new file mode 100644 index 0000000..f8a0a75 --- /dev/null +++ b/B_main/admin.py @@ -0,0 +1,84 @@ +from django.contrib import admin +from django.utils.html import format_html +from django import forms +from .models import Person + +class PersonAdminForm(forms.ModelForm): + class Meta: + model = Person + fields = '__all__' + widgets = { + '사진': forms.FileInput(attrs={ + 'style': 'border: 1px solid #ccc; padding: 5px; border-radius: 3px;' + }) + } + +@admin.register(Person) +class PersonAdmin(admin.ModelAdmin): + form = PersonAdminForm + list_display = ['SEQUENCE', '이름', '소속', '직책', '연락처', 'user', '모든사람보기권한', '비밀번호설정필요', '사진'] + list_filter = ['모든사람보기권한', '비밀번호설정필요', '소속', '직책'] + search_fields = ['이름', '소속', '직책', '연락처', 'keyword1'] + readonly_fields = ['수정일시', '사진미리보기'] + list_editable = ['SEQUENCE'] + list_display_links = ['이름'] + ordering = ['이름'] + + fieldsets = ( + ('기본 정보', { + 'fields': ('이름', '연락처', 'user') + }), + ('상세 정보', { + 'fields': ('소속', '직책', '주소', '생년월일') + }), + ('미디어', { + 'fields': ('사진', '사진미리보기') + }), + ('설정', { + 'fields': ('모든사람보기권한', '비밀번호설정필요', 'TITLE', 'SEQUENCE', 'keyword1') + }), + ) + + class Media: + css = { + 'all': ('admin/css/custom_admin.css',) + } + + def 사진미리보기(self, obj): + if obj.사진: + return format_html( + '', + obj.사진.url + ) + return "사진 없음" + 사진미리보기.short_description = '사진 미리보기' + + def 모든사람보기권한(self, obj): + if obj.모든사람보기권한: + return format_html('✓ 모든 사람 보기') + else: + return format_html('👤 회원가입자만 보기') + 모든사람보기권한.short_description = '보기 권한' + + def 비밀번호설정필요(self, obj): + if obj.비밀번호설정필요: + return format_html('⚠️ 비밀번호 설정 필요') + else: + return format_html('✓ 비밀번호 설정 완료') + 비밀번호설정필요.short_description = '비밀번호 설정 상태' + + def 수정일시(self, obj): + return obj.user.date_joined if obj.user else 'N/A' + 수정일시.short_description = '수정일시' + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_view_permission(self, request, obj=None): + return request.user.is_superuser \ No newline at end of file diff --git a/B_main/apps.py b/B_main/apps.py new file mode 100644 index 0000000..18edb53 --- /dev/null +++ b/B_main/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BMainConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'B_main' diff --git a/B_main/clean_duplicates.py b/B_main/clean_duplicates.py new file mode 100644 index 0000000..7943d2d --- /dev/null +++ b/B_main/clean_duplicates.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python +""" +중복된 Person 데이터를 정리하는 스크립트 +""" + +import os +import sys +import django + +# Django 설정 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +django.setup() + +from B_main.models import Person +from django.contrib.auth.models import User + +def clean_duplicates(): + """중복된 Person 데이터 정리""" + print("=" * 60) + print("중복 Person 데이터 정리") + print("=" * 60) + + # 전화번호별로 그룹화 + phone_groups = {} + for person in Person.objects.all(): + if person.연락처: + if person.연락처 not in phone_groups: + phone_groups[person.연락처] = [] + phone_groups[person.연락처].append(person) + + deleted_count = 0 + + for phone, persons in phone_groups.items(): + if len(persons) > 1: + print(f"\n전화번호 {phone}에 대한 중복 발견:") + + # 회원가입 상태별로 분류 + registered = [p for p in persons if p.회원가입상태 == '회원가입'] + not_registered = [p for p in persons if p.회원가입상태 == '미가입'] + withdrawn = [p for p in persons if p.회원가입상태 == '탈퇴'] + + print(f" 회원가입: {len(registered)}개") + print(f" 미가입: {len(not_registered)}개") + print(f" 탈퇴: {len(withdrawn)}개") + + # 정리 로직 + if registered: + # 회원가입된 것이 있으면 나머지 삭제 + keep_person = registered[0] + to_delete = persons[1:] # 첫 번째 것 제외하고 모두 삭제 + + print(f" 유지: {keep_person.이름} (ID: {keep_person.id}, 회원가입상태: {keep_person.회원가입상태})") + + for person in to_delete: + print(f" 삭제: {person.이름} (ID: {person.id}, 회원가입상태: {person.회원가입상태})") + person.delete() + deleted_count += 1 + + elif not_registered: + # 미가입만 있으면 첫 번째 것만 유지 + keep_person = not_registered[0] + to_delete = not_registered[1:] + withdrawn + + print(f" 유지: {keep_person.이름} (ID: {keep_person.id}, 회원가입상태: {keep_person.회원가입상태})") + + for person in to_delete: + print(f" 삭제: {person.이름} (ID: {person.id}, 회원가입상태: {person.회원가입상태})") + person.delete() + deleted_count += 1 + + elif withdrawn: + # 탈퇴만 있으면 첫 번째 것만 유지 + keep_person = withdrawn[0] + to_delete = withdrawn[1:] + + print(f" 유지: {keep_person.이름} (ID: {keep_person.id}, 회원가입상태: {keep_person.회원가입상태})") + + for person in to_delete: + print(f" 삭제: {person.이름} (ID: {person.id}, 회원가입상태: {person.회원가입상태})") + person.delete() + deleted_count += 1 + + print(f"\n총 {deleted_count}개의 중복 데이터가 삭제되었습니다.") + + # 최종 확인 + print("\n최종 중복 확인:") + final_phone_counts = {} + for person in Person.objects.all(): + if person.연락처: + final_phone_counts[person.연락처] = final_phone_counts.get(person.연락처, 0) + 1 + + final_duplicates = {phone: count for phone, count in final_phone_counts.items() if count > 1} + if final_duplicates: + print("여전히 중복된 전화번호가 있습니다:") + for phone, count in final_duplicates.items(): + print(f" {phone}: {count}개") + else: + print("모든 중복이 해결되었습니다.") + +if __name__ == '__main__': + clean_duplicates() \ No newline at end of file diff --git a/B_main/forms.py b/B_main/forms.py new file mode 100644 index 0000000..61fa600 --- /dev/null +++ b/B_main/forms.py @@ -0,0 +1,297 @@ +import re +from django import forms +from django.contrib.auth.models import User +from .models import Person + +def format_phone_number(phone): + """전화번호에서 대시 제거""" + return re.sub(r'[^0-9]', '', phone) + +def format_phone_with_dash(phone): + """전화번호에 대시 추가 (010-1234-5678 형식)""" + phone = format_phone_number(phone) + if len(phone) == 11: + return f"{phone[:3]}-{phone[3:7]}-{phone[7:]}" + return phone + +# 허가된 사람들의 정보 (실제 데이터로 교체 필요) +PEOPLE = [ + {'이름': '김봉수', '연락처': '01033433319'}, + # ... 더 많은 사람들 +] + +def is_allowed_person(name, phone): + """허가된 사람인지 확인""" + formatted_phone = format_phone_number(phone) + for person in PEOPLE: + if person['이름'] == name and person['연락처'] == formatted_phone: + return True + return False + +def is_already_registered(name, phone): + """이미 가입한 사용자인지 확인""" + # 전화번호 포맷팅 적용 + formatted_phone = format_phone_number(phone) + + # 전화번호로 User 검색 (username이 전화번호이므로) + existing_user = User.objects.filter(username=formatted_phone).first() + if existing_user: + # 해당 User와 연결된 Person이 있는 경우 (이미 회원가입한 상태) + try: + person = Person.objects.get(user=existing_user) + return True + except Person.DoesNotExist: + pass + + # 이름과 전화번호로 Person 검색 (user가 있는 경우만 - 이미 회원가입한 상태) + if Person.objects.filter( + 이름=name, + 연락처=formatted_phone, + user__isnull=False + ).exists(): + return True + + return False + +def get_allowed_names(): + """허가된 모든 이름 목록 반환""" + return [person['이름'] for person in PEOPLE] + +def get_phone_by_name(name): + """이름으로 전화번호 찾기""" + for person in PEOPLE: + if person['이름'] == name: + return person['연락처'] + return None + +class CustomFileInput(forms.FileInput): + """"Currently:" 텍스트를 제거하는 커스텀 파일 입력 위젯""" + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + # "Currently:" 텍스트 제거 + if 'help_text' in context: + context['help_text'] = '' + return context + +class Step1PhoneForm(forms.Form): + name = forms.CharField( + max_length=50, + label='이름', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '이름' + }) + ) + phone = forms.CharField( + max_length=11, + label='전화번호', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '01012345678' + }) + ) + verification_code = forms.CharField( + max_length=6, + label='인증번호', + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '6자리 인증번호' + }) + ) + + def clean(self): + cleaned_data = super().clean() + name = cleaned_data.get('name') + phone = cleaned_data.get('phone') + + if name and phone: + # 전화번호 포맷팅 적용 (대시 제거) + formatted_phone = format_phone_number(phone) + cleaned_data['phone'] = formatted_phone + + # 이미 가입한 사용자인지 먼저 확인 + if is_already_registered(name, formatted_phone): + raise forms.ValidationError('이미 가입한 유저입니다') + + # 허가되지 않은 사용자인지 확인 + if not is_allowed_person(name, formatted_phone): + raise forms.ValidationError('초대되지 않은 사용자입니다') + + return cleaned_data + +class Step2AccountForm(forms.Form): + password1 = forms.CharField( + label='Password', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': 'Password' + }) + ) + password2 = forms.CharField( + label='Password (again)', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': 'Password (again)' + }) + ) + privacy_agreement = forms.BooleanField( + required=True, + label='정보공개 동의', + 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' + }) + ) + + def clean(self): + cleaned_data = super().clean() + password1 = cleaned_data.get('password1') + password2 = cleaned_data.get('password2') + + if password1 and password2 and password1 != password2: + raise forms.ValidationError('비밀번호가 일치하지 않습니다.') + + return cleaned_data + + def save(self, name, phone, request, commit=True): + # 전화번호 포맷팅 적용 + formatted_phone = format_phone_number(phone) + + # 기존 사용자가 있는지 확인 (전화번호로) + existing_user = User.objects.filter(username=formatted_phone).first() + if existing_user: + # 해당 User와 연결된 Person이 있는 경우 (이미 회원가입한 상태) + try: + person = Person.objects.get(user=existing_user) + print(f"[DEBUG] 기존 회원가입 사용자 발견: {existing_user.username}") + return existing_user + except Person.DoesNotExist: + pass + + try: + # 새 사용자 생성 (전화번호를 username으로 사용) + user = User.objects.create_user( + username=formatted_phone, + email='', # 이메일은 빈 값으로 설정 + password=self.cleaned_data['password1'], + first_name=name + ) + + # 기존 Person 정보가 있는지 확인 (user가 없는 상태) + # 전화번호는 대시 제거하여 비교 + existing_person = Person.objects.filter( + 이름=name, + user__isnull=True + ).first() + + # 전화번호 비교 (대시 제거하여) + if existing_person: + person_phone_clean = re.sub(r'[^0-9]', '', existing_person.연락처) + if person_phone_clean != formatted_phone: + existing_person = None + + if existing_person: + # 기존 미가입 Person이 있으면 user 연결 + existing_person.user = user + existing_person.save() + print(f"[DEBUG] 기존 Person 업데이트: {name} (user 연결)") + return user + else: + # 기존 Person이 없으면 새로 생성 + Person.objects.create( + user=user, + 이름=name, + 연락처=format_phone_with_dash(formatted_phone), # 대시 있는 전화번호로 저장 + 소속='', + 직책='', + 주소='', + 사진='profile_photos/default_user.png' + ) + print(f"[DEBUG] 새 Person 생성: {name}") + return user + except Exception as e: + print(f"[DEBUG] 사용자 생성 중 오류: {e}") + # 이미 존재하는 사용자인 경우 기존 사용자 반환 + existing_user = User.objects.filter(username=formatted_phone).first() + if existing_user: + return existing_user + raise e + +class PhoneVerificationForm(forms.Form): + """전화번호 인증 폼""" + name = forms.ChoiceField( + choices=[('', '이름을 선택하세요')] + [(name, name) for name in get_allowed_names()], + label='이름', + widget=forms.Select(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' + }) + ) + phone = forms.CharField( + max_length=11, + label='전화번호', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '01012345678' + }) + ) + verification_code = forms.CharField( + max_length=6, + label='인증번호', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '6자리 인증번호' + }) + ) + + def clean(self): + cleaned_data = super().clean() + name = cleaned_data.get('name') + phone = cleaned_data.get('phone') + + if name and phone: + # 전화번호 포맷팅 적용 (대시 제거) + formatted_phone = format_phone_number(phone) + cleaned_data['phone'] = formatted_phone + + if not is_allowed_person(name, formatted_phone): + raise forms.ValidationError('이름과 전화번호가 일치하지 않습니다.') + + return cleaned_data + +class PersonForm(forms.ModelForm): + """Person 모델 폼""" + class Meta: + model = Person + fields = ['이름', '소속', '직책', '연락처', '주소', '생년월일', '사진', 'keyword1'] + widgets = { + '이름': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-200 bg-opacity-80 text-gray-500 border border-gray-300 focus:outline-none', + 'readonly': True, + 'tabindex': '-1', + }), + '소속': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition' + }), + '직책': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition' + }), + '연락처': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-200 bg-opacity-80 text-gray-500 border border-gray-300 focus:outline-none', + 'readonly': True, + 'tabindex': '-1', + }), + '주소': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition' + }), + '생년월일': forms.DateInput(attrs={ + 'type': 'date', + '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' + }), + '사진': 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' + }), + 'keyword1': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '검색 키워드 (예: 회계감사)' + }), + } diff --git a/B_main/manual_populate.py b/B_main/manual_populate.py new file mode 100644 index 0000000..b0aecf7 --- /dev/null +++ b/B_main/manual_populate.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python +""" +수동으로 Person 데이터를 초기화하고 peopleinfo.py의 데이터로 채우는 스크립트 + +사용법: +python manage.py shell +exec(open('B_main/manual_populate.py').read()) +""" + +import os +import sys +import django +from datetime import datetime + +# Django 설정 +import sys +import os + +# 프로젝트 루트 경로를 Python 경로에 추가 +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') +django.setup() + +from django.contrib.auth.models import User +from B_main.models import Person +from B_main.peopleinfo import PEOPLE + +def clear_existing_persons(): + """superuser를 제외한 모든 Person을 삭제""" + print("기존 Person 데이터 정리 중...") + + # superuser들 찾기 + superusers = User.objects.filter(is_superuser=True) + + if superusers.exists(): + print(f"Superuser 발견: {superusers.count()}명") + + # 모든 superuser의 Person들을 보존 + preserved_persons = [] + for superuser in superusers: + person = Person.objects.filter(user=superuser).first() + if person: + preserved_persons.append(person) + print(f"보존할 Person: {person.이름} (ID: {superuser.id}, 이메일: {superuser.email})") + else: + print(f"Superuser {superuser.username} (ID: {superuser.id}, 이메일: {superuser.email}) - Person 없음") + + # 모든 Person 삭제 (superuser들 제외) + deleted_count = Person.objects.exclude(user__in=superusers).delete()[0] + print(f"삭제된 Person 수: {deleted_count}") + + # 보존된 Person 중 첫 번째를 반환 (또는 None) + return preserved_persons[0] if preserved_persons else None + + else: + print("Superuser를 찾을 수 없습니다.") + # 모든 Person 삭제 + deleted_count = Person.objects.all().delete()[0] + print(f"삭제된 Person 수: {deleted_count}") + return None + +def parse_birth_date(birth_str): + """생년월일 문자열을 Date 객체로 변환""" + if not birth_str or birth_str == '': + return None + + try: + # "1960.03.27" 형식을 파싱 + if '.' in birth_str: + return datetime.strptime(birth_str, '%Y.%m.%d').date() + # "1962" 형식도 처리 + elif len(birth_str) == 4: + return datetime.strptime(f"{birth_str}.01.01", '%Y.%m.%d').date() + else: + return None + except ValueError: + print(f"생년월일 파싱 오류: {birth_str}") + return None + +def create_persons_from_peopleinfo(): + """peopleinfo.py의 데이터로 Person 객체 생성""" + print("peopleinfo.py 데이터로 Person 생성 중...") + + created_count = 0 + error_count = 0 + + for person_data in PEOPLE: + try: + # 기본 필드들 + name = person_data.get('이름', '') + affiliation = person_data.get('소속', '') + birth_date = parse_birth_date(person_data.get('생년월일', '')) + position = person_data.get('직책', '') + phone = person_data.get('연락처', '') + address = person_data.get('주소', '') + # 사진 경로에서 'media/' 접두사 제거 + photo = person_data.get('사진', 'profile_photos/default_user.png') + if photo.startswith('media/'): + photo = photo[6:] # 'media/' 제거 + title = person_data.get('TITLE', '') + sequence = person_data.get('SEQUENCE', None) + + # SEQUENCE를 정수로 변환 + if sequence and sequence != '': + try: + sequence = int(sequence) + except ValueError: + sequence = None + else: + sequence = None + + # 이미 존재하는지 확인 + existing_person = Person.objects.filter(이름=name, 연락처=phone).first() + + if existing_person: + print(f"이미 존재하는 Person: {name} ({phone})") + continue + + # 김봉수, 김태형만 보이게 설정, 나머지는 안보이게 설정 + show_in_main = name in ['김봉수', '김태형'] + + # 새 Person 생성 + person = Person.objects.create( + 이름=name, + 소속=affiliation, + 생년월일=birth_date, + 직책=position, + 연락처=phone, + 주소=address, + 사진=photo, + TITLE=title, + SEQUENCE=sequence, + 보일지여부=show_in_main + ) + + created_count += 1 + print(f"생성됨: {name} ({phone})") + + except Exception as e: + error_count += 1 + print(f"오류 발생 ({name}): {str(e)}") + continue + + print(f"\n생성 완료: {created_count}개") + print(f"오류 발생: {error_count}개") + + return created_count + +def main(): + """메인 실행 함수""" + print("=" * 50) + print("Person 데이터 초기화 및 재생성") + print("=" * 50) + + # 1. 기존 데이터 정리 + preserved_person = clear_existing_persons() + + # 2. peopleinfo.py 데이터로 새로 생성 + created_count = create_persons_from_peopleinfo() + + # 3. 결과 요약 + total_persons = Person.objects.count() + print("\n" + "=" * 50) + print("작업 완료!") + print(f"총 Person 수: {total_persons}") + if preserved_person: + print(f"보존된 Person: {preserved_person.이름} (Superuser)") + print("=" * 50) + +if __name__ == "__main__": + # 직접 실행 + main() \ No newline at end of file diff --git a/B_main/migrations/0001_initial.py b/B_main/migrations/0001_initial.py new file mode 100644 index 0000000..00d73dd --- /dev/null +++ b/B_main/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.16 on 2025-07-31 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('이름', models.CharField(max_length=50)), + ('소속', models.CharField(max_length=100)), + ('생년월일', models.DateField(blank=True, null=True)), + ('직책', models.CharField(max_length=50)), + ('연락처', models.CharField(max_length=20)), + ('주소', models.CharField(max_length=255)), + ('사진', models.CharField(max_length=255)), + ('TITLE', models.CharField(blank=True, max_length=50, null=True)), + ('SEQUENCE', models.IntegerField(blank=True, null=True)), + ], + ), + ] diff --git a/B_main/migrations/0002_person_user.py b/B_main/migrations/0002_person_user.py new file mode 100644 index 0000000..68ba0d9 --- /dev/null +++ b/B_main/migrations/0002_person_user.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2025-07-31 10:26 + +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', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/B_main/migrations/0003_person_보일지여부_alter_person_사진.py b/B_main/migrations/0003_person_보일지여부_alter_person_사진.py new file mode 100644 index 0000000..638dddc --- /dev/null +++ b/B_main/migrations/0003_person_보일지여부_alter_person_사진.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2025-08-01 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0002_person_user'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='보일지여부', + field=models.BooleanField(default=True, verbose_name='메인페이지 표시'), + ), + migrations.AlterField( + model_name='person', + name='사진', + field=models.ImageField(blank=True, default='static/B_main/images/default_user.png', upload_to='profile_photos/'), + ), + ] diff --git a/B_main/migrations/0004_person_회원가입상태_alter_person_사진.py b/B_main/migrations/0004_person_회원가입상태_alter_person_사진.py new file mode 100644 index 0000000..21b1ce9 --- /dev/null +++ b/B_main/migrations/0004_person_회원가입상태_alter_person_사진.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2025-08-01 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0003_person_보일지여부_alter_person_사진'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='회원가입상태', + field=models.CharField(choices=[('미가입', '미가입'), ('회원가입', '회원가입'), ('탈퇴', '탈퇴')], default='미가입', max_length=10, verbose_name='회원가입 상태'), + ), + migrations.AlterField( + model_name='person', + name='사진', + field=models.ImageField(blank=True, default='profile_photos/default_user.png', upload_to='profile_photos/'), + ), + ] diff --git a/B_main/migrations/0005_person_keyword1_person_keyword2_person_keyword3.py b/B_main/migrations/0005_person_keyword1_person_keyword2_person_keyword3.py new file mode 100644 index 0000000..1e849e8 --- /dev/null +++ b/B_main/migrations/0005_person_keyword1_person_keyword2_person_keyword3.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2025-08-01 12:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0004_person_회원가입상태_alter_person_사진'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='keyword1', + field=models.CharField(blank=True, help_text='첫 번째 키워드를 입력하세요 (예: 회계감사)', max_length=50, null=True, verbose_name='키워드1'), + ), + migrations.AddField( + model_name='person', + name='keyword2', + field=models.CharField(blank=True, help_text='두 번째 키워드를 입력하세요 (예: 잡자재)', max_length=50, null=True, verbose_name='키워드2'), + ), + migrations.AddField( + model_name='person', + name='keyword3', + field=models.CharField(blank=True, help_text='세 번째 키워드를 입력하세요 (예: 기획)', max_length=50, null=True, verbose_name='키워드3'), + ), + ] diff --git a/B_main/migrations/0006_person_모든사람보기권한.py b/B_main/migrations/0006_person_모든사람보기권한.py new file mode 100644 index 0000000..4791fc6 --- /dev/null +++ b/B_main/migrations/0006_person_모든사람보기권한.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-08-02 13:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0005_person_keyword1_person_keyword2_person_keyword3'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='모든사람보기권한', + field=models.BooleanField(default=False, help_text='True인 경우 모든 사람을 볼 수 있고, False인 경우 회원가입한 사람만 볼 수 있습니다.', verbose_name='모든 사람 보기 권한'), + ), + ] diff --git a/B_main/migrations/0007_remove_person_보일지여부.py b/B_main/migrations/0007_remove_person_보일지여부.py new file mode 100644 index 0000000..ac15b2f --- /dev/null +++ b/B_main/migrations/0007_remove_person_보일지여부.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2025-08-02 15:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0006_person_모든사람보기권한'), + ] + + operations = [ + migrations.RemoveField( + model_name='person', + name='보일지여부', + ), + ] diff --git a/B_main/migrations/0008_remove_person_회원가입상태.py b/B_main/migrations/0008_remove_person_회원가입상태.py new file mode 100644 index 0000000..45cdf2b --- /dev/null +++ b/B_main/migrations/0008_remove_person_회원가입상태.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2025-08-02 15:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0007_remove_person_보일지여부'), + ] + + operations = [ + migrations.RemoveField( + model_name='person', + name='회원가입상태', + ), + ] diff --git a/B_main/migrations/0009_remove_person_keyword2_remove_person_keyword3_and_more.py b/B_main/migrations/0009_remove_person_keyword2_remove_person_keyword3_and_more.py new file mode 100644 index 0000000..dddba94 --- /dev/null +++ b/B_main/migrations/0009_remove_person_keyword2_remove_person_keyword3_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2025-08-02 15:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0008_remove_person_회원가입상태'), + ] + + operations = [ + migrations.RemoveField( + model_name='person', + name='keyword2', + ), + migrations.RemoveField( + model_name='person', + name='keyword3', + ), + migrations.AlterField( + model_name='person', + name='keyword1', + field=models.CharField(blank=True, help_text='다른 사람들이 당신을 찾을 수 있도록 키워드를 입력하세요 (예: 회계감사)', max_length=50, null=True, verbose_name='검색 키워드'), + ), + ] diff --git a/B_main/migrations/0010_alter_person_options_person_비밀번호설정필요.py b/B_main/migrations/0010_alter_person_options_person_비밀번호설정필요.py new file mode 100644 index 0000000..d5fb4dc --- /dev/null +++ b/B_main/migrations/0010_alter_person_options_person_비밀번호설정필요.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2025-08-02 16:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('B_main', '0009_remove_person_keyword2_remove_person_keyword3_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='person', + options={'verbose_name': '사람', 'verbose_name_plural': '사람들'}, + ), + migrations.AddField( + model_name='person', + name='비밀번호설정필요', + field=models.BooleanField(default=False, help_text='True인 경우 사용자가 메인페이지 접근 시 비밀번호 설정 페이지로 리다이렉트됩니다.', verbose_name='비밀번호 설정 필요'), + ), + ] diff --git a/B_main/migrations/__init__.py b/B_main/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/B_main/migrations/__pycache__/0001_initial.cpython-38.pyc b/B_main/migrations/__pycache__/0001_initial.cpython-38.pyc new file mode 100644 index 0000000..32449d1 Binary files /dev/null and b/B_main/migrations/__pycache__/0001_initial.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0002_person_user.cpython-38.pyc b/B_main/migrations/__pycache__/0002_person_user.cpython-38.pyc new file mode 100644 index 0000000..81fa080 Binary files /dev/null and b/B_main/migrations/__pycache__/0002_person_user.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-38.pyc b/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-38.pyc new file mode 100644 index 0000000..de9846a Binary files /dev/null and b/B_main/migrations/__pycache__/0003_person_보일지여부_alter_person_사진.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-38.pyc b/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-38.pyc new file mode 100644 index 0000000..a95c654 Binary files /dev/null and b/B_main/migrations/__pycache__/0004_person_회원가입상태_alter_person_사진.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-38.pyc b/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-38.pyc new file mode 100644 index 0000000..720fcba Binary files /dev/null and b/B_main/migrations/__pycache__/0005_person_keyword1_person_keyword2_person_keyword3.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-38.pyc b/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-38.pyc new file mode 100644 index 0000000..bbe68d4 Binary files /dev/null and b/B_main/migrations/__pycache__/0006_person_모든사람보기권한.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-38.pyc b/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-38.pyc new file mode 100644 index 0000000..45eccd8 Binary files /dev/null and b/B_main/migrations/__pycache__/0007_remove_person_보일지여부.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-38.pyc b/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-38.pyc new file mode 100644 index 0000000..a6bf4da Binary files /dev/null and b/B_main/migrations/__pycache__/0008_remove_person_회원가입상태.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-38.pyc b/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-38.pyc new file mode 100644 index 0000000..0b64fce Binary files /dev/null and b/B_main/migrations/__pycache__/0009_remove_person_keyword2_remove_person_keyword3_and_more.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-38.pyc b/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-38.pyc new file mode 100644 index 0000000..c707597 Binary files /dev/null and b/B_main/migrations/__pycache__/0010_alter_person_options_person_비밀번호설정필요.cpython-38.pyc differ diff --git a/B_main/migrations/__pycache__/__init__.cpython-38.pyc b/B_main/migrations/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..e53da87 Binary files /dev/null and b/B_main/migrations/__pycache__/__init__.cpython-38.pyc differ diff --git a/B_main/models.py b/B_main/models.py new file mode 100644 index 0000000..746397c --- /dev/null +++ b/B_main/models.py @@ -0,0 +1,32 @@ +import os +from django.utils.deconstruct import deconstructible +from django.db import models +from django.contrib.auth.models import User + +@deconstructible +class StaticImagePath(object): + def __call__(self, instance, filename): + # B_main 앱 폴더 하위의 static/B_main/images/에 저장 + return f'B_main/static/B_main/images/{filename}' + +class Person(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True) + 이름 = models.CharField(max_length=50) + 소속 = models.CharField(max_length=100) + 생년월일 = models.DateField(null=True, blank=True) + 직책 = models.CharField(max_length=50) + 연락처 = models.CharField(max_length=20) + 주소 = models.CharField(max_length=255) + 사진 = models.ImageField(upload_to='profile_photos/', default='profile_photos/default_user.png', blank=True) + TITLE = models.CharField(max_length=50, blank=True, null=True) + SEQUENCE = models.IntegerField(blank=True, null=True) + 모든사람보기권한 = models.BooleanField(default=False, verbose_name='모든 사람 보기 권한', help_text='True인 경우 모든 사람을 볼 수 있고, False인 경우 회원가입한 사람만 볼 수 있습니다.') + keyword1 = models.CharField(max_length=50, blank=True, null=True, verbose_name='검색 키워드', help_text='다른 사람들이 당신을 찾을 수 있도록 키워드를 입력하세요 (예: 회계감사)') + 비밀번호설정필요 = models.BooleanField(default=False, verbose_name='비밀번호 설정 필요', help_text='True인 경우 사용자가 메인페이지 접근 시 비밀번호 설정 페이지로 리다이렉트됩니다.') + + class Meta: + verbose_name = '사람' + verbose_name_plural = '사람들' + + def __str__(self): + return self.이름 diff --git a/B_main/peopleinfo.py b/B_main/peopleinfo.py new file mode 100644 index 0000000..ee98e95 --- /dev/null +++ b/B_main/peopleinfo.py @@ -0,0 +1,162 @@ +PEOPLE = [ +{'이름' : '강경옥','소속' : '소니아 리키엘','생년월일' : '1960.03.27','직책' : '점장','연락처' : '010-3858-5270','주소' : '부산시 기장군 기장읍 기장해안로 147 (롯데동부산 1층)','사진' : 'profile_photos/강경옥.png','TITLE':'경조국','SEQUENCE':'',}, +{'이름' : '강규호','소속' : '건우건축사사무소','생년월일' : '1972.08.05','직책' : '대표','연락처' : '010-3156-9448','주소' : '부산시 진구 동평로 350(양정현대프라자, 2층, 213호)','사진' : 'profile_photos/강규호.png',}, +{'이름' : '강승구','소속' : '㈜ 대원석재','생년월일' : '1971.08.09','직책' : '대표','연락처' : '010-3846-0812','주소' : '부산광역시 연제구 월드컵대로 32번길 9 ','사진' : 'profile_photos/강승구.png',}, +{'이름' : '강지훈','소속' : '제이에이치툴링','생년월일' : '1989.08.22','직책' : '대표','연락처' : '010-7752-2731','주소' : '부산광역시 사상구 감전천로 252','사진' : 'profile_photos/강지훈.png',}, +{'이름' : '고현숙','소속' : '의료법인 좋은사람들 ','생년월일' : '1961.08.22','직책' : '이사','연락처' : '010-3591-9400','주소' : '부산 연제구 과정로 128','사진' : 'profile_photos/고현숙.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '공한수','소속' : '부산시 서구','생년월일' : '1959.09.23','직책' : '구청장','연락처' : '010-2020-2982','주소' : '부산광역시 서구 구덕로 120','사진' : 'profile_photos/공한수.png',}, +{'이름' : '곽기융','소속' : '㈜동천산업','생년월일' : '1970.09.26','직책' : '대표','연락처' : '010-3882-8394','주소' : '부산광역시 동래구 온천천로471번가길 18-3 ㈜동천산업 2층','사진' : 'profile_photos/곽기융.png',}, +{'이름' : '권중천','소속' : '희창물산㈜','생년월일' : '1945.04.26','직책' : '회장','연락처' : '010-5109-2755','주소' : '부산광역시 서구 충무대로146, 희창물산㈜','사진' : 'profile_photos/권중천.png','TITLE':'고문회장','SEQUENCE':'6',}, +{'이름' : '김가현','소속' : '스카이블루에셋㈜','생년월일' : '1973.06.16','직책' : '팀장','연락처' : '010-4544-7379','주소' : '부산시 동구 조방로 14, 동일타워 10층 위너스 지점','사진' : 'profile_photos/김가현.png',}, +{'이름' : '김기재','소속' : '부산시 영도구','생년월일' : '1957.05.29','직책' : '구청장','연락처' : '010-3867-3368','주소' : '','사진' : 'profile_photos/김기재.png',}, +{'이름' : '김기호','소속' : '경동개발','생년월일' : '1966.01.05','직책' : '대표','연락처' : '010-3131-9092','주소' : '경남 양산시 동면 금오 12길 83','사진' : 'profile_photos/김기호.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김대성','소속' : '㈜지에스어패럴 ','생년월일' : '1974.11.05','직책' : '대표','연락처' : '010-4877-4277','주소' : '부산 남구 동제당로 12 3층,4층','사진' : 'profile_photos/김대성.png','TITLE':'기획사무국','SEQUENCE':'',}, +{'이름' : '김동화','소속' : 'KDB생명','생년월일' : '1966.04.18','직책' : '지점장','연락처' : '010-6677-8079','주소' : '부산시 진구 중앙대로 640 ABL건물 생명빌딩 10층','사진' : 'profile_photos/김동화.png','TITLE':'홍보국','SEQUENCE':'',}, +{'이름' : '김미경','소속' : '㈜동남석면환경연구소','생년월일' : '1976.06.23','직책' : '대표','연락처' : '010-4579-4781','주소' : '부산광역시 연제구 고분로 55번길24','사진' : 'profile_photos/김미경.png',}, +{'이름' : '김미애','소속' : '예재원','생년월일' : '1976.02.12','직책' : '대표','연락처' : '010-3568-8055','주소' : '부산광역시 동래구 여고북로 215-1 1층','사진' : 'profile_photos/김미애.png',}, +{'이름' : '김민주','소속' : '웰킨 두피/탈모센터','생년월일' : '1969.11.20','직책' : '원장','연락처' : '010-4221-0515','주소' : '부산시 금정구 중앙대로 1629번길26 금샘빌딩 4층','사진' : 'profile_photos/김민주.png',}, +{'이름' : '김보성','소속' : '가온기업','생년월일' : '1987.07.18','직책' : '대표','연락처' : '010-9328-0588','주소' : '부산광역시 사상구 낙동대로1452번길 35','사진' : 'profile_photos/김보성.png','TITLE':'기획사무국','SEQUENCE':'',}, +{'이름' : '김봉수','소속' : '선민회계법인','생년월일' : '1979.01.03','직책' : '이사','연락처' : '010-3343-3319','주소' : '부산시 동구 조방로14 동일타워 413호 선민회계법인','사진' : 'profile_photos/김봉수.png','TITLE':'감사','SEQUENCE':'',}, +{'이름' : '김상준','소속' : '부산지방검찰청 서부지청','생년월일' : '1979.05.11','직책' : '형사2부장검사','연락처' : '010-7373-8126','주소' : '부산 강서구 명지국제7로 67(명지동) 부산지방검찰청 서부지청','사진' : 'profile_photos/김상준.png',}, +{'이름' : '김선이','소속' : '롯데백화점 세인트앤드류스','생년월일' : '1961.04.22','직책' : '대표','연락처' : '010-5391-6021','주소' : '부산진구 가야대로 772 4층','사진' : 'profile_photos/김선이.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김성주','소속' : '㈜ 현진','생년월일' : '1955.10.05','직책' : '대표','연락처' : '010-3863-7207','주소' : '부산 강서구 생곡산단2로11번길 33','사진' : 'profile_photos/김성주.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '김성훈','소속' : 'THE SYSTEM','생년월일' : '1972.05.16','직책' : '대표','연락처' : '010-4840-1197','주소' : '부산광역시 강서구 미음국제5로마길 5, 3층','사진' : 'profile_photos/김성훈.png',}, +{'이름' : '김영하','소속' : '싱싱F.S','생년월일' : '1987.06.14','직책' : '대표','연락처' : '010-2006-5106','주소' : '부산시 사상구 새벽로 131 산업용재 유통상가','사진' : 'profile_photos/김영하.png',}, +{'이름' : '김영훈','소속' : '해운대비치골프앤리조트','생년월일' : '1975.03.09','직책' : '전무','연락처' : '010-8081-3345','주소' : '부산광역시 기장군 대변로 74','사진' : 'profile_photos/김영훈.png',}, +{'이름' : '김외숙','소속' : '㈜주승','생년월일' : '1968.12.20','직책' : '대표','연락처' : '010-2110-1173','주소' : '부산시 사상구 삼락천로 138','사진' : 'profile_photos/김외숙.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김용권','소속' : '㈜태성산업기계','생년월일' : '1971.08.20','직책' : '대표','연락처' : '010-2592-4402','주소' : '','사진' : 'profile_photos/김용권.png',}, +{'이름' : '김윤규','소속' : '㈜ 동남엔지니어링','생년월일' : '1968.11.25','직책' : '대표','연락처' : '010-5448-0650','주소' : '부산광역시 강서구 미음산단로8번길 80','사진' : 'profile_photos/김윤규.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김윤아','소속' : '㈜한국분석센터','생년월일' : '1964.04.18','직책' : '대표','연락처' : '010-3854-7940','주소' : '부산광역시 사상구 학감대로 133번길 13 ㈜한국분석센터','사진' : 'profile_photos/김윤아.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김은희','소속' : '씨앤웍스 ','생년월일' : '1976.06.08','직책' : '대표','연락처' : '010-2568-6258','주소' : '부산시 해운대구 센텀서로 30 KNN타워 1506호','사진' : 'profile_photos/김은희.png',}, +{'이름' : '김일곤','소속' : '다사랑 문고','생년월일' : '1966.02.01','직책' : '대표','연락처' : '010-2549-4459','주소' : '부산광역시 금정구 부산대학로 49 오션프라자 1층','사진' : 'profile_photos/김일곤.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김재준','소속' : '㈜ 성강개발 JOB','생년월일' : '1977.01.14','직책' : '대표','연락처' : '010-8873-8282','주소' : '부산시 진구 부전동 262-15 3층','사진' : 'profile_photos/김재준.png',}, +{'이름' : '김정호','소속' : '㈜아신비에스','생년월일' : '1975.10.02','직책' : '대표','연락처' : '010-8466-5106','주소' : '부산 서구 원양로 35 국제수산물도매시장 도매장동 3층 104호','사진' : 'profile_photos/김정호.png',}, +{'이름' : '김준수','소속' : '법무법인 로인','생년월일' : '1989.12.25','직책' : '대표변호사','연락처' : '010-6898-0505','주소' : '부산 연제구 법원로 28 801호','사진' : 'profile_photos/김준수.png',}, +{'이름' : '김중선','소속' : '연일한우참숯구이','생년월일' : '1961.11.19','직책' : '사장','연락처' : '010-2783-6974','주소' : '부산시 연제구 고분로32번길 42 1층','사진' : 'profile_photos/김중선.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '김진홍','소속' : '부산시 동구','생년월일' : '1957.11.17','직책' : '구청장','연락처' : '010-5609-5609','주소' : '','사진' : 'profile_photos/김진홍.png',}, +{'이름' : '김태영','소속' : '가가산업개발㈜','생년월일' : '1972.11.25','직책' : '대표','연락처' : '010-6267-4598','주소' : '부산시 북구 금곡대로 616번길 25 3층','사진' : 'profile_photos/김태영.png',}, +{'이름' : '김태형','소속' : '해우법무사사무소','생년월일' : '1962.02.27','직책' : '대표','연락처' : '010-6338-9339','주소' : '부산 연제구 법원로 34 909호(거제동, 정림빌딩)','사진' : 'profile_photos/김태형.png','TITLE':'총무국장','SEQUENCE':'',}, +{'이름' : '김한집','소속' : '사상기업발전협의회','생년월일' : '1959.10.22','직책' : '회장','연락처' : '010-4646-0560','주소' : '부산시 사상구 사상로 440번길 28(모라동)','사진' : 'profile_photos/김한집.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '김현우','소속' : '부산교통공사','생년월일' : '1969.12.02','직책' : '기획예산실장','연락처' : '010-8007-9813','주소' : '부산광역시 부산진구 중앙대로 644번길 20','사진' : 'profile_photos/김현우.png',}, +{'이름' : '김현준','소속' : 'BNK 부산은행','생년월일' : '1970.07.13','직책' : '상무','연락처' : '010-3590-9457','주소' : '부산광역시 남구 문현금융로 30, 부산은행 본점 18층','사진' : 'profile_photos/김현준.png',}, +{'이름' : '김희경','소속' : '오케이물류 ','생년월일' : '1970.04.15','직책' : '부장','연락처' : '010-5858-3136','주소' : '부산 중구 대청로 155번길 6 오케이물류㈜','사진' : 'profile_photos/김희경(수정).png',}, +{'이름' : '노현주','소속' : '이앤씨상봉㈜','생년월일' : '1960.11.27','직책' : '이사','연락처' : '010-3857-2756','주소' : '부산시 동래구 충렬대로 107번길','사진' : 'profile_photos/노현주.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '노희숙','소속' : '더베스트금융㈜부경','생년월일' : '1966.12.01','직책' : '대표','연락처' : '010-8398-5508','주소' : '부산시 동래구 온천천로 179-1 휘담채 3층','사진' : 'profile_photos/노희숙.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '마점래','소속' : '㈜ 엠오티','생년월일' : '1962.03.17','직책' : '회장','연락처' : '010-3591-1575','주소' : '경상남도 양산시 상북면 석계산단2길 46','사진' : 'profile_photos/마점래(수정).png','TITLE':'선임상임부회장','SEQUENCE':'7',}, +{'이름' : '문성배','소속' : '(주)SMC ','생년월일' : '1965.05.06','직책' : '대표','연락처' : '010-9304-0388','주소' : '부산광역시 동구 수정중로 11번길 29, 2층','사진' : 'profile_photos/문성배.png','TITLE':'감사','SEQUENCE':'',}, +{'이름' : '문정순','소속' : '지클랩','생년월일' : '1968.05.29','직책' : '대표','연락처' : '010-9800-4848','주소' : '경기도 화성시 동탄대로 636-1 911호','사진' : 'profile_photos/문정순.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '민수연','소속' : 'BS부산오페라단','생년월일' : '1974.04.05','직책' : '단장','연락처' : '010-8448-8358','주소' : '부산광역시 금강로 380번길 21 2층','사진' : 'profile_photos/민수연.png','TITLE':'친교문화국','SEQUENCE':'',}, +{'이름' : '민홍기','소속' : '민플란트치과의원','생년월일' : '1988.06.08','직책' : '원장','연락처' : '010-8509-4470','주소' : '부산 해운대구 센텀남대로 50 A1102호, A1103호','사진' : 'profile_photos/민홍기.png',}, +{'이름' : '박강범','소속' : '부영회계법인','생년월일' : '1981.10.23','직책' : '대표','연락처' : '010-3949-8866','주소' : '부산광역시 해운대구 센텀중앙로97, 센텀스카이비즈 3707호','사진' : 'profile_photos/박강범.png',}, +{'이름' : '박경민','소속' : '로한종합건설㈜','생년월일' : '1976.07.31','직책' : '대표','연락처' : '010-9961-9699','주소' : '부산광역시 기장군 장안읍 고무로 129','사진' : 'profile_photos/박경민.png',}, +{'이름' : '박국제','소속' : '㈜국제경영기술원','생년월일' : '1951.06.15','직책' : '원장','연락처' : '010-3842-5063','주소' : '부산광역시 금정구 두실로24번길 12','사진' : 'profile_photos/박국제.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '박대진','소속' : '한몽경영인협의회','생년월일' : '1973.05.10','직책' : '회장','연락처' : '010-2610-0531','주소' : '강남구 테헤란로 82길 15 574호','사진' : 'profile_photos/박대진.png','TITLE':'홍보국','SEQUENCE':'',}, +{'이름' : '박명숙','소속' : '거산통상','생년월일' : '1968.04.08','직책' : '대표','연락처' : '010-6289-1777','주소' : '부산시 사상구 괘감로 37, 11동 107호 거산통상','사진' : 'profile_photos/박명숙.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '박명옥','소속' : '㈜참한디자인','생년월일' : '1966.03.25','직책' : '이사','연락처' : '010-8551-5871','주소' : '부산시 연제구 거제시장로 15 참한빌딩 3층','사진' : 'profile_photos/박명옥.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '박명진','소속' : '해운대비치골프앤리조트','생년월일' : '1962','직책' : '회장','연락처' : '010-6267-8188','주소' : '부산시 기장군 기장읍 대변로 74 해운대비치골프앤리조트','사진' : 'profile_photos/박명진.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '박민희','소속' : '㈜청운','생년월일' : '1989.05.18','직책' : '대표','연락처' : '010-3168-7872','주소' : '경남 양산시 동면 외송로30,701동1601호(사송더샵데시앙2차7단지)','사진' : 'profile_photos/박민희.png','TITLE':'친교문화국','SEQUENCE':'',}, +{'이름' : '박부술','소속' : '(주)삼림물산','생년월일' : '1968.05.13','직책' : '대표','연락처' : '010-9880-7422','주소' : '부산시 강서구 녹간산단 407로 8','사진' : 'profile_photos/박부술.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '박성호','소속' : '부산진해경제자유구역청','생년월일' : '1966.12.13','직책' : '청장','연락처' : '010-5641-5815','주소' : '부산광역시 강서구 녹산산단232로 38-26로','사진' : 'profile_photos/박성호.png',}, +{'이름' : '박성훈','소속' : '국민의힘 부산 북구(을)','생년월일' : '1971.01.18','직책' : '국회의원','연락처' : '010-6760-3435','주소' : '','사진' : 'profile_photos/박성훈.png',}, +{'이름' : '박순자','소속' : '','생년월일' : '1958.01.02','직책' : '','연락처' : '010-2383-1296','주소' : '부산시 동래구 명륜로 49 센트럴 B/D 6층','사진' : 'profile_photos/박순자.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '박영우','소속' : '퍼시픽링스코리아 영남지사','생년월일' : '1969.01.29','직책' : '지사장','연락처' : '010-3844-8255','주소' : '부산시 해운대구 센텀서로 30 209호','사진' : 'profile_photos/박영우.png','TITLE':'친교문화국','SEQUENCE':'',}, +{'이름' : '박영해','소속' : '건양사이버대학교','생년월일' : '1961.05.19','직책' : '교수','연락처' : '010-3542-3578','주소' : '대전시 서구 관저동로 158','사진' : 'profile_photos/박영해.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '박재성','소속' : '㈜이앤아이솔루션','생년월일' : '1989.03.02','직책' : '대표','연락처' : '010-9963-5420','주소' : '부산시 수영구 수영로 488번길 26 재안빌딩 4층','사진' : 'profile_photos/박재성.png',}, +{'이름' : '박정숙','소속' : '(주)노벨홀딩스','생년월일' : '1979.01.10','직책' : '대표','연락처' : '010-8076-2001','주소' : '부산시 부산진구 가야대로 473 4층','사진' : 'profile_photos/박정숙.png',}, +{'이름' : '박정은','소속' : '㈜승진','생년월일' : '1984.04.02','직책' : '대표','연락처' : '010-6484-4402','주소' : '부산광역시 강서구 미음동 1554-6','사진' : 'profile_photos/박정은.png',}, +{'이름' : '박종인','소속' : '법무법인 로베리 ','생년월일' : '1976.03.15','직책' : '부산사무소 대표(파트너변호사)','연락처' : '010-5564-1791','주소' : '부산 연제구 법원남로 15번길 26 위너스빌딩 3층','사진' : 'profile_photos/박종인.png',}, +{'이름' : '박주원','소속' : '㈜ 온나라 부동산 중개법인','생년월일' : '1965.10.30','직책' : '부사장','연락처' : '010-5624-5321','주소' : '부산시 연제구 중앙대로 144 우전빌딩 2층','사진' : 'profile_photos/박주원(수정).png','TITLE':'기획사무국','SEQUENCE':'',}, +{'이름' : '박지환','소속' : '㈜푸르다','생년월일' : '1960.06.05','직책' : '대표','연락처' : '010-3844-7818','주소' : '부산시 기장군 일광면 체육공원1로 3','사진' : 'profile_photos/박지환.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '배범한','소속' : '㈜대산컨설팅','생년월일' : '1980.10.02','직책' : '대표','연락처' : '010-2223-3940','주소' : '부산광역시 해운대구 반여로 186-7, 1층','사진' : 'profile_photos/배범한.png',}, +{'이름' : '배성효','소속' : '법무법인 무한','생년월일' : '1964.11.25','직책' : '대표변호사','연락처' : '010-6201-2117','주소' : '부산 연제구 법원북로 86, 9층(거제동, 만해빌딩)','사진' : 'profile_photos/배성효.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '변연옥','소속' : '홍콩반점 양산역점 ','생년월일' : '1970.10.03','직책' : '대표','연락처' : '010-7765-7890','주소' : '양산역 3길 16 101호','사진' : 'profile_photos/변연옥.png',}, +{'이름' : '빈윤진','소속' : '진무역','생년월일' : '1964.10.05','직책' : '대표','연락처' : '010-3567-2854','주소' : '부산시 남구 문현동 고동골로 10-1 동양빌딩 3층','사진' : 'profile_photos/빈윤진.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '서강섭','소속' : '㈜호방종합건설','생년월일' : '1970.09.28','직책' : '대표','연락처' : '010-3856-7303','주소' : '부산시 사상구 덕상로 22, 403호(덕포동, 명성빌딩)','사진' : 'profile_photos/서강섭.png','TITLE':'경조국','SEQUENCE':'',}, +{'이름' : '서지윤','소속' : '㈜리만 부산 센텀지사','생년월일' : '1972.09.20','직책' : '지사장','연락처' : '010-2823-4375','주소' : '부산시 해운대구 센텀동로9 트럼프월드센텀 209호','사진' : 'profile_photos/서지윤.png',}, +{'이름' : '성동화','소속' : '부산신용보증재단','생년월일' : '1961.10.17','직책' : '이사장','연락처' : '010-8787-5902','주소' : '부산광역시 부산진구 진연로 15(양정동)','사진' : 'profile_photos/성동화.png',}, +{'이름' : '성충식','소속' : '㈜에이스여행사','생년월일' : '1962.01.03','직책' : '대표','연락처' : '010-5007-9178','주소' : '부산시 중구 해관로73 일광빌딩 3층','사진' : 'profile_photos/성충식.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '손동현','소속' : '아트스윙','생년월일' : '1985.12.09','직책' : '대표','연락처' : '010-3883-7113','주소' : '부산 북구 만덕3로 16번길 1 부산이노비즈센터 206호 아트스윙','사진' : 'profile_photos/손동현.png','TITLE':'홍보국','SEQUENCE':'',}, +{'이름' : '송연익','소속' : '(주)에스엠산업','생년월일' : '1968.06.26','직책' : '대표','연락처' : '010-3849-2100','주소' : '서울시 강남구 대치4동 910-6번지 202동 202호','사진' : 'profile_photos/송연익.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '심수현','소속' : '카페레빗','생년월일' : '1969.08.19','직책' : '대표','연락처' : '010-8234-8776','주소' : '해운대구 달맞이 길 62번 길 5-57 카페레빗','사진' : 'profile_photos/심수현.png',}, +{'이름' : '안복지','소속' : '플로라네트(꽃사세요)','생년월일' : '1972.09.01','직책' : '대표','연락처' : '010-5499-3339','주소' : '부산시 남구 수영로 26 (대림문현시티프라자) 107호','사진' : 'profile_photos/안복지.png','TITLE':'경조국','SEQUENCE':'',}, +{'이름' : '안상배','소속' : '법무법인 예주','생년월일' : '1984.03.28','직책' : '대표변호사','연락처' : '010-6683-3981','주소' : '부산광역시 연제구 법원남로15번길 10, 6층(거제동, 미르코아빌딩)','사진' : 'profile_photos/안상배.png','TITLE':'기획사무국','SEQUENCE':'',}, +{'이름' : '안영봉','소속' : '남부경찰서','생년월일' : '1970.05.14','직책' : '서장','연락처' : '010-3563-8339','주소' : '부산광역시 연제구 중앙대로 999','사진' : 'profile_photos/안영봉.png',}, +{'이름' : '양재진','소속' : '㈜ 한림기업','생년월일' : '1970.06.21','직책' : '대표 ','연락처' : '010-7181-3241','주소' : '부산광역시 기장군 장안읍 반룡산단1로55','사진' : 'profile_photos/양재진.png',}, +{'이름' : '어익수','소속' : '㈜ 다오테크','생년월일' : '1964.07.05','직책' : '대표','연락처' : '010-4552-2495','주소' : '부산광역시 영도구 남향서로 119(남향동1가 9번지)','사진' : 'profile_photos/어익수.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '엄신아','소속' : '신라횟집','생년월일' : '1971.10.12','직책' : '대표','연락처' : '010-6656-5837','주소' : '부산시 수영구 민학수변로 7번가 16','사진' : 'profile_photos/엄신아.png',}, +{'이름' : '여은주','소속' : '지구산업','생년월일' : '1965.04.27','직책' : '대표','연락처' : '010-6818-2045','주소' : '부산광역시 부산진구 신천대로 71번길 23(범천동)','사진' : 'profile_photos/여은주.png','TITLE':'재무국','SEQUENCE':'',}, +{'이름' : '예영숙','소속' : '삼성생명보험㈜','생년월일' : '1960.11.18','직책' : '명예전무','연락처' : '010-3532-3519','주소' : '대구광역시 중구 달구벌대로 2095,삼성생명빌딩 9층 예영숙명예전무실','사진' : 'profile_photos/예영숙.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '오용택','소속' : '한택','생년월일' : '1965.07.29','직책' : '대표','연락처' : '010-8512-3238','주소' : '경남 김해시 김해대로 2596번길125 (지내동)','사진' : 'profile_photos/오용택.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '오원재','소속' : '㈜에스씨원건설','생년월일' : '1970.11.26','직책' : '대표','연락처' : '010-2585-2424','주소' : '부산광역시 북구 금곡대로616번길 135','사진' : 'profile_photos/오원재.png','TITLE':'재무국','SEQUENCE':'',}, +{'이름' : '유석찬','소속' : '서호종합건설㈜','생년월일' : '1968.02.28','직책' : '대표','연락처' : '010-9320-7007','주소' : '부산시 강서구 명지국제2로28번길 26, 602호(명지동, 퍼스트삼융)','사진' : 'profile_photos/유석찬.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이기영','소속' : 'BNK부산은행 부전동금융센터','생년월일' : '1971.11.22','직책' : '금융센터장','연락처' : '010-3882-1516','주소' : '부산광역시 부산진구 새싹로 1(부전동)','사진' : 'profile_photos/이기영.png',}, +{'이름' : '이대명','소속' : '법무사 이대명 사무소','생년월일' : '1963.08.25','직책' : '법무사','연락처' : '010-2834-8248','주소' : '부산 연제구 법원남로 16번길 21','사진' : 'profile_photos/이대명.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이두홍','소속' : '㈜ 동남창호산업','생년월일' : '1974.05.18','직책' : '대표','연락처' : '010-2602-8263','주소' : '경남 양산시 상북면 수서로 70','사진' : 'profile_photos/이두홍.png','TITLE':'친교문화국','SEQUENCE':'',}, +{'이름' : '이범민','소속' : '㈜ 와이즈','생년월일' : '1985.02.09','직책' : '대표','연락처' : '010-9334-1364','주소' : '경남 양산시 물금읍 서들8길 45, 4층 와이즈','사진' : 'profile_photos/이범민.png',}, +{'이름' : '이상경','소속' : '법무법인 태종','생년월일' : '1964.12.22','직책' : '대표변호사','연락처' : '010-5351-1866','주소' : '부산 연제구 법원로 12,701호','사진' : 'profile_photos/이상경.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이상규','소속' : '㈜보문','생년월일' : '1956.09.29','직책' : '대표','연락처' : '010-3887-1288','주소' : '부산광역시 부산진구 동천로108번길 14','사진' : 'profile_photos/이상규.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '이상민','소속' : '(주)월드이노텍','생년월일' : '1987.05.03','직책' : '전무','연락처' : '010-3230-9354','주소' : '경상남도 양산시 덕계동 웅상농공단지길 42 월드이노텍','사진' : 'profile_photos/이상민.png',}, +{'이름' : '이수연','소속' : '이수연 힐링예술원','생년월일' : '1969.04.15','직책' : '원장','연락처' : '010-5539-9999','주소' : '부산시 연제구 세병로 6, 3층','사진' : 'profile_photos/이수연.png',}, +{'이름' : '이순옥','소속' : '삼성생명보험㈜','생년월일' : '1963.12.17','직책' : '명예상무','연락처' : '010-3871-8088','주소' : '부산시 동구 중앙대로 222 삼성생명빌딩 황도지점','사진' : 'profile_photos/이순옥.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이승규','소속' : '성심종합건설㈜','생년월일' : '1959.11.20','직책' : '대표','연락처' : '010-7248-3301','주소' : '부산광역시 사하구 감천로134, (감천동, 중모빌딩) 6층','사진' : 'profile_photos/이승규.png','TITLE':'회장','SEQUENCE':'4',}, +{'이름' : '이영희','소속' : 'IBK투자증권','생년월일' : '1961.07.22','직책' : '자문역','연락처' : '010-4329-9432','주소' : '부산광역시 해운대구 센텀남대로 50 (우동) 임페리얼타워 2층','사진' : 'profile_photos/이영희.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이인숙','소속' : '큐앤㈜','생년월일' : '1969.05.10','직책' : '대표','연락처' : '010-9092-0510','주소' : '부산광역시 남구 고동골로 78번길 12(문현동 창암빌딩 6층)','사진' : 'profile_photos/이인숙.png',}, +{'이름' : '이정문','소속' : '글로벌산업','생년월일' : '1967.03.15','직책' : '대표','연락처' : '010-3860-3650','주소' : '부산시 사상구 새벽시장로 19-19','사진' : 'profile_photos/이정문.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이주영','소속' : '인(IN)코칭연구소','생년월일' : '1976.05.01','직책' : '대표','연락처' : '010-4872-8837','주소' : '부산 부산진구 서전로37번길 25-9, 802호','사진' : 'profile_photos/이주영.png',}, +{'이름' : '이학민','소속' : '㈜신화이엔지','생년월일' : '1983.08.03','직책' : '대표','연락처' : '010-9306-9994','주소' : '부산광역시 강서구 미읍산단로 37번길 9(구량동) 1층 주식회사 신화이엔지','사진' : 'profile_photos/이학민.png',}, +{'이름' : '이향숙','소속' : '메이저','생년월일' : '1963.03.23','직책' : '대표','연락처' : '010-9312-3023','주소' : '부산시 해운대구 우동 203번지 오션타워 4층','사진' : 'profile_photos/이향숙.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이현수','소속' : '삼우종합개발','생년월일' : '1961.06.24','직책' : '대표','연락처' : '010-3874-1222','주소' : '부산광역시 사하구 다대로 531','사진' : 'profile_photos/이현수.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '이현우','소속' : '㈜은하수산 ','생년월일' : '1964.08.24','직책' : '대표','연락처' : '010-3593-7888','주소' : '부산광역시 강서구 녹산산단 381로 36`','사진' : 'profile_photos/이현우.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '이현정','소속' : '㈜ 스윗솔루션','생년월일' : '1981.11.25','직책' : '대표','연락처' : '010-5109-5789','주소' : '부산 강서구 범방3로78번길 25','사진' : 'profile_photos/이현정.png',}, +{'이름' : '이화경','소속' : '해동산업㈜','생년월일' : '1965.04.03','직책' : '이사','연락처' : '010-5436-7257','주소' : '부산시 사상구 감전동 낙동대로 1052','사진' : 'profile_photos/이화경.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '임문수','소속' : '㈜휴먼스컴퍼니','생년월일' : '1963.03.22','직책' : '대표','연락처' : '010-4130-4750','주소' : '서울 서대문구 충정리시온 217호','사진' : 'profile_photos/임문수.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '임영민','소속' : '티엔에스무역㈜','생년월일' : '1988.10.29','직책' : '전무','연락처' : '010-9699-8113','주소' : '부산광역시 중구 태종로 14-1, 반도빌딩 4층, 티엔에스무역㈜','사진' : 'profile_photos/임영민.png',}, +{'이름' : '임윤택','소속' : '센텀종합병원','생년월일' : '1960.01.06','직책' : '행정부장','연락처' : '010-6561-2222','주소' : '부산광역시 수영구 수영로 679번길 8 센텀종합병원 신관 14층','사진' : 'profile_photos/임윤택.png',}, +{'이름' : '임창섭','소속' : '㈜ 동신','생년월일' : '1966.08.15','직책' : '회장','연락처' : '010-4128-3343','주소' : '부산광역시 해운대구 우동1로 20번길 27-10 ㈜동신','사진' : 'profile_photos/임창섭(수정).png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '장은화','소속' : '정덕기업경영연구원','생년월일' : '1973.04.17','직책' : '대표','연락처' : '010-8892-9635','주소' : '부산시 동구 중앙대로 409 디알라이프시티 305호','사진' : 'profile_photos/장은화.png',}, +{'이름' : '장지훈','소속' : '㈜다우플랫폼','생년월일' : '1983.04.07','직책' : '대표','연락처' : '010-9459-0579','주소' : '부산시 연제구 과정로 276번가길 32, 2층','사진' : 'profile_photos/장지훈.png',}, +{'이름' : '장현정','소속' : '㈜ 고앤파트너스','생년월일' : '1978.05.05','직책' : '이사','연락처' : '010-3329-1226','주소' : '창원시 의창구 용동로 83번안길 7, 401호(사림동, 미래드림빌딩)','사진' : 'profile_photos/장현정.png',}, +{'이름' : '전미영','소속' : 'IBK기업은행 부산지점','생년월일' : '1971.05.23','직책' : '부지점장','연락처' : '010-5567-7514','주소' : '부산광역시 중구 중앙대로88 (중앙동4가) 기업은행 부산지점','사진' : 'profile_photos/전미영.png',}, +{'이름' : '전병웅','소속' : '㈜이진주택','생년월일' : '1974.09.25','직책' : '대표','연락처' : '010-4553-6301','주소' : '부산광역시 수영구 연수로 405, 4층','사진' : 'profile_photos/전병웅(수정).png',}, +{'이름' : '전성훈','소속' : '대원플러스그룹','생년월일' : '1971.05.25','직책' : '이사','연락처' : '010-2857-9157','주소' : '부산시 해운대구 마린시티2로33 제니스스퀘어 A동 402호','사진' : 'profile_photos/전성훈.png',}, +{'이름' : '전종태','소속' : '㈜디엔씨텍','생년월일' : '1956.08.27','직책' : '대표','연락처' : '010-6583-3004','주소' : '경기도 화성시 장안면 석포로 94-21','사진' : 'profile_photos/전종태.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '전희충','소속' : '㈜대웅이티','생년월일' : '1962.08.01','직책' : '대 표','연락처' : '010-3001-3435','주소' : '부산광역시 강서구 화전산단3로 90 ㈜대웅이티','사진' : 'profile_photos/전희충.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '정석민','소속' : '㈜연우 비즈니스 컨설팅','생년월일' : '1974.10.16','직책' : '대표','연락처' : '010-4552-0609','주소' : '부산 해운대구 해운대로 790(좌동, 대림아크로텔) 1601호','사진' : 'profile_photos/정석민.png',}, +{'이름' : '정용표','소속' : '㈜케이에이엠','생년월일' : '1959.01.06','직책' : '대표','연락처' : '010-3868-4103','주소' : '부산시 강서구 녹산산단382로 50번길30','사진' : 'profile_photos/정용표.png','TITLE':'수석부회장','SEQUENCE':'5',}, +{'이름' : '정윤목','소속' : '㈜금양프린텍 ','생년월일' : '1955.03.03','직책' : '대표','연락처' : '010-3863-4546','주소' : '부산광역시 중구 흑교로 35번길 9(부평동3가)','사진' : 'profile_photos/정윤목.png','TITLE':'경조국','SEQUENCE':'',}, +{'이름' : '정의석','소속' : '국제식품','생년월일' : '1982.07.08','직책' : '본부장','연락처' : '010-6309-7896','주소' : '부산시 진구 거제대로 70 국제식품빌딩','사진' : 'profile_photos/정의석.png',}, +{'이름' : '정종복','소속' : '부산광역시 기장군청','생년월일' : '1954.11.20','직책' : '군수','연락처' : '010-3574-8512','주소' : '부산 기장군 기장읍 기장대로 560 기장군청','사진' : 'profile_photos/정종복.png',}, +{'이름' : '정형재','소속' : '㈜하우스메이커','생년월일' : '1974.10.23','직책' : '대표','연락처' : '010-6330-9911','주소' : '부산광역시 남구 동제당로 2(문현동 1층)','사진' : 'profile_photos/정형재.png',}, +{'이름' : '제권진','소속' : '좋은엘리베이터㈜','생년월일' : '1972.04.12','직책' : '대표','연락처' : '010-8002-8255','주소' : '부산시 강서구 신로산단1로 101, 상가동 304호(신호동, 부산신호사랑으로부영5차)','사진' : 'profile_photos/제권진.png',}, +{'이름' : '제오수','소속' : '㈜에스비안전','생년월일' : '1957.06.12','직책' : '대표','연락처' : '010-3850-5728','주소' : '부산광역시 강서구 녹산산다382로14번길 55(녹산협업화사업장)','사진' : 'profile_photos/제오수(수정).png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '제일호','소속' : '경동건설㈜','생년월일' : '1975.10.25','직책' : '부장','연락처' : '010-4446-7515','주소' : '부산광역시 연제구 황령산로 599(연산동 1985-12번지)','사진' : 'profile_photos/제일호.png',}, +{'이름' : '조승민','소속' : '중소벤처기업진흥공단 부산지역본부','생년월일' : '1970.04.28','직책' : '본부장','연락처' : '010-3033-9055','주소' : '부산시 부산진구 중앙대로 639번지 엠디엠타워 23층','사진' : 'profile_photos/조승민.png',}, +{'이름' : '조엘리사','소속' : '㈜꼬레아티에스','생년월일' : '1973.01.01','직책' : '대표','연락처' : '010-6421-3240','주소' : '부산 사상구 광장로 56번길 56(3층, 괘법동)','사진' : 'profile_photos/조엘리사.png','TITLE':'홍보국','SEQUENCE':'',}, +{'이름' : '주진우','소속' : '국민의힘 부산 해운대구(갑)','생년월일' : '1975.05.25','직책' : '국회의원','연락처' : '010-9004-9330','주소' : '','사진' : 'profile_photos/주진우.png',}, +{'이름' : '주효정','소속' : '우리돼지국밥','생년월일' : '1971.07.29','직책' : '대표','연락처' : '010-4480-7724','주소' : '부산 동구 초량로 27-1','사진' : 'profile_photos/주효정.png',}, +{'이름' : '진서윤','소속' : '㈜파나','생년월일' : '1966.04.20','직책' : '상무','연락처' : '010-3855-7758','주소' : '양산시 상북면 양산대로 1266-3 ㈜ 파나','사진' : 'profile_photos/진서윤.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '진종규','소속' : '㈜ 화승이엔씨','생년월일' : '1968.10.25','직책' : '대표','연락처' : '010-9331-4119','주소' : '부산광역시 부산진구 중앙대로 979번길 6 뉴라이웰 203호','사진' : 'profile_photos/진종규.png',}, +{'이름' : '최대경','소속' : '수원대학교','생년월일' : '1961.11.12','직책' : '특임교수','연락처' : '010-3582-5164','주소' : '경기도 화성시 봉담융 와우안길 17','사진' : 'profile_photos/최대경.png',}, +{'이름' : '최승자','소속' : '플로스 플라워','생년월일' : '1959.12.21','직책' : '대표','연락처' : '010-9315-4755','주소' : '부산시 중구 중앙대로 41번길 3 ','사진' : 'profile_photos/최승자.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '최유심','소속' : '㈜정수인더스트리','생년월일' : '1964.05.21','직책' : '대표','연락처' : '010-4554-6226','주소' : '부산시 부산진구 가야대로 749-1 1307호','사진' : 'profile_photos/최유심.png','TITLE':'재무국','SEQUENCE':'',}, +{'이름' : '최정현','소속' : '엔피씨글로벌','생년월일' : '1979.07.12','직책' : '대표','연락처' : '010-9663-5081','주소' : '부산 강서구 공항로 1409번길 42','사진' : 'profile_photos/최정현(수정).png',}, +{'이름' : '최준익','소속' : '늘바다품애','생년월일' : '1987.06.28','직책' : '대표','연락처' : '010-9798-4172','주소' : '창원시 마산합포구 어시장 4길 20','사진' : 'profile_photos/최준익.png',}, +{'이름' : '최진봉','소속' : '부산시 중구','생년월일' : '1955.01.18','직책' : '구청장','연락처' : '010-4628-4002','주소' : '부산광역시 중구 중구로 120, 부산중구청 2층(구청장 비서실)','사진' : 'profile_photos/최진봉.png',}, +{'이름' : '하익수','소속' : '남우건설주식회사','생년월일' : '1965.10.08','직책' : '대표','연락처' : '010-3585-0940','주소' : '부산금정구 금장로 225번길 장전벽산블루밍디자인 아파트 204동 1005호','사진' : 'profile_photos/하익수.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '한윤철','소속' : '㈜ 고려엔지니어링종합건설','생년월일' : '1972.07.04','직책' : '대표','연락처' : '010-4556-0985','주소' : '부산 남구 못골로 12번길 67 4층(대연동)','사진' : 'profile_photos/한윤철.png',}, +{'이름' : '허성우','소속' : '월강','생년월일' : '1979.11.13','직책' : '대표','연락처' : '010-3420-1049','주소' : '부산광역시 부산진구 서면로7 월강(1~4층)','사진' : 'profile_photos/허성우.png',}, +{'이름' : '현광열','소속' : 'LG에어컨가전SW(주)','생년월일' : '1969.07.07','직책' : '대표','연락처' : '010-9926-5569','주소' : '부산시 금정구 금정로55, 5층','사진' : 'profile_photos/현광열.png',}, +{'이름' : '황미영','소속' : '㈜센트럴시티','생년월일' : '1965.09.16','직책' : '대표','연락처' : '010-6780-8082','주소' : '부산시 해운대구 센텀중앙로 97, 에이동 3011호(재송동, 센텀스카이비즈)','사진' : 'profile_photos/황미영.png','TITLE':'상임부회장','SEQUENCE':'',}, +{'이름' : '황순민','소속' : '부산지방국세청','생년월일' : '1969.12.12','직책' : '송무과장','연락처' : '010-9002-0026','주소' : '부산광역시 연제구 토곡로20','사진' : 'profile_photos/황순민.png',}, +{'이름' : '황윤미','소속' : '㈜ 마케팅위너','생년월일' : '1976.10.09','직책' : '대표','연락처' : '010-9695-2918','주소' : '부산광역시 사상구 모라로 22, 부산벤처타워 1607호','사진' : 'profile_photos/황윤미.png',}, +{'이름' : '황진순','소속' : '영진에셋 알파지점','생년월일' : '1968.04.01','직책' : '지점장','연락처' : '010-6561-5593','주소' : '부산시 부산진구 서면로22, 6층 602호(태양빌딩)','사진' : 'profile_photos/황진순.png','TITLE':'부회장','SEQUENCE':'',}, +{'이름' : '황태욱','소속' : '㈜유림이엔티','생년월일' : '1982.10.27','직책' : '실장','연락처' : '010-3300-0706','주소' : '부산시 강서구 낙동북로73번길 15','사진' : 'profile_photos/황태욱.png',}, +{'이름' : '황하섭','소속' : '세정강재㈜','생년월일' : '1973.12.17','직책' : '대표','연락처' : '010-5582-0078-','주소' : '부산 사상구 학장로 135번길20 (학장동)','사진' : 'profile_photos/황하섭.png',}, +{'이름' : '황현숙','소속' : '정관장 가야점','생년월일' : '1961.04.27','직책' : '대표','연락처' : '010-3851-0187','주소' : '부산시 부산진구 가야대로 679번길 155 유림상가 정관장','사진' : 'profile_photos/황현숙.png','TITLE':'고문','SEQUENCE':'',}, +{'이름' : '황현종','소속' : '더와이즈 법률사무소','생년월일' : '1983.10.24','직책' : '대표변호사','연락처' : '010-5466-9173','주소' : '부산광역시 연제구 법원로 28,1305호(거제동, 부산법조타운빌딩)','사진' : 'profile_photos/황현종.png','TITLE':'재무국','SEQUENCE':'',}, +# {'이름' : '허남식','소속' : '신라대학교','직책' : '총장','연락처' : '010-9568-3579','주소' : 'president@silla.ac.kr','사진' : 'profile_photos/허남식.png','TITLE':'총장','SEQUENCE':'1',}, +# {'이름' : '이희태','소속' : '신라대학교','직책' : '대학원장','연락처' : '010-3866-4694','주소' : '부산광역시 사상구 백양대로 700번길 140 신라대학교 대학본부 603호','사진' : 'profile_photos/이희태.png','TITLE':'대학원장','SEQUENCE':'2',}, +# {'이름' : '최두원','소속' : '신라대학교','직책' : '부원장','연락처' : '010-2564-1741','주소' : '부산광역시 사상구 백양대로 700번길 140 신라대학교 공학관 910호','사진' : 'profile_photos/최두원.png','TITLE':'부원장','SEQUENCE':'3',}, +] + + diff --git a/B_main/restore_phone_dashes.py b/B_main/restore_phone_dashes.py new file mode 100644 index 0000000..eaecaf9 --- /dev/null +++ b/B_main/restore_phone_dashes.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +""" +기존 Person 데이터의 전화번호를 대시 있는 형태로 되돌리는 스크립트 +""" + +import os +import sys +import django +import re + +# Django 설정 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +django.setup() + +from B_main.models import Person + +def format_phone_with_dash(phone): + """전화번호를 010-XXXX-XXXX 형식으로 변환""" + if not phone: + return phone + # 숫자만 추출 + numbers = re.sub(r'[^0-9]', '', phone) + + # 11자리인 경우에만 포맷팅 + if len(numbers) == 11 and numbers.startswith('010'): + return f"{numbers[:3]}-{numbers[3:7]}-{numbers[7:]}" + elif len(numbers) == 10 and numbers.startswith('010'): + return f"{numbers[:3]}-{numbers[3:6]}-{numbers[6:]}" + + return phone + +def restore_phone_dashes(): + """기존 Person 데이터의 전화번호를 대시 있는 형태로 되돌리기""" + print("=" * 60) + print("Person 데이터 전화번호 대시 복원") + print("=" * 60) + + # 모든 Person 데이터 조회 + persons = Person.objects.all() + + updated_count = 0 + for person in persons: + if person.연락처: + old_phone = person.연락처 + new_phone = format_phone_with_dash(old_phone) + + if old_phone != new_phone: + print(f"복원: {person.이름} - {old_phone} → {new_phone}") + person.연락처 = new_phone + person.save() + updated_count += 1 + else: + print(f"변경 없음: {person.이름} - {old_phone}") + else: + print(f"전화번호 없음: {person.이름}") + + print(f"\n총 {updated_count}개의 전화번호가 복원되었습니다.") + +if __name__ == '__main__': + restore_phone_dashes() \ No newline at end of file diff --git a/B_main/show_all_users.py b/B_main/show_all_users.py new file mode 100644 index 0000000..b1c108e --- /dev/null +++ b/B_main/show_all_users.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +""" +전체 유저의 메인페이지 표시를 '표시'로 변경하는 스크립트 +""" + +import os +import sys +import django + +# Django 설정 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +django.setup() + +from B_main.models import Person + +def show_all_users(): + """전체 유저의 메인페이지 표시를 '표시'로 변경""" + print("=" * 60) + print("전체 유저 메인페이지 표시 설정") + print("=" * 60) + + # 모든 Person 데이터 조회 + persons = Person.objects.all() + + updated_count = 0 + for person in persons: + if not person.보일지여부: + print(f"표시로 변경: {person.이름} (회원가입상태: {person.회원가입상태})") + person.보일지여부 = True + person.save() + updated_count += 1 + else: + print(f"이미 표시: {person.이름} (회원가입상태: {person.회원가입상태})") + + print(f"\n총 {updated_count}개의 사용자가 표시로 변경되었습니다.") + + # 최종 통계 + total_persons = Person.objects.count() + visible_persons = Person.objects.filter(보일지여부=True).count() + hidden_persons = Person.objects.filter(보일지여부=False).count() + + print(f"\n최종 통계:") + print(f" 전체 사용자: {total_persons}명") + print(f" 표시 사용자: {visible_persons}명") + print(f" 숨김 사용자: {hidden_persons}명") + +if __name__ == '__main__': + show_all_users() \ No newline at end of file diff --git a/B_main/templates/B_main/custom_signup.html b/B_main/templates/B_main/custom_signup.html new file mode 100644 index 0000000..9a7e0ca --- /dev/null +++ b/B_main/templates/B_main/custom_signup.html @@ -0,0 +1,88 @@ + + + + + + 회원가입 | 신라 AMP + + + + + + +
+
+

신라 AMP

+

회원가입

+
+ +
+ {% csrf_token %} + + {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + +
+ + {{ form.email }} +
+ +
+ + {{ form.password1 }} +
+ +
+ + {{ form.password2 }} +
+ +
+ {{ form.privacy_agreement }} +
+ + {% if form.privacy_agreement.help_text %} +

{{ form.privacy_agreement.help_text }}

+ {% endif %} +
+
+ + + {{ form.name }} + {{ form.phone }} + + +
+ + +
+ + + \ No newline at end of file diff --git a/B_main/templates/B_main/main(darkmode).htm b/B_main/templates/B_main/main(darkmode).htm new file mode 100644 index 0000000..96b907e --- /dev/null +++ b/B_main/templates/B_main/main(darkmode).htm @@ -0,0 +1,39 @@ +{% load static %} + + + + + + 신라대학교 AMP 제8기 + + + + +
+

신라대학교 AMP 제8기

+ + +
+ +
+ + +
+ {% for person in people %} + {% include 'B_main/partials/card.htm' %} + {% endfor %} +
+
+ + diff --git a/B_main/templates/B_main/main.htm b/B_main/templates/B_main/main.htm new file mode 100644 index 0000000..4190ec4 --- /dev/null +++ b/B_main/templates/B_main/main.htm @@ -0,0 +1,211 @@ +{% load static %} + + + + + + 신라대학교 AMP 제8기 + + + + + + + +
+ +
+

신라대학교 AMP 제8기

+
+ {% if user.is_authenticated %} +
+ + + +
+ +
+ + + + +
+ + + + +
+ +
+ {% elif request.session.authenticated %} + 로그아웃 + {% else %} + 로그인 + 회원가입 + {% endif %} +
+
+ + + +
+ +
+ + +
+ {% for person in people %} + {% include 'B_main/partials/card.htm' %} + {% endfor %} +
+
+ + + + \ No newline at end of file diff --git a/B_main/templates/B_main/partials/card.htm b/B_main/templates/B_main/partials/card.htm new file mode 100644 index 0000000..51e9871 --- /dev/null +++ b/B_main/templates/B_main/partials/card.htm @@ -0,0 +1,123 @@ +{% load static %} +
+ +
+ {% if person.사진 and person.사진.url and 'media/' in person.사진.url %} + {{ person.이름 }} + {% else %} + {{ person.이름 }} + {% endif %} + {% if person.이름 %} + + 📇연락처저장 + + {% endif %} +
+ + +
+
+
+

+ {{ person.이름 }} + {% if person.생년월일 %} + ({{ person.생년월일|date:"Y년 m월 d일" }}) + {% endif %} +

+
+ {% if person.TITLE %} + {% if person.TITLE == '회장' or person.TITLE == '수석부회장' or person.TITLE == '선임상임부회장' or person.TITLE == '고문회장'%} + + {{ person.TITLE }} + + {% elif "경조국" in person.TITLE or "기획사무국" in person.TITLE or "홍보국" in person.TITLE or "친교문화국" in person.TITLE or "재무국" in person.TITLE %} + + {{ person.TITLE }} + + {% else %} + + {{ person.TITLE }} + + {% endif %} + {% endif %} +
+ +
+
+ 소속: + {{ person.소속 }} +
+
+ 직책: + {{ person.직책 }} +
+
+ 연락처: + {{ person.연락처 }} +
+
+ {% if person.이름 == '허남식' %} + 이메일: + {% else %} + 주소: + {% endif %} + {{ person.주소 }} +
+
+
+
+ + + + + \ No newline at end of file diff --git a/B_main/templates/B_main/partials/card_list.htm b/B_main/templates/B_main/partials/card_list.htm new file mode 100644 index 0000000..b77e2ac --- /dev/null +++ b/B_main/templates/B_main/partials/card_list.htm @@ -0,0 +1,4 @@ +{% load static %} +{% for person in people %} + {% include 'B_main/partials/card.htm' %} +{% endfor %} diff --git a/B_main/templates/B_main/password.htm b/B_main/templates/B_main/password.htm new file mode 100644 index 0000000..c4f2050 --- /dev/null +++ b/B_main/templates/B_main/password.htm @@ -0,0 +1,62 @@ + + + + + + Enter Access Code | 신라 AMP + + + + + + +
+
+

신라 AMP

+

Enter access code to continue

+
+ +
+ {% csrf_token %} +
+ +
+ +
+ + +
+ + + + + diff --git a/B_main/templates/B_main/phone_verification.html b/B_main/templates/B_main/phone_verification.html new file mode 100644 index 0000000..75ffde1 --- /dev/null +++ b/B_main/templates/B_main/phone_verification.html @@ -0,0 +1,114 @@ + + + + + + 전화번호 인증 | 신라 AMP + + + + + + +
+
+

신라 AMP

+

전화번호 인증

+
+ +
+ {% csrf_token %} + + {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + +
+ + {{ form.name }} +
+ +
+ + {{ form.phone }} +
+ +
+ + {{ form.verification_code }} +
+ + +
+ + +
+ + + + + \ No newline at end of file diff --git a/B_main/templates/B_main/profile_form.htm b/B_main/templates/B_main/profile_form.htm new file mode 100644 index 0000000..84ddb04 --- /dev/null +++ b/B_main/templates/B_main/profile_form.htm @@ -0,0 +1,217 @@ + + + + + + 프로필 수정 | 신라 AMP + + + + + + + +
+
+

신라 AMP

+

프로필 수정

+
+ +
+ {% csrf_token %} + {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + +
+ + {{ form.이름 }} +
+
+ + {{ form.소속 }} +
+
+ + {{ form.직책 }} +
+
+ + {{ form.연락처 }} +
+
+ + {{ form.주소 }} +
+
+ + {{ form.생년월일 }} +
+
+ + + + + {% if form.instance.사진 and form.instance.사진.url %} +
+ 프로필 사진 미리보기 +
+ {% else %} +
+ 프로필 사진 미리보기 +
+ {% endif %} +
+ + +
+

검색 키워드

+

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

+ +
+ {{ form.keyword1 }} +
+
+ + +
+ + +
+
+

회원탈퇴

+

+ 탈퇴하시면 로그인이 불가능하며, 개인정보는 보존됩니다. +

+ +
+
+ + +
+
+

비밀번호 변경

+

+ 전화번호 인증을 통해 비밀번호를 변경할 수 있습니다. +

+ + 비밀번호 변경 + +
+
+ + +
+ + + + diff --git a/B_main/templates/B_main/signup.html b/B_main/templates/B_main/signup.html new file mode 100644 index 0000000..ae58352 --- /dev/null +++ b/B_main/templates/B_main/signup.html @@ -0,0 +1,135 @@ + + + + + + 회원가입 | 신라 AMP + + + + + + +
+
+

신라 AMP

+

회원 정보를 입력해 주세요

+
+ + {% if message %} +
{{ message }}
+ {% endif %} + + {% if step == 1 %} +
+ {% csrf_token %} +
+ + {{ form1.name }} +
+
+ +
+ {{ form1.phone }} + +
+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ +
+ {{ form1.verification_code }} + +
+
+
+ {% elif step == 2 %} +
+ {% csrf_token %} + + {% if form2.errors %} +
+ {% for field, errors in form2.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} +
+ + +
+
+ + +
+ +
+ + {{ form2.password1 }} +
+
+ + {{ form2.password2 }} +
+ +
+
+ {{ form2.privacy_agreement }} +
+ +
+
+ {% if form2.privacy_agreement.help_text %} +
+

다음 정보의 공개에 동의합니다:

+
    +
  • 이름
  • +
  • 생년월일
  • +
  • 소속
  • +
  • 직책
  • +
  • 연락처
  • +
  • 주소
  • +
  • 사진
  • +
+

※ 위 정보는 신라 AMP 제8기 수강생들 간에 공유됩니다.

+
+ {% endif %} +
+ + +
+ {% endif %} + +
+ 이미 계정이 있으신가요? 로그인 +
+
+ + \ No newline at end of file diff --git a/B_main/tests.py b/B_main/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/B_main/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/B_main/update_phone_numbers.py b/B_main/update_phone_numbers.py new file mode 100644 index 0000000..4f2b49b --- /dev/null +++ b/B_main/update_phone_numbers.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +""" +기존 Person 데이터의 전화번호를 대시 없는 형태로 업데이트하는 스크립트 +""" + +import os +import sys +import django +import re + +# Django 설정 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +django.setup() + +from B_main.models import Person + +def format_phone_number(phone): + """전화번호에서 숫자만 추출하여 반환""" + if not phone: + return phone + # 숫자만 추출 + numbers = re.sub(r'[^0-9]', '', phone) + + # 11자리인 경우에만 반환 + if len(numbers) == 11 and numbers.startswith('010'): + return numbers + elif len(numbers) == 10 and numbers.startswith('010'): + return numbers + + return phone + +def update_phone_numbers(): + """기존 Person 데이터의 전화번호를 대시 없는 형태로 업데이트""" + print("=" * 60) + print("Person 데이터 전화번호 업데이트") + print("=" * 60) + + # 모든 Person 데이터 조회 + persons = Person.objects.all() + + updated_count = 0 + for person in persons: + if person.연락처: + old_phone = person.연락처 + new_phone = format_phone_number(old_phone) + + if old_phone != new_phone: + print(f"업데이트: {person.이름} - {old_phone} → {new_phone}") + person.연락처 = new_phone + person.save() + updated_count += 1 + else: + print(f"변경 없음: {person.이름} - {old_phone}") + else: + print(f"전화번호 없음: {person.이름}") + + print(f"\n총 {updated_count}개의 전화번호가 업데이트되었습니다.") + + # 중복 확인 + print("\n중복 전화번호 확인:") + phone_counts = {} + for person in Person.objects.all(): + if person.연락처: + phone_counts[person.연락처] = phone_counts.get(person.연락처, 0) + 1 + + duplicates = {phone: count for phone, count in phone_counts.items() if count > 1} + if duplicates: + print("중복된 전화번호 발견:") + for phone, count in duplicates.items(): + print(f" {phone}: {count}개") + persons_with_phone = Person.objects.filter(연락처=phone) + for person in persons_with_phone: + print(f" - {person.이름} (ID: {person.id}, 회원가입상태: {person.회원가입상태})") + else: + print("중복된 전화번호가 없습니다.") + +if __name__ == '__main__': + update_phone_numbers() \ No newline at end of file diff --git a/B_main/urls.py b/B_main/urls.py new file mode 100644 index 0000000..abb0a0d --- /dev/null +++ b/B_main/urls.py @@ -0,0 +1,19 @@ +from django.urls import path +from . import views +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('', views.main, name='main'), + path('vcard//', views.vcard_download, name='vcard_download'), + path('search/', views.search_people, name='search_people'), + path('password/', views.password_required, name='password_required'), + path('logout/', views.logout_view, name='logout'), + path('my-profile/', views.my_profile, name='my_profile'), + path('withdraw/', views.withdraw, name='withdraw'), + path('session_logout/', views.session_logout, name='session_logout'), + path('signup/', views.signup_view, name='signup'), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/B_main/views.py b/B_main/views.py new file mode 100644 index 0000000..c580781 --- /dev/null +++ b/B_main/views.py @@ -0,0 +1,373 @@ +from django.shortcuts import render, redirect +from django.http import HttpResponse, JsonResponse +from urllib.parse import unquote +from .models import Person +from django.db import models +from django.contrib.auth.decorators import login_required +from .forms import PersonForm, Step1PhoneForm, Step2AccountForm +from .models import Person +from django.shortcuts import get_object_or_404 +from django.db.models import Q, Case, When, Value, IntegerField +from django.contrib.auth import login, logout +import random +import json + +def password_required(request): + PASSWORD = '1110' # 실제 비밀번호 + + # 디버깅을 위한 로그 + print(f"[DEBUG] password_required - user.is_authenticated: {request.user.is_authenticated}") + print(f"[DEBUG] password_required - user: {request.user}") + + # 로그인이 된 사용자는 바로 메인 페이지로 리다이렉트 + if request.user.is_authenticated: + next_url = request.GET.get("next", "/") + if not next_url: + next_url = "/" + print(f"[DEBUG] User is authenticated, redirecting to: {next_url}") + return redirect(next_url) + + if request.method == "POST": + entered_password = request.POST.get("password") + if entered_password == PASSWORD: + request.session["authenticated"] = True + next_url = request.POST.get("next", "/") + + if not next_url: + next_url = "/" + + return redirect(next_url) + else: + return render(request, "B_main/password.htm", {"error": "Incorrect password. Please try again."}) + + # GET 요청 시 비밀번호 입력 폼 렌더링 + next_url = request.GET.get("next", "/") + return render(request, "B_main/password.htm", {"next": next_url}) + + +# 인증 검사 함수 +def check_authentication(request): + # 디버깅을 위한 로그 + print(f"[DEBUG] check_authentication - user.is_authenticated: {request.user.is_authenticated}") + print(f"[DEBUG] check_authentication - session.authenticated: {request.session.get('authenticated')}") + print(f"[DEBUG] check_authentication - user: {request.user}") + + # 로그인이 된 사용자는 인증 통과 + if request.user.is_authenticated: + print(f"[DEBUG] User is authenticated, allowing access") + return None + + # 세션 인증이 된 사용자도 통과 + if request.session.get("authenticated"): + print(f"[DEBUG] Session is authenticated, allowing access") + return None + + # 둘 다 안 된 경우에만 비밀번호 페이지로 리다이렉트 + print(f"[DEBUG] No authentication found, redirecting to password page") + return redirect(f"/accounts/login/?next={request.path}") + + + +def main(request): + auth_check = check_authentication(request) + if auth_check: + return auth_check + + # 현재 사용자의 Person 정보 가져오기 + current_user_person = None + if request.user.is_authenticated: + try: + current_user_person = Person.objects.get(user=request.user) + except Person.DoesNotExist: + pass + + # 기본 필터: 이름이 있는 사람들 + base_filter = Person.objects.filter( + 이름__isnull=False + ).exclude( + 이름__exact='' + ) + + # 현재 사용자의 권한에 따라 추가 필터 적용 + if current_user_person and not current_user_person.모든사람보기권한: + # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 (user가 있는 사람들) + base_filter = base_filter.filter(user__isnull=False) + print(f"[DEBUG] 회원가입자만 표시 모드: {current_user_person.이름}") + else: + print(f"[DEBUG] 모든 사람 표시 모드") + + # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 + people = base_filter.annotate( + sequence_order=Case( + When(SEQUENCE__isnull=True, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + ).order_by('sequence_order', 'SEQUENCE', '이름') + + print(f"[DEBUG] 메인 페이지 표시: {people.count()}명") + for person in people: + status = "회원가입" if person.user else "미가입" + print(f"[DEBUG] - {person.이름} (상태: {status})") + + return render(request, 'B_main/main.htm', {'people': people}) + + + +def search_people(request): + auth_check = check_authentication(request) + if auth_check: + return auth_check + + query = request.GET.get('q', '') + print(f"[DEBUG] 검색 쿼리: '{query}'") + + # 현재 사용자의 Person 정보 가져오기 + current_user_person = None + if request.user.is_authenticated: + try: + current_user_person = Person.objects.get(user=request.user) + except Person.DoesNotExist: + pass + + # 기본 필터: 모든 사람 + base_filter = Person.objects.all() + + # 현재 사용자의 권한에 따라 추가 필터 적용 + if current_user_person and not current_user_person.모든사람보기권한: + # 모든사람보기권한이 False인 경우 회원가입한 사람만 표시 (user가 있는 사람들) + base_filter = base_filter.filter(user__isnull=False) + print(f"[DEBUG] 검색 - 회원가입자만 표시 모드: {current_user_person.이름}") + else: + print(f"[DEBUG] 검색 - 모든 사람 표시 모드") + + if query: + # 이름, 소속, 직책, 키워드로 검색 + # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 + people = base_filter.filter( + Q(이름__icontains=query) | + Q(소속__icontains=query) | + Q(TITLE__icontains=query) | + Q(직책__icontains=query) | + Q(keyword1__icontains=query) | + Q(생년월일__icontains=query) + ).filter( + 이름__isnull=False + ).exclude( + 이름__exact='' + ).annotate( + sequence_order=Case( + When(SEQUENCE__isnull=True, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + ).order_by('sequence_order', 'SEQUENCE', '이름') + print(f"[DEBUG] 검색 결과: {people.count()}명") + for person in people: + print(f"[DEBUG] - {person.이름} (소속: {person.소속}, 직책: {person.직책})") + else: + # 순서가 있는 항목을 먼저 보여주고, 나머지는 가나다순으로 정렬 + people = base_filter.filter( + 이름__isnull=False + ).exclude( + 이름__exact='' + ).annotate( + sequence_order=Case( + When(SEQUENCE__isnull=True, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ) + ).order_by('sequence_order', 'SEQUENCE', '이름') + print(f"[DEBUG] 전체 목록: {people.count()}명") + + return render(request, 'B_main/partials/card_list.htm', {'people': people}) + +def vcard_download(request, name): + auth_check = check_authentication(request) + if auth_check: + return auth_check + + name = unquote(name) + if not name: + return HttpResponse("Invalid name", status=400) + + person = get_object_or_404(Person, 이름=name) + + vcard_content = f"""BEGIN:VCARD +VERSION:3.0 +N:{person.이름};;;; +FN:{person.이름} +ORG:{person.소속} +TITLE:{person.직책} +TEL;CELL:{person.연락처} +ADR:;;{person.주소} +END:VCARD +""" + + response = HttpResponse(vcard_content, content_type='text/vcard') + response['Content-Disposition'] = f'attachment; filename="{person.이름}.vcf"' + return response + + + + + + +def logout_view(request): + request.session.flush() + return redirect('/password/') + + + + +@login_required +def my_profile(request): + try: + person = Person.objects.get(user=request.user) + except Person.DoesNotExist: + person = None + + if request.method == 'POST': + form = PersonForm(request.POST, instance=person) + if form.is_valid(): + person = form.save(commit=False) + person.user = request.user + person.save() + return redirect('main') # or any success page + else: + form = PersonForm(instance=person) + + return render(request, 'B_main/profile_form.htm', {'form': form}) + + +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def withdraw(request): + """회원탈퇴 뷰""" + try: + # 현재 사용자의 Person 정보 가져오기 + person = Person.objects.get(user=request.user) + + # User 연결 해제 + person.user = None + person.save() + + # User 객체 삭제 (전화번호 계정 삭제) + user_phone = request.user.username + request.user.delete() + + # 로그아웃 + logout(request) + + print(f"[DEBUG] 회원탈퇴 완료: {user_phone} (User 삭제, Person 연결 해제)") + return JsonResponse({'success': True}) + except Person.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Person 정보를 찾을 수 없습니다.'}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + +def session_logout(request): + try: + del request.session['authenticated'] + except KeyError: + pass + return redirect('/') + +def signup_view(request): + import random + from .forms import is_allowed_person + from django.contrib.auth import login + + # GET 요청 시 세션 초기화 (새로운 회원가입 시작) + # 단, 인증번호 확인 후 리다이렉트된 경우는 세션 유지 + if request.method == 'GET' and not request.session.get('signup_verified'): + 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 + + step = request.session.get('signup_step', 1) + name = request.session.get('signup_name') + phone = request.session.get('signup_phone') + code_sent = bool(request.session.get('signup_code')) + verified = request.session.get('signup_verified', False) + + # 1단계: 이름, 전화번호, 인증번호 + if step == 1: + if request.method == 'POST': + form = Step1PhoneForm(request.POST) + action = request.POST.get('action') + if action == 'send_code': + if form.is_valid(): + name = form.cleaned_data['name'] + phone = form.cleaned_data['phone'] + + # 폼 검증에서 이미 허가되지 않은 사용자 체크를 했으므로 여기서는 제거 + code = str(random.randint(100000, 999999)) + request.session['signup_code'] = code + request.session['signup_name'] = name + request.session['signup_phone'] = phone + request.session['signup_verified'] = False + print(f"[DEBUG] 인증번호 발송: {name} ({phone}) - {code}") + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': True, 'message': '인증번호가 발송되었습니다.' + }) + else: + # 폼 에러 메시지 확인 + error_message = '입력 정보를 확인해주세요.' + if form.errors: + # 첫 번째 에러 메시지 사용 + for field_errors in form.errors.values(): + if field_errors: + error_message = field_errors[0] + break + + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': False, + 'error': error_message + }) + elif action == 'verify_code': + if form.is_valid(): + verification_code = form.cleaned_data['verification_code'] + session_code = request.session.get('signup_code') + if verification_code and verification_code == session_code: + # 인증 성공 + request.session['signup_verified'] = True + request.session['signup_step'] = 2 + return redirect('signup') + else: + return render(request, 'B_main/signup.html', { + 'step': 1, 'form1': form, 'code_sent': True, 'error': '인증번호가 올바르지 않습니다.' + }) + else: + return render(request, 'B_main/signup.html', {'step': 1, 'form1': form, 'code_sent': code_sent}) + else: + form = Step1PhoneForm() + return render(request, 'B_main/signup.html', {'step': 1, 'form1': form, 'code_sent': False}) + + # 2단계: 이메일, 비밀번호, 비밀번호 확인 + if step == 2 and verified and name and phone: + if request.method == 'POST': + form2 = Step2AccountForm(request.POST) + if form2.is_valid(): + user = form2.save(name, phone, request) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') + # 세션 정리 + for key in ['signup_code', 'signup_name', 'signup_phone', 'signup_verified', 'signup_step']: + request.session.pop(key, None) + return redirect('main') + else: + return render(request, 'B_main/signup.html', {'step': 2, 'form2': form2, 'name': name, 'phone': phone}) + else: + form2 = Step2AccountForm() + return render(request, 'B_main/signup.html', {'step': 2, 'form2': form2, 'name': name, 'phone': phone}) + + # 기본: 1단계로 초기화 + request.session['signup_step'] = 1 + request.session['signup_verified'] = False + return redirect('signup') \ No newline at end of file diff --git a/C_accounts/__init__.py b/C_accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/C_accounts/__pycache__/__init__.cpython-38.pyc b/C_accounts/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..fb84964 Binary files /dev/null and b/C_accounts/__pycache__/__init__.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/admin.cpython-38.pyc b/C_accounts/__pycache__/admin.cpython-38.pyc new file mode 100644 index 0000000..0c2e6b1 Binary files /dev/null and b/C_accounts/__pycache__/admin.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/apps.cpython-38.pyc b/C_accounts/__pycache__/apps.cpython-38.pyc new file mode 100644 index 0000000..36cc13a Binary files /dev/null and b/C_accounts/__pycache__/apps.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/forms.cpython-38.pyc b/C_accounts/__pycache__/forms.cpython-38.pyc new file mode 100644 index 0000000..0880a85 Binary files /dev/null and b/C_accounts/__pycache__/forms.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/middleware.cpython-38.pyc b/C_accounts/__pycache__/middleware.cpython-38.pyc new file mode 100644 index 0000000..d9b9520 Binary files /dev/null and b/C_accounts/__pycache__/middleware.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/models.cpython-38.pyc b/C_accounts/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000..e1b257b Binary files /dev/null and b/C_accounts/__pycache__/models.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/signals.cpython-38.pyc b/C_accounts/__pycache__/signals.cpython-38.pyc new file mode 100644 index 0000000..c321f9e Binary files /dev/null and b/C_accounts/__pycache__/signals.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/urls.cpython-38.pyc b/C_accounts/__pycache__/urls.cpython-38.pyc new file mode 100644 index 0000000..e6535d0 Binary files /dev/null and b/C_accounts/__pycache__/urls.cpython-38.pyc differ diff --git a/C_accounts/__pycache__/views.cpython-38.pyc b/C_accounts/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000..0e9b0fb Binary files /dev/null and b/C_accounts/__pycache__/views.cpython-38.pyc differ diff --git a/C_accounts/admin.py b/C_accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/C_accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/C_accounts/apps.py b/C_accounts/apps.py new file mode 100644 index 0000000..1936998 --- /dev/null +++ b/C_accounts/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class CAccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'C_accounts' + + def ready(self): + import C_accounts.signals + diff --git a/C_accounts/forms.py b/C_accounts/forms.py new file mode 100644 index 0000000..56ea6be --- /dev/null +++ b/C_accounts/forms.py @@ -0,0 +1,269 @@ +from django import forms +from django.contrib.auth import get_user_model +from B_main.models import Person # 또는 Person 모델이 정의된 경로로 import +import random +import re + +User = get_user_model() + +def format_phone_number(phone): + """전화번호에서 대시 제거""" + return re.sub(r'[^0-9]', '', phone) + +class CustomFileInput(forms.FileInput): + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + # "Currently:" 텍스트 제거 + if 'help_text' in context: + context['help_text'] = '' + return context + +class ProfileFullEditForm(forms.ModelForm): + # 통합된 이름 필드 (편집 불가능) + full_name = forms.CharField( + label="이름", + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-600 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'readonly': 'readonly', + 'placeholder': '이름' + }) + ) + + class Meta: + model = Person + fields = [ + '소속', '직책', '주소', '사진', 'keyword1' + ] + widgets = { + '소속': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '소속' + }), + '직책': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '직책' + }), + '주소': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '주소' + }), + '사진': CustomFileInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'accept': 'image/*' + }), + 'keyword1': forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '검색 키워드 (예: 회계감사)' + }), + } + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') + super().__init__(*args, **kwargs) + + # 통합된 이름 설정 (first_name + last_name) + full_name = f"{self.user.first_name or ''} {self.user.last_name or ''}".strip() + self.fields['full_name'].initial = full_name + + # Person 모델 필드 초기값 설정 (기존 인스턴스가 있는 경우) + if self.instance and self.instance.pk: + # 기존 Person 인스턴스의 데이터로 초기화 + for field_name in self.fields: + if field_name == 'full_name': + continue + if hasattr(self.instance, field_name): + self.fields[field_name].initial = getattr(self.instance, field_name) + + def save(self, commit=True): + # Person 모델 저장 (User 모델은 수정하지 않음) + if commit: + instance = super().save(commit=False) + instance.user = self.user + instance.save() + + return self.user + +# 모드1: 비밀번호 찾기 폼 +class PasswordResetStep1Form(forms.Form): + """비밀번호 찾기 1단계: 전화번호 인증""" + phone = forms.CharField( + max_length=11, + label='전화번호', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '01012345678' + }) + ) + verification_code = forms.CharField( + max_length=6, + label='인증번호', + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '6자리 인증번호' + }) + ) + + def clean_phone(self): + phone = self.cleaned_data.get('phone') + if phone: + # 전화번호 포맷팅 적용 (대시 제거) + formatted_phone = format_phone_number(phone) + + # 해당 전화번호로 가입된 사용자가 있는지 확인 + try: + user = User.objects.get(username=formatted_phone) + return formatted_phone + except User.DoesNotExist: + raise forms.ValidationError('등록되지 않은 전화번호입니다.') + return phone + +# 모드2: 로그인 상태 비밀번호 변경 폼 +class PasswordChangeLoggedInForm(forms.Form): + """로그인 상태에서 비밀번호 변경""" + current_password = forms.CharField( + label='현재 비밀번호', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '현재 비밀번호' + }) + ) + new_password1 = forms.CharField( + label='새 비밀번호', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호' + }) + ) + new_password2 = forms.CharField( + label='새 비밀번호 확인', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호 확인' + }) + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + def clean_current_password(self): + current_password = self.cleaned_data.get('current_password') + if self.user and not self.user.check_password(current_password): + raise forms.ValidationError('현재 비밀번호가 올바르지 않습니다.') + return current_password + + def clean(self): + cleaned_data = super().clean() + password1 = cleaned_data.get('new_password1') + password2 = cleaned_data.get('new_password2') + + if password1 and password2 and password1 != password2: + raise forms.ValidationError('새 비밀번호가 일치하지 않습니다.') + + if password1 and len(password1) < 8: + raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.') + + return cleaned_data + +# 모드3: 강제 비밀번호 설정 폼 +class ForcePasswordSetForm(forms.Form): + """강제 비밀번호 설정""" + new_password1 = forms.CharField( + label='새 비밀번호', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호' + }) + ) + new_password2 = forms.CharField( + label='새 비밀번호 확인', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호 확인' + }) + ) + + def clean(self): + cleaned_data = super().clean() + password1 = cleaned_data.get('new_password1') + password2 = cleaned_data.get('new_password2') + + if password1 and password2 and password1 != password2: + raise forms.ValidationError('비밀번호가 일치하지 않습니다.') + + if password1 and len(password1) < 8: + raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.') + + return cleaned_data + +# 기존 폼들 (유지) +class PasswordChangeStep1Form(forms.Form): + """비밀번호 변경 1단계: 전화번호 인증""" + phone = forms.CharField( + max_length=11, + label='전화번호', + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '01012345678' + }) + ) + verification_code = forms.CharField( + max_length=6, + label='인증번호', + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '6자리 인증번호' + }) + ) + + def clean(self): + cleaned_data = super().clean() + phone = cleaned_data.get('phone') + + if phone: + # 전화번호 포맷팅 적용 (대시 제거) + formatted_phone = format_phone_number(phone) + cleaned_data['phone'] = formatted_phone + + # 현재 로그인한 사용자의 전화번호와 일치하는지 확인 + if not self.user or self.user.username != formatted_phone: + raise forms.ValidationError('등록된 전화번호와 일치하지 않습니다.') + + return cleaned_data + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + +class PasswordChangeStep2Form(forms.Form): + """비밀번호 변경 2단계: 새 비밀번호 입력""" + new_password1 = forms.CharField( + label='새 비밀번호', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호' + }) + ) + new_password2 = forms.CharField( + label='새 비밀번호 확인', + widget=forms.PasswordInput(attrs={ + 'class': 'w-full px-4 py-3 rounded-xl bg-gray-700 bg-opacity-80 text-white placeholder-gray-400 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition', + 'placeholder': '새 비밀번호 확인' + }) + ) + + def clean(self): + cleaned_data = super().clean() + password1 = cleaned_data.get('new_password1') + password2 = cleaned_data.get('new_password2') + + if password1 and password2 and password1 != password2: + raise forms.ValidationError('비밀번호가 일치하지 않습니다.') + + if password1 and len(password1) < 8: + raise forms.ValidationError('비밀번호는 최소 8자 이상이어야 합니다.') + + return cleaned_data diff --git a/C_accounts/middleware.py b/C_accounts/middleware.py new file mode 100644 index 0000000..f09c0c2 --- /dev/null +++ b/C_accounts/middleware.py @@ -0,0 +1,25 @@ +from django.shortcuts import redirect +from django.urls import reverse +from B_main.models import Person + +class ForcePasswordSetMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # 로그인한 사용자이고 비밀번호 설정이 필요한 경우 + if request.user.is_authenticated: + try: + person = Person.objects.get(user=request.user) + if person.비밀번호설정필요: + # 현재 URL이 강제 비밀번호 설정 페이지가 아닌 경우에만 리다이렉트 + current_path = request.path + force_password_set_path = reverse('accounts:force_password_set') + + if current_path != force_password_set_path and not current_path.startswith('/admin/'): + return redirect('accounts:force_password_set') + except Person.DoesNotExist: + pass + + response = self.get_response(request) + return response \ No newline at end of file diff --git a/C_accounts/migrations/__init__.py b/C_accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/C_accounts/migrations/__pycache__/__init__.cpython-38.pyc b/C_accounts/migrations/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..29e6a12 Binary files /dev/null and b/C_accounts/migrations/__pycache__/__init__.cpython-38.pyc differ diff --git a/C_accounts/models.py b/C_accounts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/C_accounts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/C_accounts/signals.py b/C_accounts/signals.py new file mode 100644 index 0000000..72fcb62 --- /dev/null +++ b/C_accounts/signals.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User +from allauth.account.signals import user_signed_up +from B_main.models import Person + +@receiver(user_signed_up) +def create_person_profile(sender, request, user, **kwargs): + """회원가입 시 Person 프로필 생성""" + try: + # 이미 Person 프로필이 있는지 확인 + Person.objects.get_or_create( + user=user, + defaults={ + '이름': user.get_full_name() or user.username, + '소속': '', + '직책': '', + '연락처': '', + '주소': '', + '사진': 'B_main/images/강경옥.png' + } + ) + except Exception as e: + print(f"Person 프로필 생성 중 오류: {e}") diff --git a/C_accounts/templates/C_accounts/force_password_set.html b/C_accounts/templates/C_accounts/force_password_set.html new file mode 100644 index 0000000..da0cb5c --- /dev/null +++ b/C_accounts/templates/C_accounts/force_password_set.html @@ -0,0 +1,182 @@ +{% load static %} + + + + + + 비밀번호 설정 | 신라대학교 AMP 제8기 + + + + + + + + + +
+ +
+
+

신라대학교 AMP 제8기

+
+ {% if user.is_authenticated %} +
+ + + +
+ + + + + + +
+ +
+ {% elif request.session.authenticated %} + 로그아웃 + {% else %} + 로그인 + 회원가입 + {% endif %} +
+
+
+ + +
+
+
+

비밀번호 설정

+

보안을 위해 비밀번호를 설정해주세요

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

{{ form.new_password1.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form.new_password2 }} + {% if form.new_password2.errors %} +

{{ form.new_password2.errors.0 }}

+ {% endif %} +
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/C_accounts/templates/C_accounts/password_change.html b/C_accounts/templates/C_accounts/password_change.html new file mode 100644 index 0000000..5a1d17d --- /dev/null +++ b/C_accounts/templates/C_accounts/password_change.html @@ -0,0 +1,142 @@ +{% extends "base.htm" %} + +{% block content %} + + +
+
+
+

신라 AMP

+

비밀번호 변경

+
+ + {% if step == 1 %} + +
+ {% csrf_token %} + {% if error %} +
{{ error }}
+ {% endif %} + {% if message %} +
{{ message }}
+ {% endif %} + +
+ + {{ form1.phone }} + {% if form1.phone.errors %} +

{{ form1.phone.errors.0 }}

+ {% endif %} +
+ + {% if code_sent %} +
+ + {{ form1.verification_code }} + {% if form1.verification_code.errors %} +

{{ form1.verification_code.errors.0 }}

+ {% endif %} +
+ {% endif %} + +
+ {% if not code_sent %} + + {% else %} + + {% endif %} +
+
+ + {% elif step == 2 %} + +
+ {% csrf_token %} + {% if form2.errors %} +
+ {% for field, errors in form2.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + +
+

전화번호: {{ phone }}

+
+ +
+ + {{ form2.new_password1 }} + {% if form2.new_password1.errors %} +

{{ form2.new_password1.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form2.new_password2 }} + {% if form2.new_password2.errors %} +

{{ form2.new_password2.errors.0 }}

+ {% endif %} +
+ + +
+ {% endif %} + + +
+
+{% endblock %} \ No newline at end of file diff --git a/C_accounts/templates/C_accounts/password_change_logged_in.html b/C_accounts/templates/C_accounts/password_change_logged_in.html new file mode 100644 index 0000000..229324a --- /dev/null +++ b/C_accounts/templates/C_accounts/password_change_logged_in.html @@ -0,0 +1,197 @@ +{% load static %} + + + + + + 비밀번호 변경 | 신라대학교 AMP 제8기 + + + + + + + + + +
+
+ +
+

신라대학교 AMP 제8기

+
+ {% if user.is_authenticated %} +
+ + + +
+ + + + + + +
+ +
+ {% elif request.session.authenticated %} + 로그아웃 + {% else %} + 로그인 + 회원가입 + {% endif %} +
+
+ + +
+
+
+

비밀번호 변경

+

현재 비밀번호를 입력하고 새 비밀번호를 설정하세요

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

{{ form.current_password.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form.new_password1 }} + {% if form.new_password1.errors %} +

{{ form.new_password1.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form.new_password2 }} + {% if form.new_password2.errors %} +

{{ form.new_password2.errors.0 }}

+ {% endif %} +
+ + +
+ + +
+
+
+
+ + \ No newline at end of file diff --git a/C_accounts/templates/C_accounts/password_reset.html b/C_accounts/templates/C_accounts/password_reset.html new file mode 100644 index 0000000..5f109fc --- /dev/null +++ b/C_accounts/templates/C_accounts/password_reset.html @@ -0,0 +1,122 @@ + + + + + + 전화번호 찾기 | 신라 AMP + + + + + + +
+
+

신라 AMP

+

전화번호 인증을 통해 비밀번호를 재설정하세요

+
+ + {% if step == 1 %} + +
+ {% csrf_token %} + {% if error %} +
{{ error }}
+ {% endif %} + {% if message %} +
{{ message }}
+ {% endif %} + +
+ + {{ form1.phone }} + {% if form1.phone.errors %} +

{{ form1.phone.errors.0 }}

+ {% endif %} +
+ + {% if code_sent %} +
+ + {{ form1.verification_code }} + {% if form1.verification_code.errors %} +

{{ form1.verification_code.errors.0 }}

+ {% endif %} +
+ {% endif %} + +
+ {% if not code_sent %} + + {% else %} + + {% endif %} +
+
+ + {% elif step == 2 %} + +
+ {% csrf_token %} + {% if form2.errors %} +
+ {% for field, errors in form2.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + +
+

전화번호: {{ phone }}

+
+ +
+ + {{ form2.new_password1 }} + {% if form2.new_password1.errors %} +

{{ form2.new_password1.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form2.new_password2 }} + {% if form2.new_password2.errors %} +

{{ form2.new_password2.errors.0 }}

+ {% endif %} +
+ + +
+ {% endif %} + + +
+ + \ No newline at end of file diff --git a/C_accounts/templates/C_accounts/profile_edit.html b/C_accounts/templates/C_accounts/profile_edit.html new file mode 100644 index 0000000..92c8f3c --- /dev/null +++ b/C_accounts/templates/C_accounts/profile_edit.html @@ -0,0 +1,350 @@ +{% load static %} + + + + + + 프로필 수정 | 신라대학교 AMP 제8기 + + + + + + + + + +
+
+ +
+

신라대학교 AMP 제8기

+
+ {% if user.is_authenticated %} +
+ + + +
+ +
+ + + + +
+ + + + +
+ +
+ {% elif request.session.authenticated %} + 로그아웃 + {% else %} + 로그인 + 회원가입 + {% endif %} +
+
+ + +
+
+
+

프로필 수정

+

개인 정보를 수정하세요

+
+ + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + {% if form.errors %} +
+ {% for field, errors in form.errors.items %} + {% for error in errors %} + {{ error }} + {% endfor %} + {% endfor %} +
+ {% endif %} + + +
+ + {{ form.full_name }} +
+ +
+ + +
+ + {% if form.instance.생년월일 %} +
+ + +
+ {% endif %} + + {% if form.instance.TITLE %} +
+ + +
+ {% endif %} + + +
+ + {{ form.소속 }} +
+
+ + {{ form.직책 }} +
+
+ + {{ form.주소 }} +
+
+ + {{ form.사진 }} + + {% if form.instance.사진 and form.instance.사진.url %} +
+ 프로필 사진 미리보기 +
+ {% else %} +
+ 프로필 사진 미리보기 +
+ {% endif %} +
+ + +
+

검색 키워드

+

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

+ +
+ {{ form.keyword1 }} +
+
+ + +
+ + + + + +
+
+
+
+ + + + diff --git a/C_accounts/templates/base.htm b/C_accounts/templates/base.htm new file mode 100644 index 0000000..aec5c22 --- /dev/null +++ b/C_accounts/templates/base.htm @@ -0,0 +1,46 @@ + + + + + + {% block title %}신라 AMP{% endblock %} + + + + + + + +
+

신라대학교 AMP 제8기

+ {% if user.is_authenticated %} +
+ {{ user.email }}님 | + 로그아웃 +
+ {% else %} + + {% endif %} +
+ + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/C_accounts/templatetags/__init__.py b/C_accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/C_accounts/templatetags/__pycache__/__init__.cpython-38.pyc b/C_accounts/templatetags/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..c5be934 Binary files /dev/null and b/C_accounts/templatetags/__pycache__/__init__.cpython-38.pyc differ diff --git a/C_accounts/templatetags/__pycache__/form_filters.cpython-38.pyc b/C_accounts/templatetags/__pycache__/form_filters.cpython-38.pyc new file mode 100644 index 0000000..d437390 Binary files /dev/null and b/C_accounts/templatetags/__pycache__/form_filters.cpython-38.pyc differ diff --git a/C_accounts/templatetags/form_filters.py b/C_accounts/templatetags/form_filters.py new file mode 100644 index 0000000..1d19690 --- /dev/null +++ b/C_accounts/templatetags/form_filters.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter(name='add_class') +def add_class(field, css): + return field.as_widget(attrs={"class": css}) diff --git a/C_accounts/tests.py b/C_accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/C_accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/C_accounts/urls.py b/C_accounts/urls.py new file mode 100644 index 0000000..c3e9313 --- /dev/null +++ b/C_accounts/urls.py @@ -0,0 +1,13 @@ +# C_accounts/urls.py +from django.urls import path +from . import views + +app_name = 'accounts' + +urlpatterns = [ + path('profile_edit/', views.profile_edit, name='custom_profile_edit'), + path('password_change/', views.password_change, name='password_change'), + path('password_reset/', views.password_reset, name='password_reset'), + path('password_change_logged_in/', views.password_change_logged_in, name='password_change_logged_in'), + path('force_password_set/', views.force_password_set, name='force_password_set'), +] diff --git a/C_accounts/views.py b/C_accounts/views.py new file mode 100644 index 0000000..c6879ea --- /dev/null +++ b/C_accounts/views.py @@ -0,0 +1,271 @@ +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from .forms import ( + ProfileFullEditForm, PasswordChangeStep1Form, PasswordChangeStep2Form, + PasswordResetStep1Form, PasswordChangeLoggedInForm, ForcePasswordSetForm +) +from B_main.models import Person +import random + +User = get_user_model() + +@login_required +def profile_edit(request): + """프로필 편집 뷰""" + # 현재 사용자의 Person 인스턴스 가져오기 + try: + person = Person.objects.get(user=request.user) + except Person.DoesNotExist: + # Person 인스턴스가 없으면 새로 생성 + person = Person.objects.create(user=request.user) + + if request.method == 'POST': + form = ProfileFullEditForm(request.POST, request.FILES, user=request.user, instance=person) + if form.is_valid(): + form.save() + messages.success(request, '프로필이 성공적으로 업데이트되었습니다.') + return redirect('accounts:custom_profile_edit') + else: + form = ProfileFullEditForm(user=request.user, instance=person) + + return render(request, 'C_accounts/profile_edit.html', {'form': form}) + +@login_required +def password_change(request): + """비밀번호 변경 뷰 (2단계 프로세스)""" + # 세션 초기화 + if 'password_change_step' not in request.session: + request.session['password_change_step'] = 1 + request.session['password_change_code'] = None + request.session['password_change_phone'] = None + request.session['password_change_verified'] = False + + step = request.session.get('password_change_step', 1) + code_sent = request.session.get('password_change_code') is not None + verified = request.session.get('password_change_verified', False) + phone = request.session.get('password_change_phone') + error = None + message = None + + if step == 1: + if request.method == 'POST': + action = request.POST.get('action') + + if action == 'send_code': + form1 = PasswordChangeStep1Form(request.POST, user=request.user) + if form1.is_valid(): + phone = form1.cleaned_data['phone'] + # 인증번호 생성 (실제로는 SMS 발송) + verification_code = str(random.randint(100000, 999999)) + print(f"[DEBUG] 인증번호: {verification_code}") # 실제로는 SMS 발송 + + request.session['password_change_code'] = verification_code + request.session['password_change_phone'] = phone + request.session['password_change_step'] = 1 + message = '인증번호가 발송되었습니다.' + code_sent = True + else: + error = '전화번호를 확인해주세요.' + elif action == 'verify_code': + form1 = PasswordChangeStep1Form(request.POST, user=request.user) + if form1.is_valid(): + input_code = form1.cleaned_data['verification_code'] + stored_code = request.session.get('password_change_code') + + if input_code == stored_code: + request.session['password_change_verified'] = True + request.session['password_change_step'] = 2 + return redirect('accounts:password_change') + else: + error = '인증번호가 일치하지 않습니다.' + else: + error = '인증번호를 확인해주세요.' + else: + form1 = PasswordChangeStep1Form(user=request.user) + + return render(request, 'C_accounts/password_change.html', { + 'step': 1, 'form1': form1, 'code_sent': code_sent, 'error': error, 'message': message + }) + + elif step == 2 and verified and phone: + if request.method == 'POST': + form2 = PasswordChangeStep2Form(request.POST) + if form2.is_valid(): + new_password = form2.cleaned_data['new_password1'] + request.user.set_password(new_password) + request.user.save() + + # 세션 정리 + del request.session['password_change_step'] + del request.session['password_change_code'] + del request.session['password_change_phone'] + del request.session['password_change_verified'] + + messages.success(request, '비밀번호가 성공적으로 변경되었습니다.') + return redirect('accounts:custom_profile_edit') + else: + return render(request, 'C_accounts/password_change.html', { + 'step': 2, 'form2': form2, 'phone': phone + }) + else: + form2 = PasswordChangeStep2Form() + return render(request, 'C_accounts/password_change.html', { + 'step': 2, 'form2': form2, 'phone': phone + }) + + # 기본: 1단계로 초기화 + request.session['password_change_step'] = 1 + request.session['password_change_verified'] = False + return redirect('accounts:password_change') + +# 모드1: 비밀번호 찾기 (로그인하지 않은 상태) +def password_reset(request): + """비밀번호 찾기 뷰""" + # 세션 초기화 + if 'password_reset_step' not in request.session: + request.session['password_reset_step'] = 1 + request.session['password_reset_code'] = None + request.session['password_reset_phone'] = None + request.session['password_reset_verified'] = False + + step = request.session.get('password_reset_step', 1) + code_sent = request.session.get('password_reset_code') is not None + verified = request.session.get('password_reset_verified', False) + phone = request.session.get('password_reset_phone') + error = None + message = None + + if step == 1: + if request.method == 'POST': + action = request.POST.get('action') + + if action == 'send_code': + form1 = PasswordResetStep1Form(request.POST) + if form1.is_valid(): + phone = form1.cleaned_data['phone'] + # 인증번호 생성 (실제로는 SMS 발송) + verification_code = str(random.randint(100000, 999999)) + print(f"[DEBUG] 비밀번호 찾기 인증번호: {verification_code}") # 실제로는 SMS 발송 + + request.session['password_reset_code'] = verification_code + request.session['password_reset_phone'] = phone + request.session['password_reset_step'] = 1 + message = '인증번호가 발송되었습니다.' + code_sent = True + else: + error = '전화번호를 확인해주세요.' + elif action == 'verify_code': + form1 = PasswordResetStep1Form(request.POST) + if form1.is_valid(): + input_code = form1.cleaned_data['verification_code'] + stored_code = request.session.get('password_reset_code') + + if input_code == stored_code: + request.session['password_reset_verified'] = True + request.session['password_reset_step'] = 2 + return redirect('accounts:password_reset') + else: + error = '인증번호가 일치하지 않습니다.' + else: + error = '인증번호를 확인해주세요.' + else: + form1 = PasswordResetStep1Form() + + return render(request, 'C_accounts/password_reset.html', { + 'step': 1, 'form1': form1, 'code_sent': code_sent, 'error': error, 'message': message + }) + + elif step == 2 and verified and phone: + if request.method == 'POST': + form2 = ForcePasswordSetForm(request.POST) + if form2.is_valid(): + new_password = form2.cleaned_data['new_password1'] + # 해당 전화번호의 사용자 찾기 + try: + user = User.objects.get(username=phone) + user.set_password(new_password) + user.save() + + # 세션 정리 + del request.session['password_reset_step'] + del request.session['password_reset_code'] + del request.session['password_reset_phone'] + del request.session['password_reset_verified'] + + messages.success(request, '비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인해주세요.') + return redirect('account_login') + except User.DoesNotExist: + error = '사용자를 찾을 수 없습니다.' + else: + return render(request, 'C_accounts/password_reset.html', { + 'step': 2, 'form2': form2, 'phone': phone + }) + else: + form2 = ForcePasswordSetForm() + return render(request, 'C_accounts/password_reset.html', { + 'step': 2, 'form2': form2, 'phone': phone + }) + + # 기본: 1단계로 초기화 + request.session['password_reset_step'] = 1 + request.session['password_reset_verified'] = False + return redirect('accounts:password_reset') + +# 모드2: 로그인 상태에서 비밀번호 변경 +@login_required +def password_change_logged_in(request): + """로그인 상태에서 비밀번호 변경 뷰""" + if request.method == 'POST': + form = PasswordChangeLoggedInForm(request.POST, user=request.user) + if form.is_valid(): + new_password = form.cleaned_data['new_password1'] + request.user.set_password(new_password) + request.user.save() + + messages.success(request, '비밀번호가 성공적으로 변경되었습니다.') + return redirect('accounts:custom_profile_edit') + else: + form = PasswordChangeLoggedInForm(user=request.user) + + return render(request, 'C_accounts/password_change_logged_in.html', {'form': form}) + +# 모드3: 강제 비밀번호 설정 +@login_required +def force_password_set(request): + """강제 비밀번호 설정 뷰""" + # 현재 사용자의 Person 인스턴스 확인 + try: + person = Person.objects.get(user=request.user) + if not person.비밀번호설정필요: + return redirect('main') + except Person.DoesNotExist: + return redirect('main') + + if request.method == 'POST': + form = ForcePasswordSetForm(request.POST) + if form.is_valid(): + new_password = form.cleaned_data['new_password1'] + request.user.set_password(new_password) + request.user.save() + + # 비밀번호 설정 필요 플래그 해제 + person.비밀번호설정필요 = False + person.save() + + # 로그아웃 처리 + from django.contrib.auth import logout + logout(request) + + # 로그아웃 후 세션에 메시지 저장 (로그인 페이지에서 표시) + request.session['password_set_message'] = '비밀번호가 성공적으로 설정되었습니다. 새 비밀번호로 로그인해주세요.' + + return redirect('account_login') + else: + form = ForcePasswordSetForm() + + return render(request, 'C_accounts/force_password_set.html', {'form': form}) + + diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..f6823ff Binary files /dev/null and b/db.sqlite3 differ diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..9127d0e --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A_core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/media/profile_photos/Bongsu.jpg b/media/profile_photos/Bongsu.jpg new file mode 100644 index 0000000..d646adf Binary files /dev/null and b/media/profile_photos/Bongsu.jpg differ diff --git a/media/profile_photos/bong.jpg b/media/profile_photos/bong.jpg new file mode 100644 index 0000000..b8f6a61 Binary files /dev/null and b/media/profile_photos/bong.jpg differ diff --git a/media/profile_photos/default_user.png b/media/profile_photos/default_user.png new file mode 100644 index 0000000..7127aa8 Binary files /dev/null and b/media/profile_photos/default_user.png differ diff --git a/media/profile_photos/image.png b/media/profile_photos/image.png new file mode 100644 index 0000000..baa5a8e Binary files /dev/null and b/media/profile_photos/image.png differ diff --git a/media/profile_photos/강경옥.png b/media/profile_photos/강경옥.png new file mode 100644 index 0000000..ab51457 Binary files /dev/null and b/media/profile_photos/강경옥.png differ diff --git a/media/profile_photos/강규호.png b/media/profile_photos/강규호.png new file mode 100644 index 0000000..5cf813f Binary files /dev/null and b/media/profile_photos/강규호.png differ diff --git a/media/profile_photos/강동혁.png b/media/profile_photos/강동혁.png new file mode 100644 index 0000000..5760f7d Binary files /dev/null and b/media/profile_photos/강동혁.png differ diff --git a/media/profile_photos/강승구.png b/media/profile_photos/강승구.png new file mode 100644 index 0000000..8410286 Binary files /dev/null and b/media/profile_photos/강승구.png differ diff --git a/media/profile_photos/강지훈.png b/media/profile_photos/강지훈.png new file mode 100644 index 0000000..84c3201 Binary files /dev/null and b/media/profile_photos/강지훈.png differ diff --git a/media/profile_photos/고현숙.png b/media/profile_photos/고현숙.png new file mode 100644 index 0000000..bac179a Binary files /dev/null and b/media/profile_photos/고현숙.png differ diff --git a/media/profile_photos/공한수.png b/media/profile_photos/공한수.png new file mode 100644 index 0000000..294da9b Binary files /dev/null and b/media/profile_photos/공한수.png differ diff --git a/media/profile_photos/곽기융.png b/media/profile_photos/곽기융.png new file mode 100644 index 0000000..aa9ed17 Binary files /dev/null and b/media/profile_photos/곽기융.png differ diff --git a/media/profile_photos/권중천.png b/media/profile_photos/권중천.png new file mode 100644 index 0000000..5585aa2 Binary files /dev/null and b/media/profile_photos/권중천.png differ diff --git a/media/profile_photos/김가현.png b/media/profile_photos/김가현.png new file mode 100644 index 0000000..cbeb638 Binary files /dev/null and b/media/profile_photos/김가현.png differ diff --git a/media/profile_photos/김기재.png b/media/profile_photos/김기재.png new file mode 100644 index 0000000..66ec9a8 Binary files /dev/null and b/media/profile_photos/김기재.png differ diff --git a/media/profile_photos/김기호.png b/media/profile_photos/김기호.png new file mode 100644 index 0000000..36739f4 Binary files /dev/null and b/media/profile_photos/김기호.png differ diff --git a/media/profile_photos/김대성.png b/media/profile_photos/김대성.png new file mode 100644 index 0000000..b69635e Binary files /dev/null and b/media/profile_photos/김대성.png differ diff --git a/media/profile_photos/김동화.png b/media/profile_photos/김동화.png new file mode 100644 index 0000000..0561ee6 Binary files /dev/null and b/media/profile_photos/김동화.png differ diff --git a/media/profile_photos/김미경.png b/media/profile_photos/김미경.png new file mode 100644 index 0000000..2720b73 Binary files /dev/null and b/media/profile_photos/김미경.png differ diff --git a/media/profile_photos/김미애.png b/media/profile_photos/김미애.png new file mode 100644 index 0000000..42ad22c Binary files /dev/null and b/media/profile_photos/김미애.png differ diff --git a/media/profile_photos/김민주.png b/media/profile_photos/김민주.png new file mode 100644 index 0000000..7e7f3bf Binary files /dev/null and b/media/profile_photos/김민주.png differ diff --git a/media/profile_photos/김보성.png b/media/profile_photos/김보성.png new file mode 100644 index 0000000..b47f49a Binary files /dev/null and b/media/profile_photos/김보성.png differ diff --git a/media/profile_photos/김봉수.png b/media/profile_photos/김봉수.png new file mode 100644 index 0000000..ebd3af2 Binary files /dev/null and b/media/profile_photos/김봉수.png differ diff --git a/media/profile_photos/김상우.png b/media/profile_photos/김상우.png new file mode 100644 index 0000000..e4935b0 Binary files /dev/null and b/media/profile_photos/김상우.png differ diff --git a/media/profile_photos/김상준.png b/media/profile_photos/김상준.png new file mode 100644 index 0000000..3f540a1 Binary files /dev/null and b/media/profile_photos/김상준.png differ diff --git a/media/profile_photos/김선이.png b/media/profile_photos/김선이.png new file mode 100644 index 0000000..8fb2ca2 Binary files /dev/null and b/media/profile_photos/김선이.png differ diff --git a/media/profile_photos/김성주.png b/media/profile_photos/김성주.png new file mode 100644 index 0000000..a9a3256 Binary files /dev/null and b/media/profile_photos/김성주.png differ diff --git a/media/profile_photos/김성훈.png b/media/profile_photos/김성훈.png new file mode 100644 index 0000000..cd6f526 Binary files /dev/null and b/media/profile_photos/김성훈.png differ diff --git a/media/profile_photos/김영하.png b/media/profile_photos/김영하.png new file mode 100644 index 0000000..5c2c37a Binary files /dev/null and b/media/profile_photos/김영하.png differ diff --git a/media/profile_photos/김영훈.png b/media/profile_photos/김영훈.png new file mode 100644 index 0000000..3373748 Binary files /dev/null and b/media/profile_photos/김영훈.png differ diff --git a/media/profile_photos/김외숙.png b/media/profile_photos/김외숙.png new file mode 100644 index 0000000..625fd5e Binary files /dev/null and b/media/profile_photos/김외숙.png differ diff --git a/media/profile_photos/김용권.png b/media/profile_photos/김용권.png new file mode 100644 index 0000000..8d93157 Binary files /dev/null and b/media/profile_photos/김용권.png differ diff --git a/media/profile_photos/김윤규.png b/media/profile_photos/김윤규.png new file mode 100644 index 0000000..45a0e45 Binary files /dev/null and b/media/profile_photos/김윤규.png differ diff --git a/media/profile_photos/김윤아.png b/media/profile_photos/김윤아.png new file mode 100644 index 0000000..c3e0f42 Binary files /dev/null and b/media/profile_photos/김윤아.png differ diff --git a/media/profile_photos/김은희.png b/media/profile_photos/김은희.png new file mode 100644 index 0000000..aae956f Binary files /dev/null and b/media/profile_photos/김은희.png differ diff --git a/media/profile_photos/김일곤.png b/media/profile_photos/김일곤.png new file mode 100644 index 0000000..7d9ca96 Binary files /dev/null and b/media/profile_photos/김일곤.png differ diff --git a/media/profile_photos/김재준.png b/media/profile_photos/김재준.png new file mode 100644 index 0000000..e7521c2 Binary files /dev/null and b/media/profile_photos/김재준.png differ diff --git a/media/profile_photos/김재홍.png b/media/profile_photos/김재홍.png new file mode 100644 index 0000000..6482591 Binary files /dev/null and b/media/profile_photos/김재홍.png differ diff --git a/media/profile_photos/김정호.png b/media/profile_photos/김정호.png new file mode 100644 index 0000000..a43bd38 Binary files /dev/null and b/media/profile_photos/김정호.png differ diff --git a/media/profile_photos/김준수.png b/media/profile_photos/김준수.png new file mode 100644 index 0000000..11243f4 Binary files /dev/null and b/media/profile_photos/김준수.png differ diff --git a/media/profile_photos/김중선.png b/media/profile_photos/김중선.png new file mode 100644 index 0000000..f505def Binary files /dev/null and b/media/profile_photos/김중선.png differ diff --git a/media/profile_photos/김진홍.png b/media/profile_photos/김진홍.png new file mode 100644 index 0000000..4fa66c1 Binary files /dev/null and b/media/profile_photos/김진홍.png differ diff --git a/media/profile_photos/김태영.png b/media/profile_photos/김태영.png new file mode 100644 index 0000000..770b67c Binary files /dev/null and b/media/profile_photos/김태영.png differ diff --git a/media/profile_photos/김태형.png b/media/profile_photos/김태형.png new file mode 100644 index 0000000..70cd944 Binary files /dev/null and b/media/profile_photos/김태형.png differ diff --git a/media/profile_photos/김한집.png b/media/profile_photos/김한집.png new file mode 100644 index 0000000..3bbf934 Binary files /dev/null and b/media/profile_photos/김한집.png differ diff --git a/media/profile_photos/김현우.png b/media/profile_photos/김현우.png new file mode 100644 index 0000000..13c7b84 Binary files /dev/null and b/media/profile_photos/김현우.png differ diff --git a/media/profile_photos/김현준.png b/media/profile_photos/김현준.png new file mode 100644 index 0000000..f362792 Binary files /dev/null and b/media/profile_photos/김현준.png differ diff --git a/media/profile_photos/김희경(수정).png b/media/profile_photos/김희경(수정).png new file mode 100644 index 0000000..a2b47bb Binary files /dev/null and b/media/profile_photos/김희경(수정).png differ diff --git a/media/profile_photos/김희경.png b/media/profile_photos/김희경.png new file mode 100644 index 0000000..a3e24b0 Binary files /dev/null and b/media/profile_photos/김희경.png differ diff --git a/media/profile_photos/노현주.png b/media/profile_photos/노현주.png new file mode 100644 index 0000000..5d2e6ab Binary files /dev/null and b/media/profile_photos/노현주.png differ diff --git a/media/profile_photos/노희숙.png b/media/profile_photos/노희숙.png new file mode 100644 index 0000000..0ad3fa7 Binary files /dev/null and b/media/profile_photos/노희숙.png differ diff --git a/media/profile_photos/마점래(수정).png b/media/profile_photos/마점래(수정).png new file mode 100644 index 0000000..57c7062 Binary files /dev/null and b/media/profile_photos/마점래(수정).png differ diff --git a/media/profile_photos/마점래.png b/media/profile_photos/마점래.png new file mode 100644 index 0000000..f329407 Binary files /dev/null and b/media/profile_photos/마점래.png differ diff --git a/media/profile_photos/문성배.png b/media/profile_photos/문성배.png new file mode 100644 index 0000000..4b0d0ed Binary files /dev/null and b/media/profile_photos/문성배.png differ diff --git a/media/profile_photos/문정순.png b/media/profile_photos/문정순.png new file mode 100644 index 0000000..109178f Binary files /dev/null and b/media/profile_photos/문정순.png differ diff --git a/media/profile_photos/민수연.png b/media/profile_photos/민수연.png new file mode 100644 index 0000000..1bce053 Binary files /dev/null and b/media/profile_photos/민수연.png differ diff --git a/media/profile_photos/민홍기.png b/media/profile_photos/민홍기.png new file mode 100644 index 0000000..9a79eb2 Binary files /dev/null and b/media/profile_photos/민홍기.png differ diff --git a/media/profile_photos/박강범.png b/media/profile_photos/박강범.png new file mode 100644 index 0000000..7e08df4 Binary files /dev/null and b/media/profile_photos/박강범.png differ diff --git a/media/profile_photos/박경민.png b/media/profile_photos/박경민.png new file mode 100644 index 0000000..f8f377e Binary files /dev/null and b/media/profile_photos/박경민.png differ diff --git a/media/profile_photos/박국제.png b/media/profile_photos/박국제.png new file mode 100644 index 0000000..0167fec Binary files /dev/null and b/media/profile_photos/박국제.png differ diff --git a/media/profile_photos/박대진.png b/media/profile_photos/박대진.png new file mode 100644 index 0000000..b2c14e3 Binary files /dev/null and b/media/profile_photos/박대진.png differ diff --git a/media/profile_photos/박명숙.png b/media/profile_photos/박명숙.png new file mode 100644 index 0000000..4d29c65 Binary files /dev/null and b/media/profile_photos/박명숙.png differ diff --git a/media/profile_photos/박명옥.png b/media/profile_photos/박명옥.png new file mode 100644 index 0000000..1fcfa01 Binary files /dev/null and b/media/profile_photos/박명옥.png differ diff --git a/media/profile_photos/박명진.png b/media/profile_photos/박명진.png new file mode 100644 index 0000000..f62c5cf Binary files /dev/null and b/media/profile_photos/박명진.png differ diff --git a/media/profile_photos/박민희.png b/media/profile_photos/박민희.png new file mode 100644 index 0000000..1e47419 Binary files /dev/null and b/media/profile_photos/박민희.png differ diff --git a/media/profile_photos/박부술.png b/media/profile_photos/박부술.png new file mode 100644 index 0000000..16dd1fe Binary files /dev/null and b/media/profile_photos/박부술.png differ diff --git a/media/profile_photos/박성호.png b/media/profile_photos/박성호.png new file mode 100644 index 0000000..ff5ecb3 Binary files /dev/null and b/media/profile_photos/박성호.png differ diff --git a/media/profile_photos/박성훈.png b/media/profile_photos/박성훈.png new file mode 100644 index 0000000..5f5fa00 Binary files /dev/null and b/media/profile_photos/박성훈.png differ diff --git a/media/profile_photos/박순자.png b/media/profile_photos/박순자.png new file mode 100644 index 0000000..736f81b Binary files /dev/null and b/media/profile_photos/박순자.png differ diff --git a/media/profile_photos/박영우.png b/media/profile_photos/박영우.png new file mode 100644 index 0000000..0060211 Binary files /dev/null and b/media/profile_photos/박영우.png differ diff --git a/media/profile_photos/박영해.png b/media/profile_photos/박영해.png new file mode 100644 index 0000000..925026e Binary files /dev/null and b/media/profile_photos/박영해.png differ diff --git a/media/profile_photos/박재성.png b/media/profile_photos/박재성.png new file mode 100644 index 0000000..b8e82c5 Binary files /dev/null and b/media/profile_photos/박재성.png differ diff --git a/media/profile_photos/박정숙.png b/media/profile_photos/박정숙.png new file mode 100644 index 0000000..c283099 Binary files /dev/null and b/media/profile_photos/박정숙.png differ diff --git a/media/profile_photos/박정은.png b/media/profile_photos/박정은.png new file mode 100644 index 0000000..1d59b1a Binary files /dev/null and b/media/profile_photos/박정은.png differ diff --git a/media/profile_photos/박종인.png b/media/profile_photos/박종인.png new file mode 100644 index 0000000..67beadb Binary files /dev/null and b/media/profile_photos/박종인.png differ diff --git a/media/profile_photos/박주원(수정).png b/media/profile_photos/박주원(수정).png new file mode 100644 index 0000000..a7eb814 Binary files /dev/null and b/media/profile_photos/박주원(수정).png differ diff --git a/media/profile_photos/박주원.png b/media/profile_photos/박주원.png new file mode 100644 index 0000000..7332285 Binary files /dev/null and b/media/profile_photos/박주원.png differ diff --git a/media/profile_photos/박지환.png b/media/profile_photos/박지환.png new file mode 100644 index 0000000..41abe6f Binary files /dev/null and b/media/profile_photos/박지환.png differ diff --git a/media/profile_photos/배기중.png b/media/profile_photos/배기중.png new file mode 100644 index 0000000..b4306e3 Binary files /dev/null and b/media/profile_photos/배기중.png differ diff --git a/media/profile_photos/배범한.png b/media/profile_photos/배범한.png new file mode 100644 index 0000000..93b9515 Binary files /dev/null and b/media/profile_photos/배범한.png differ diff --git a/media/profile_photos/배성효.png b/media/profile_photos/배성효.png new file mode 100644 index 0000000..c8af8d0 Binary files /dev/null and b/media/profile_photos/배성효.png differ diff --git a/media/profile_photos/변연옥.png b/media/profile_photos/변연옥.png new file mode 100644 index 0000000..684f079 Binary files /dev/null and b/media/profile_photos/변연옥.png differ diff --git a/media/profile_photos/빈윤진.png b/media/profile_photos/빈윤진.png new file mode 100644 index 0000000..ed8c060 Binary files /dev/null and b/media/profile_photos/빈윤진.png differ diff --git a/media/profile_photos/서강섭.png b/media/profile_photos/서강섭.png new file mode 100644 index 0000000..b5cb681 Binary files /dev/null and b/media/profile_photos/서강섭.png differ diff --git a/media/profile_photos/서지윤.png b/media/profile_photos/서지윤.png new file mode 100644 index 0000000..d46dddb Binary files /dev/null and b/media/profile_photos/서지윤.png differ diff --git a/media/profile_photos/성동화.png b/media/profile_photos/성동화.png new file mode 100644 index 0000000..c441457 Binary files /dev/null and b/media/profile_photos/성동화.png differ diff --git a/media/profile_photos/성충식.png b/media/profile_photos/성충식.png new file mode 100644 index 0000000..b8b0724 Binary files /dev/null and b/media/profile_photos/성충식.png differ diff --git a/media/profile_photos/손동현.png b/media/profile_photos/손동현.png new file mode 100644 index 0000000..c25e365 Binary files /dev/null and b/media/profile_photos/손동현.png differ diff --git a/media/profile_photos/송연익.png b/media/profile_photos/송연익.png new file mode 100644 index 0000000..898a6ff Binary files /dev/null and b/media/profile_photos/송연익.png differ diff --git a/media/profile_photos/심수현.png b/media/profile_photos/심수현.png new file mode 100644 index 0000000..6537589 Binary files /dev/null and b/media/profile_photos/심수현.png differ diff --git a/media/profile_photos/안복지.png b/media/profile_photos/안복지.png new file mode 100644 index 0000000..e113457 Binary files /dev/null and b/media/profile_photos/안복지.png differ diff --git a/media/profile_photos/안상배.png b/media/profile_photos/안상배.png new file mode 100644 index 0000000..ae1c0fe Binary files /dev/null and b/media/profile_photos/안상배.png differ diff --git a/media/profile_photos/안영봉.png b/media/profile_photos/안영봉.png new file mode 100644 index 0000000..200e180 Binary files /dev/null and b/media/profile_photos/안영봉.png differ diff --git a/media/profile_photos/양재진.png b/media/profile_photos/양재진.png new file mode 100644 index 0000000..a1773d2 Binary files /dev/null and b/media/profile_photos/양재진.png differ diff --git a/media/profile_photos/어익수.png b/media/profile_photos/어익수.png new file mode 100644 index 0000000..b28e582 Binary files /dev/null and b/media/profile_photos/어익수.png differ diff --git a/media/profile_photos/엄신아.png b/media/profile_photos/엄신아.png new file mode 100644 index 0000000..5d8df64 Binary files /dev/null and b/media/profile_photos/엄신아.png differ diff --git a/media/profile_photos/여은주.png b/media/profile_photos/여은주.png new file mode 100644 index 0000000..ced3f82 Binary files /dev/null and b/media/profile_photos/여은주.png differ diff --git a/media/profile_photos/예영숙.png b/media/profile_photos/예영숙.png new file mode 100644 index 0000000..1ece167 Binary files /dev/null and b/media/profile_photos/예영숙.png differ diff --git a/media/profile_photos/오용택.png b/media/profile_photos/오용택.png new file mode 100644 index 0000000..a9da755 Binary files /dev/null and b/media/profile_photos/오용택.png differ diff --git a/media/profile_photos/오원재.png b/media/profile_photos/오원재.png new file mode 100644 index 0000000..2dddf95 Binary files /dev/null and b/media/profile_photos/오원재.png differ diff --git a/media/profile_photos/유석찬.png b/media/profile_photos/유석찬.png new file mode 100644 index 0000000..f83416c Binary files /dev/null and b/media/profile_photos/유석찬.png differ diff --git a/media/profile_photos/이기영.png b/media/profile_photos/이기영.png new file mode 100644 index 0000000..886cc36 Binary files /dev/null and b/media/profile_photos/이기영.png differ diff --git a/media/profile_photos/이대명.png b/media/profile_photos/이대명.png new file mode 100644 index 0000000..0f7b440 Binary files /dev/null and b/media/profile_photos/이대명.png differ diff --git a/media/profile_photos/이두홍.png b/media/profile_photos/이두홍.png new file mode 100644 index 0000000..4560083 Binary files /dev/null and b/media/profile_photos/이두홍.png differ diff --git a/media/profile_photos/이범민.png b/media/profile_photos/이범민.png new file mode 100644 index 0000000..27bb875 Binary files /dev/null and b/media/profile_photos/이범민.png differ diff --git a/media/profile_photos/이상경.png b/media/profile_photos/이상경.png new file mode 100644 index 0000000..fd6d301 Binary files /dev/null and b/media/profile_photos/이상경.png differ diff --git a/media/profile_photos/이상규.png b/media/profile_photos/이상규.png new file mode 100644 index 0000000..85404b3 Binary files /dev/null and b/media/profile_photos/이상규.png differ diff --git a/media/profile_photos/이상민.png b/media/profile_photos/이상민.png new file mode 100644 index 0000000..a90bac4 Binary files /dev/null and b/media/profile_photos/이상민.png differ diff --git a/media/profile_photos/이수연.png b/media/profile_photos/이수연.png new file mode 100644 index 0000000..4d7decb Binary files /dev/null and b/media/profile_photos/이수연.png differ diff --git a/media/profile_photos/이순옥.png b/media/profile_photos/이순옥.png new file mode 100644 index 0000000..38441ec Binary files /dev/null and b/media/profile_photos/이순옥.png differ diff --git a/media/profile_photos/이승규.png b/media/profile_photos/이승규.png new file mode 100644 index 0000000..e839ab7 Binary files /dev/null and b/media/profile_photos/이승규.png differ diff --git a/media/profile_photos/이영희.png b/media/profile_photos/이영희.png new file mode 100644 index 0000000..17eb685 Binary files /dev/null and b/media/profile_photos/이영희.png differ diff --git a/media/profile_photos/이인숙.png b/media/profile_photos/이인숙.png new file mode 100644 index 0000000..8b33a9e Binary files /dev/null and b/media/profile_photos/이인숙.png differ diff --git a/media/profile_photos/이정문.png b/media/profile_photos/이정문.png new file mode 100644 index 0000000..b3c3894 Binary files /dev/null and b/media/profile_photos/이정문.png differ diff --git a/media/profile_photos/이주영.png b/media/profile_photos/이주영.png new file mode 100644 index 0000000..dce70b2 Binary files /dev/null and b/media/profile_photos/이주영.png differ diff --git a/media/profile_photos/이학민.png b/media/profile_photos/이학민.png new file mode 100644 index 0000000..884b6ca Binary files /dev/null and b/media/profile_photos/이학민.png differ diff --git a/media/profile_photos/이향숙.png b/media/profile_photos/이향숙.png new file mode 100644 index 0000000..c7abd1b Binary files /dev/null and b/media/profile_photos/이향숙.png differ diff --git a/media/profile_photos/이현수.png b/media/profile_photos/이현수.png new file mode 100644 index 0000000..c28699a Binary files /dev/null and b/media/profile_photos/이현수.png differ diff --git a/media/profile_photos/이현우.png b/media/profile_photos/이현우.png new file mode 100644 index 0000000..223553b Binary files /dev/null and b/media/profile_photos/이현우.png differ diff --git a/media/profile_photos/이현욱.png b/media/profile_photos/이현욱.png new file mode 100644 index 0000000..ed5e425 Binary files /dev/null and b/media/profile_photos/이현욱.png differ diff --git a/media/profile_photos/이현정.png b/media/profile_photos/이현정.png new file mode 100644 index 0000000..aeb5c7e Binary files /dev/null and b/media/profile_photos/이현정.png differ diff --git a/media/profile_photos/이화경.png b/media/profile_photos/이화경.png new file mode 100644 index 0000000..1905db2 Binary files /dev/null and b/media/profile_photos/이화경.png differ diff --git a/media/profile_photos/이효영.png b/media/profile_photos/이효영.png new file mode 100644 index 0000000..2e44c3d Binary files /dev/null and b/media/profile_photos/이효영.png differ diff --git a/media/profile_photos/이희태.png b/media/profile_photos/이희태.png new file mode 100644 index 0000000..6cd61d0 Binary files /dev/null and b/media/profile_photos/이희태.png differ diff --git a/media/profile_photos/임문수.png b/media/profile_photos/임문수.png new file mode 100644 index 0000000..c89dfd0 Binary files /dev/null and b/media/profile_photos/임문수.png differ diff --git a/media/profile_photos/임영민.png b/media/profile_photos/임영민.png new file mode 100644 index 0000000..cf87075 Binary files /dev/null and b/media/profile_photos/임영민.png differ diff --git a/media/profile_photos/임윤택.png b/media/profile_photos/임윤택.png new file mode 100644 index 0000000..979716c Binary files /dev/null and b/media/profile_photos/임윤택.png differ diff --git a/media/profile_photos/임창섭(수정).png b/media/profile_photos/임창섭(수정).png new file mode 100644 index 0000000..eefc90d Binary files /dev/null and b/media/profile_photos/임창섭(수정).png differ diff --git a/media/profile_photos/임창섭.png b/media/profile_photos/임창섭.png new file mode 100644 index 0000000..65baea9 Binary files /dev/null and b/media/profile_photos/임창섭.png differ diff --git a/media/profile_photos/장은화.png b/media/profile_photos/장은화.png new file mode 100644 index 0000000..9090018 Binary files /dev/null and b/media/profile_photos/장은화.png differ diff --git a/media/profile_photos/장지훈.png b/media/profile_photos/장지훈.png new file mode 100644 index 0000000..16d4839 Binary files /dev/null and b/media/profile_photos/장지훈.png differ diff --git a/media/profile_photos/장현정.png b/media/profile_photos/장현정.png new file mode 100644 index 0000000..2beac0f Binary files /dev/null and b/media/profile_photos/장현정.png differ diff --git a/media/profile_photos/전미영.png b/media/profile_photos/전미영.png new file mode 100644 index 0000000..ac28dd2 Binary files /dev/null and b/media/profile_photos/전미영.png differ diff --git a/media/profile_photos/전병웅(수정).png b/media/profile_photos/전병웅(수정).png new file mode 100644 index 0000000..bcb465e Binary files /dev/null and b/media/profile_photos/전병웅(수정).png differ diff --git a/media/profile_photos/전병웅.png b/media/profile_photos/전병웅.png new file mode 100644 index 0000000..85d03e7 Binary files /dev/null and b/media/profile_photos/전병웅.png differ diff --git a/media/profile_photos/전성훈.png b/media/profile_photos/전성훈.png new file mode 100644 index 0000000..ec4b2d4 Binary files /dev/null and b/media/profile_photos/전성훈.png differ diff --git a/media/profile_photos/전종태.png b/media/profile_photos/전종태.png new file mode 100644 index 0000000..946b34e Binary files /dev/null and b/media/profile_photos/전종태.png differ diff --git a/media/profile_photos/전희충.png b/media/profile_photos/전희충.png new file mode 100644 index 0000000..ea27cb0 Binary files /dev/null and b/media/profile_photos/전희충.png differ diff --git a/media/profile_photos/정석민.png b/media/profile_photos/정석민.png new file mode 100644 index 0000000..d8fade8 Binary files /dev/null and b/media/profile_photos/정석민.png differ diff --git a/media/profile_photos/정용표.png b/media/profile_photos/정용표.png new file mode 100644 index 0000000..e095ad8 Binary files /dev/null and b/media/profile_photos/정용표.png differ diff --git a/media/profile_photos/정윤목.png b/media/profile_photos/정윤목.png new file mode 100644 index 0000000..f5f07cd Binary files /dev/null and b/media/profile_photos/정윤목.png differ diff --git a/media/profile_photos/정의석.png b/media/profile_photos/정의석.png new file mode 100644 index 0000000..df4535f Binary files /dev/null and b/media/profile_photos/정의석.png differ diff --git a/media/profile_photos/정종복.png b/media/profile_photos/정종복.png new file mode 100644 index 0000000..ac72127 Binary files /dev/null and b/media/profile_photos/정종복.png differ diff --git a/media/profile_photos/정형재.png b/media/profile_photos/정형재.png new file mode 100644 index 0000000..a91b277 Binary files /dev/null and b/media/profile_photos/정형재.png differ diff --git a/media/profile_photos/제권진.png b/media/profile_photos/제권진.png new file mode 100644 index 0000000..936e6e6 Binary files /dev/null and b/media/profile_photos/제권진.png differ diff --git a/media/profile_photos/제오수(수정).png b/media/profile_photos/제오수(수정).png new file mode 100644 index 0000000..86dccff Binary files /dev/null and b/media/profile_photos/제오수(수정).png differ diff --git a/media/profile_photos/제오수.png b/media/profile_photos/제오수.png new file mode 100644 index 0000000..013c0cf Binary files /dev/null and b/media/profile_photos/제오수.png differ diff --git a/media/profile_photos/제일호.png b/media/profile_photos/제일호.png new file mode 100644 index 0000000..5fa5a9b Binary files /dev/null and b/media/profile_photos/제일호.png differ diff --git a/media/profile_photos/조승민.png b/media/profile_photos/조승민.png new file mode 100644 index 0000000..25c66a3 Binary files /dev/null and b/media/profile_photos/조승민.png differ diff --git a/media/profile_photos/조엘리사.png b/media/profile_photos/조엘리사.png new file mode 100644 index 0000000..41f4e5c Binary files /dev/null and b/media/profile_photos/조엘리사.png differ diff --git a/media/profile_photos/조정래.png b/media/profile_photos/조정래.png new file mode 100644 index 0000000..d33c7e8 Binary files /dev/null and b/media/profile_photos/조정래.png differ diff --git a/media/profile_photos/주진우.png b/media/profile_photos/주진우.png new file mode 100644 index 0000000..38fcd59 Binary files /dev/null and b/media/profile_photos/주진우.png differ diff --git a/media/profile_photos/주효정.png b/media/profile_photos/주효정.png new file mode 100644 index 0000000..dd9bb87 Binary files /dev/null and b/media/profile_photos/주효정.png differ diff --git a/media/profile_photos/진서윤.png b/media/profile_photos/진서윤.png new file mode 100644 index 0000000..92a31cc Binary files /dev/null and b/media/profile_photos/진서윤.png differ diff --git a/media/profile_photos/진종규.png b/media/profile_photos/진종규.png new file mode 100644 index 0000000..e9541bb Binary files /dev/null and b/media/profile_photos/진종규.png differ diff --git a/media/profile_photos/최대경.png b/media/profile_photos/최대경.png new file mode 100644 index 0000000..541bde2 Binary files /dev/null and b/media/profile_photos/최대경.png differ diff --git a/media/profile_photos/최두원.png b/media/profile_photos/최두원.png new file mode 100644 index 0000000..85d831b Binary files /dev/null and b/media/profile_photos/최두원.png differ diff --git a/media/profile_photos/최승자.png b/media/profile_photos/최승자.png new file mode 100644 index 0000000..d980607 Binary files /dev/null and b/media/profile_photos/최승자.png differ diff --git a/media/profile_photos/최유심.png b/media/profile_photos/최유심.png new file mode 100644 index 0000000..35ddb3f Binary files /dev/null and b/media/profile_photos/최유심.png differ diff --git a/media/profile_photos/최정현(수정).png b/media/profile_photos/최정현(수정).png new file mode 100644 index 0000000..ebe9279 Binary files /dev/null and b/media/profile_photos/최정현(수정).png differ diff --git a/media/profile_photos/최정현.png b/media/profile_photos/최정현.png new file mode 100644 index 0000000..afe93af Binary files /dev/null and b/media/profile_photos/최정현.png differ diff --git a/media/profile_photos/최준익.png b/media/profile_photos/최준익.png new file mode 100644 index 0000000..ec20169 Binary files /dev/null and b/media/profile_photos/최준익.png differ diff --git a/media/profile_photos/최진봉.png b/media/profile_photos/최진봉.png new file mode 100644 index 0000000..029c170 Binary files /dev/null and b/media/profile_photos/최진봉.png differ diff --git a/media/profile_photos/하익수.png b/media/profile_photos/하익수.png new file mode 100644 index 0000000..9158e67 Binary files /dev/null and b/media/profile_photos/하익수.png differ diff --git a/media/profile_photos/한윤철.png b/media/profile_photos/한윤철.png new file mode 100644 index 0000000..a5358c6 Binary files /dev/null and b/media/profile_photos/한윤철.png differ diff --git a/media/profile_photos/허남식.png b/media/profile_photos/허남식.png new file mode 100644 index 0000000..8cdc232 Binary files /dev/null and b/media/profile_photos/허남식.png differ diff --git a/media/profile_photos/허성우.png b/media/profile_photos/허성우.png new file mode 100644 index 0000000..e57fe35 Binary files /dev/null and b/media/profile_photos/허성우.png differ diff --git a/media/profile_photos/현광열.png b/media/profile_photos/현광열.png new file mode 100644 index 0000000..9a91928 Binary files /dev/null and b/media/profile_photos/현광열.png differ diff --git a/media/profile_photos/황미영.png b/media/profile_photos/황미영.png new file mode 100644 index 0000000..25d8a86 Binary files /dev/null and b/media/profile_photos/황미영.png differ diff --git a/media/profile_photos/황순민.png b/media/profile_photos/황순민.png new file mode 100644 index 0000000..523571a Binary files /dev/null and b/media/profile_photos/황순민.png differ diff --git a/media/profile_photos/황윤미.png b/media/profile_photos/황윤미.png new file mode 100644 index 0000000..d005919 Binary files /dev/null and b/media/profile_photos/황윤미.png differ diff --git a/media/profile_photos/황진순.png b/media/profile_photos/황진순.png new file mode 100644 index 0000000..dcd0093 Binary files /dev/null and b/media/profile_photos/황진순.png differ diff --git a/media/profile_photos/황태욱.png b/media/profile_photos/황태욱.png new file mode 100644 index 0000000..cffeeec Binary files /dev/null and b/media/profile_photos/황태욱.png differ diff --git a/media/profile_photos/황하섭.png b/media/profile_photos/황하섭.png new file mode 100644 index 0000000..666f527 Binary files /dev/null and b/media/profile_photos/황하섭.png differ diff --git a/media/profile_photos/황현숙.png b/media/profile_photos/황현숙.png new file mode 100644 index 0000000..5102602 Binary files /dev/null and b/media/profile_photos/황현숙.png differ diff --git a/media/profile_photos/황현종.png b/media/profile_photos/황현종.png new file mode 100644 index 0000000..0951be7 Binary files /dev/null and b/media/profile_photos/황현종.png differ diff --git a/static/admin/css/custom_admin.css b/static/admin/css/custom_admin.css new file mode 100644 index 0000000..742318a --- /dev/null +++ b/static/admin/css/custom_admin.css @@ -0,0 +1,18 @@ +/* "Currently:" 텍스트 숨기기 */ +.field-사진 .help { + display: none !important; +} + +/* 사진 필드 스타일링 */ +.field-사진 input[type="file"] { + border: 1px solid #ccc; + padding: 8px; + border-radius: 4px; + background-color: #f9f9f9; +} + +/* 사진 미리보기 스타일링 */ +.field-사진미리보기 img { + border: 1px solid #ddd; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} \ No newline at end of file diff --git a/staticfiles/account/js/account.js b/staticfiles/account/js/account.js new file mode 100644 index 0000000..fbc5135 --- /dev/null +++ b/staticfiles/account/js/account.js @@ -0,0 +1,20 @@ +(function () { + const allauth = window.allauth = window.allauth || {} + + function manageEmailForm (o) { + const actions = document.getElementsByName('action_remove') + if (actions.length) { + actions[0].addEventListener('click', function (e) { + if (!window.confirm(o.i18n.confirmDelete)) { + e.preventDefault() + } + }) + } + } + + allauth.account = { + forms: { + manageEmailForm + } + } +})() diff --git a/staticfiles/account/js/onload.js b/staticfiles/account/js/onload.js new file mode 100644 index 0000000..1a224c9 --- /dev/null +++ b/staticfiles/account/js/onload.js @@ -0,0 +1,12 @@ +(function () { + document.addEventListener('DOMContentLoaded', function () { + Array.from(document.querySelectorAll('script[data-allauth-onload]')).forEach(scriptElt => { + const funcRef = scriptElt.dataset.allauthOnload + if (typeof funcRef === 'string' && funcRef.startsWith('allauth.')) { + const funcArg = JSON.parse(scriptElt.textContent) + const func = funcRef.split('.').reduce((acc, part) => acc && acc[part], window) + func(funcArg) + } + }) + }) +})() diff --git a/staticfiles/admin/css/autocomplete.css b/staticfiles/admin/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/staticfiles/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/staticfiles/admin/css/base.css b/staticfiles/admin/css/base.css new file mode 100644 index 0000000..93db7d0 --- /dev/null +++ b/staticfiles/admin/css/base.css @@ -0,0 +1,1145 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +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); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/staticfiles/admin/css/changelists.css b/staticfiles/admin/css/changelists.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/staticfiles/admin/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/staticfiles/admin/css/custom_admin.css b/staticfiles/admin/css/custom_admin.css new file mode 100644 index 0000000..742318a --- /dev/null +++ b/staticfiles/admin/css/custom_admin.css @@ -0,0 +1,18 @@ +/* "Currently:" 텍스트 숨기기 */ +.field-사진 .help { + display: none !important; +} + +/* 사진 필드 스타일링 */ +.field-사진 input[type="file"] { + border: 1px solid #ccc; + padding: 8px; + border-radius: 4px; + background-color: #f9f9f9; +} + +/* 사진 미리보기 스타일링 */ +.field-사진미리보기 img { + border: 1px solid #ddd; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} \ No newline at end of file diff --git a/staticfiles/admin/css/dark_mode.css b/staticfiles/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/staticfiles/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/staticfiles/admin/css/dashboard.css b/staticfiles/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/staticfiles/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/staticfiles/admin/css/forms.css b/staticfiles/admin/css/forms.css new file mode 100644 index 0000000..9a8dad0 --- /dev/null +++ b/staticfiles/admin/css/forms.css @@ -0,0 +1,534 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/staticfiles/admin/css/login.css b/staticfiles/admin/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/staticfiles/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/staticfiles/admin/css/nav_sidebar.css b/staticfiles/admin/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/staticfiles/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/staticfiles/admin/css/responsive.css b/staticfiles/admin/css/responsive.css new file mode 100644 index 0000000..1d0a188 --- /dev/null +++ b/staticfiles/admin/css/responsive.css @@ -0,0 +1,999 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/staticfiles/admin/css/responsive_rtl.css b/staticfiles/admin/css/responsive_rtl.css new file mode 100644 index 0000000..31dc8ff --- /dev/null +++ b/staticfiles/admin/css/responsive_rtl.css @@ -0,0 +1,84 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } +} diff --git a/staticfiles/admin/css/rtl.css b/staticfiles/admin/css/rtl.css new file mode 100644 index 0000000..c349a93 --- /dev/null +++ b/staticfiles/admin/css/rtl.css @@ -0,0 +1,298 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md b/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/staticfiles/admin/css/vendor/select2/select2.css b/staticfiles/admin/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/staticfiles/admin/css/vendor/select2/select2.min.css b/staticfiles/admin/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/staticfiles/admin/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/staticfiles/admin/css/widgets.css b/staticfiles/admin/css/widgets.css new file mode 100644 index 0000000..1104e8b --- /dev/null +++ b/staticfiles/admin/css/widgets.css @@ -0,0 +1,604 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/staticfiles/admin/img/LICENSE b/staticfiles/admin/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/staticfiles/admin/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/staticfiles/admin/img/README.txt b/staticfiles/admin/img/README.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/staticfiles/admin/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/staticfiles/admin/img/calendar-icons.svg b/staticfiles/admin/img/calendar-icons.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/staticfiles/admin/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/gis/move_vertex_off.svg b/staticfiles/admin/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/staticfiles/admin/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/staticfiles/admin/img/gis/move_vertex_on.svg b/staticfiles/admin/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/staticfiles/admin/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/staticfiles/admin/img/icon-addlink.svg b/staticfiles/admin/img/icon-addlink.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/staticfiles/admin/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-alert.svg b/staticfiles/admin/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/staticfiles/admin/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-calendar.svg b/staticfiles/admin/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/staticfiles/admin/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/staticfiles/admin/img/icon-changelink.svg b/staticfiles/admin/img/icon-changelink.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/staticfiles/admin/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-clock.svg b/staticfiles/admin/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/staticfiles/admin/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/staticfiles/admin/img/icon-deletelink.svg b/staticfiles/admin/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/staticfiles/admin/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-no.svg b/staticfiles/admin/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/staticfiles/admin/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-unknown-alt.svg b/staticfiles/admin/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/staticfiles/admin/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-unknown.svg b/staticfiles/admin/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/staticfiles/admin/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-viewlink.svg b/staticfiles/admin/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/staticfiles/admin/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/icon-yes.svg b/staticfiles/admin/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/staticfiles/admin/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/inline-delete.svg b/staticfiles/admin/img/inline-delete.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/staticfiles/admin/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/search.svg b/staticfiles/admin/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/staticfiles/admin/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/selector-icons.svg b/staticfiles/admin/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/staticfiles/admin/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/sorting-icons.svg b/staticfiles/admin/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/staticfiles/admin/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/staticfiles/admin/img/tooltag-add.svg b/staticfiles/admin/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/staticfiles/admin/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/img/tooltag-arrowright.svg b/staticfiles/admin/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/staticfiles/admin/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/staticfiles/admin/js/SelectBox.js b/staticfiles/admin/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/staticfiles/admin/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/staticfiles/admin/js/SelectFilter2.js b/staticfiles/admin/js/SelectFilter2.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/staticfiles/admin/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

, because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

or
+ const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
+ const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
    + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
    + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/staticfiles/admin/js/actions.js b/staticfiles/admin/js/actions.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/staticfiles/admin/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/staticfiles/admin/js/admin/DateTimeShortcuts.js b/staticfiles/admin/js/admin/DateTimeShortcuts.js new file mode 100644 index 0000000..aa1cae9 --- /dev/null +++ b/staticfiles/admin/js/admin/DateTimeShortcuts.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled + calendarDivName2: 'calendarin', // name of
    that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
    that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
    + //

    Choose a time

    + // + //

    Cancel

    + //
    + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
    + //

    + // + // February 2003 + //

    + //
    + // + //
    + //
    + // Yesterday | Today | Tomorrow + //
    + //

    Cancel

    + //
    + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/staticfiles/admin/js/admin/RelatedObjectLookups.js b/staticfiles/admin/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/staticfiles/admin/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/staticfiles/admin/js/autocomplete.js b/staticfiles/admin/js/autocomplete.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/staticfiles/admin/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/staticfiles/admin/js/calendar.js b/staticfiles/admin/js/calendar.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/staticfiles/admin/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/staticfiles/admin/js/cancel.js b/staticfiles/admin/js/cancel.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/staticfiles/admin/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/staticfiles/admin/js/change_form.js b/staticfiles/admin/js/change_form.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/staticfiles/admin/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/staticfiles/admin/js/collapse.js b/staticfiles/admin/js/collapse.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/staticfiles/admin/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/staticfiles/admin/js/core.js b/staticfiles/admin/js/core.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/staticfiles/admin/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/staticfiles/admin/js/filters.js b/staticfiles/admin/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/staticfiles/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/staticfiles/admin/js/inlines.js b/staticfiles/admin/js/inlines.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/staticfiles/admin/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + options.deleteText + "
  • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/staticfiles/admin/js/jquery.init.js b/staticfiles/admin/js/jquery.init.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/staticfiles/admin/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/staticfiles/admin/js/nav_sidebar.js b/staticfiles/admin/js/nav_sidebar.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/staticfiles/admin/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/staticfiles/admin/js/popup_response.js b/staticfiles/admin/js/popup_response.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/staticfiles/admin/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/staticfiles/admin/js/prepopulate.js b/staticfiles/admin/js/prepopulate.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/staticfiles/admin/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/staticfiles/admin/js/prepopulate_init.js b/staticfiles/admin/js/prepopulate_init.js new file mode 100644 index 0000000..a58841f --- /dev/null +++ b/staticfiles/admin/js/prepopulate_init.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/staticfiles/admin/js/theme.js b/staticfiles/admin/js/theme.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/staticfiles/admin/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/staticfiles/admin/js/urlify.js b/staticfiles/admin/js/urlify.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/staticfiles/admin/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/staticfiles/admin/js/vendor/jquery/LICENSE.txt b/staticfiles/admin/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/staticfiles/admin/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/staticfiles/admin/js/vendor/jquery/jquery.js b/staticfiles/admin/js/vendor/jquery/jquery.js new file mode 100644 index 0000000..7f35c11 --- /dev/null +++ b/staticfiles/admin/js/vendor/jquery/jquery.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `