convert to gitea

This commit is contained in:
2025-09-15 13:33:34 +09:00
commit 95882ac072
277 changed files with 46023 additions and 0 deletions

View File

16
apps/web/config/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for config 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/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

298
apps/web/config/settings.py Normal file
View File

@ -0,0 +1,298 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
import logging.handlers # 로테이팅 파일 핸들러 임포트
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent # 프로젝트 설정 폴더(config)의 부모, 즉 /data/gyber/apps/web/
# ==============================================================================
# 핵심 보안 설정 (프로덕션 환경)
# ==============================================================================
# SECRET_KEY: 환경 변수 'DJANGO_SECRET_KEY' 에서 로드. 프로덕션에서는 반드시 설정해야 함.
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
# 개발 환경에서는 임시 키를 사용할 수 있지만, 프로덕션에서는 에러 발생시킴
if os.environ.get('DJANGO_DEVELOPMENT_MODE') == 'True': # 개발 모드 식별용 환경 변수 (선택적)
SECRET_KEY = 'django-insecure-temporary-dev-key-for-gyber'
print("WARNING: Using a temporary SECRET_KEY for development. Set DJANGO_SECRET_KEY in production.")
else:
raise ValueError("프로덕션 환경에서는 DJANGO_SECRET_KEY 환경 변수를 반드시 설정해야 합니다.")
# DEBUG 모드: 프로덕션이므로 False 로 고정 (필수!)
DEBUG = False
# 허용 호스트: 외부 접속 도메인 및 필요한 내부 IP 명시
ALLOWED_HOSTS = [
'gyber.oneunivrs.com', # 실제 서비스 도메인
'192.168.100.10', # 내부 테스트/접근용 IP (필요시)
# 'localhost', '127.0.0.1' # 로컬 테스트용 (프로덕션에서는 제거 고려)
]
# 환경 변수에서 추가 호스트 로드 (선택적)
# additional_hosts = os.environ.get('DJANGO_ADDITIONAL_ALLOWED_HOSTS')
# if additional_hosts:
# ALLOWED_HOSTS.extend([h.strip() for h in additional_hosts.split(',')])
# ==============================================================================
# 애플리케이션 정의
# ==============================================================================
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'gyber', # Gyber 앱
# Third-party apps
'widget_tweaks',
'mozilla_django_oidc',
]
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',
'mozilla_django_oidc.middleware.SessionRefresh',
]
ROOT_URLCONF = 'config.urls'
# ==============================================================================
# 템플릿 설정
# ==============================================================================
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')], # BASE_DIR 은 /data/gyber/apps/web/
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
# 'django.template.context_processors.debug', # DEBUG=False 이므로 주석 처리 또는 유지해도 무방
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'gyber.context_processors.theme_processor', # ★★★ 커스텀 테마 컨텍스트 프로세서 추가 ★★★
'gyber.context_processors.auth_context',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application' # /data/gyber/apps/web/config/wsgi.py
# ==============================================================================
# 데이터베이스 설정
# ==============================================================================
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'gyber'),
'USER': os.environ.get('DB_USER', 'gyber'),
'PASSWORD': os.environ.get('DB_PASSWORD'), # 환경 변수에서 로드 (필수!)
'HOST': os.environ.get('DB_HOST', '127.0.0.1'), # DB 서버 IP 또는 호스트명
'PORT': os.environ.get('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
},
}
}
if not DATABASES['default']['PASSWORD']:
raise ValueError("DB_PASSWORD 환경 변수가 설정되지 않았습니다.")
# ==============================================================================
# 비밀번호 검증
# ==============================================================================
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',},
]
# ==============================================================================
# 국제화 및 현지화
# ==============================================================================
LANGUAGE_CODE = 'ko-kr' # 한국어
TIME_ZONE = 'Asia/Seoul' # 한국 시간
USE_I18N = True
USE_TZ = True # 시간대 인식 날짜/시간 사용
# ==============================================================================
# 정적 파일 (Static files) 설정
# ==============================================================================
STATIC_URL = 'static/' # 템플릿에서 정적 파일 접근 시 URL 프리픽스
# 'collectstatic' 명령으로 모든 정적 파일을 모을 디렉토리 (Nginx 설정과 일치)
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # /data/gyber/apps/web/staticfiles/
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'), # BASE_DIR 은 /data/gyber/apps/web/
]
# ==============================================================================
# 기본 Primary Key 필드 타입
# ==============================================================================
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ==============================================================================
# 인증 및 권한 (OIDC - 프로덕션)
# ==============================================================================
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'gyber.oidc.CustomOIDCAuthenticationBackend',
)
# --- OIDC 설정 (환경 변수에서 민감 정보 로드) ---
OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "8a61fd65-70ec-4d02-8792-cc516b0746bc") # 기본값 제공 가능 (개발용)
OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET") # 환경 변수에서 로드 (필수!)
if not OIDC_RP_CLIENT_SECRET:
raise ValueError("OIDC_RP_CLIENT_SECRET 환경 변수가 설정되지 않았습니다.")
OIDC_RP_SIGN_ALGO = "RS256"
OIDC_RP_SCOPES = "profile email openid"
AZURE_TENANT_ID = os.environ.get("AZURE_TENANT_ID", "1e8605cc-8007-46b0-993f-b388917f9499") # 기본값 제공 가능
OIDC_OP_AUTHORIZATION_ENDPOINT = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/v2.0/authorize"
OIDC_OP_TOKEN_ENDPOINT = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/v2.0/token"
OIDC_OP_USER_ENDPOINT = "https://graph.microsoft.com/oidc/userinfo"
OIDC_OP_JWKS_ENDPOINT = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/discovery/v2.0/keys"
OIDC_OP_LOGOUT_ENDPOINT = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/v2.0/logout"
OIDC_CREATE_USER_CALLBACK = "gyber.oidc.map_azure_user"
OIDC_UPDATE_USER_CALLBACK = "gyber.oidc.map_azure_user"
# 프로덕션 로그아웃 후 리디렉션 URI (HTTPS, 지정된 포트 포함)
OIDC_RP_POST_LOGOUT_REDIRECT_URI = f"https://gyber.oneunivrs.com:8438/oidc/authenticate/"
# 프로덕션 환경 OIDC 보안 설정 (HTTPS 사용 가정)
OIDC_VERIFY_SSL = True # 외부 CA 인증서 사용 시 True
OIDC_REDIRECT_STATE_SECURE = True # HTTPS 사용 시 True
OIDC_SESSION_MANAGEMENT_ENABLE = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# --- 로그인/로그아웃 URL 설정 ---
LOGIN_URL = "/oidc/authenticate/"
LOGIN_REDIRECT_URL = "/dashboard/"
LOGOUT_REDIRECT_URL = LOGIN_URL # 로그아웃 후 다시 로그인 페이지로
# ==============================================================================
# 로깅 설정 (프로덕션 - 파일 로깅 중심)
# ==============================================================================
# 로그 파일을 저장할 디렉토리 경로 (/data/gyber/apps/web/logs)
LOGS_DIR = os.path.join(BASE_DIR, 'logs') # 앱별로 구분
# 로그 디렉토리가 없으면 생성 (Gunicorn 실행 사용자에게 쓰기 권한 필요)
os.makedirs(LOGS_DIR, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} [{process:d}:{thread:d}] {message}',
'style': '{',
},
'simple': {
'format': '[{levelname}] {asctime} {module}: {message}', # 시간과 모듈 정보 추가
'style': '{',
},
},
'handlers': {
'console': { # 개발 및 systemd journal 확인용
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
'file_django': { # 일반 Django 및 앱 로그
'level': 'INFO', # 프로덕션에서는 INFO 부터 기록
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOGS_DIR, 'django_app.log'),
'maxBytes': 1024 * 1024 * 20, # 20MB
'backupCount': 5,
'formatter': 'verbose',
'encoding': 'utf-8',
},
'file_oidc': { # OIDC 관련 로그
'level': 'DEBUG', # OIDC는 문제 발생 시 상세 로그 필요
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOGS_DIR, 'oidc.log'),
'maxBytes': 1024 * 1024 * 10,
'backupCount': 3,
'formatter': 'verbose',
'encoding': 'utf-8',
},
},
'loggers': {
'django': {
'handlers': ['console', 'file_django'],
'level': 'INFO',
'propagate': False,
},
'django.request': { # 4xx, 5xx 에러 등
'handlers': ['file_django'], # 에러는 파일에 상세히
'level': 'WARNING',
'propagate': False,
},
'gyber': { # 'gyber' 앱의 로그
'handlers': ['console', 'file_django'],
'level': os.environ.get('DJANGO_GYBER_LOG_LEVEL', 'INFO'), # 환경 변수로 앱 로그 레벨 제어
'propagate': False,
},
'mozilla_django_oidc': {
'handlers': ['console', 'file_oidc'],
'level': os.environ.get('DJANGO_OIDC_LOG_LEVEL', 'INFO'), # OIDC 로그 레벨도 제어
'propagate': False,
},
},
# 루트 로거: 특별히 설정하지 않은 다른 모든 로거의 로그를 처리 (필요시 활성화)
# 'root': {
# 'handlers': ['console'],
# 'level': 'WARNING',
# },
}
# ==============================================================================
# 보안 강화 설정 (HTTPS 필수)
# ==============================================================================
# Nginx에서 SSL/TLS를 처리하고 X-Forwarded-Proto 헤더를 올바르게 전달한다고 가정
# Django가 프록시 뒤에서 HTTPS를 인지하도록 설정 (Nginx 설정과 일치해야 함)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# HTTPS를 통해서만 쿠키 전송
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# 브라우저가 항상 HTTPS로만 접속하도록 강제 (HSTS)
SECURE_HSTS_SECONDS = 31536000 # 1년
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True # HSTS 프리로드 리스트에 등록하려면 True (신중히 결정)
# 클릭재킹 방지
X_FRAME_OPTIONS = 'SAMEORIGIN' # 'DENY' 또는 'SAMEORIGIN'

35
apps/web/config/urls.py Normal file
View File

@ -0,0 +1,35 @@
"""
URL configuration for config project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.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.shortcuts import redirect
urlpatterns = [
path('', lambda request: redirect('gyber:dashboard', permanent=False), name='index'),
path('admin/', admin.site.urls),
# 예: 웹 주소가 'gyber/' 로 시작하면, gyber 앱의 urls.py 파일을 참조하도록 설정
# path('gyber/', include('gyber.urls')),
# 다른 앱이 있다면 여기에 추가...
path('', include('gyber.urls')), # 예: 루트 URL 처리
# --- OIDC 관련 URL 추가 ---
# 'oidc/' 경로 아래에 mozilla-django-oidc 가 제공하는 URL 들을 포함시킴
# 예: /oidc/authenticate/, /oidc/callback/, /oidc/logout/ 등
path('oidc/', include('mozilla_django_oidc.urls')),
# --- 추가 끝 ---
# path('', include('gyber.urls')), # 필요하다면 루트 URL 설정
]

16
apps/web/config/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for config 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/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

View File

179
apps/web/gyber/admin.py Normal file
View File

@ -0,0 +1,179 @@
# /data/gyber/apps/web/gyber/admin.py
from django.contrib import admin
from .models import (
GroupInfo, ResourceCategory, UserInfo, ResourceInfo,
LogAddResource, LogDeleteResource, LogUpdateResource,
LogAddUser, LogUpdateUser, LogDeleteUser,
LogAddGroup, LogUpdateGroup, LogDeleteGroup,
LogAddCategory, LogUpdateCategory, LogDeleteCategory
)
# === UserInfo Admin ===
@admin.register(UserInfo)
class UserInfoAdmin(admin.ModelAdmin):
# ★ 수정: list_display 에 display_name, account_name 사용
list_display = ('user_id', 'display_name', 'account_name', 'group')
# ★ 수정: search_fields 에 display_name, account_name 사용
search_fields = ('display_name', 'account_name', 'group__group_name')
list_filter = ('group',)
raw_id_fields = ('group',)
# === GroupInfo Admin ===
@admin.register(GroupInfo)
class GroupInfoAdmin(admin.ModelAdmin):
# ★ 수정: list_display 에 manager_user 사용
list_display = ('group_id', 'group_name', 'manager_user')
# ★ 수정: search_fields 에 manager_user 관련 필드 사용
search_fields = ('group_name', 'manager_user__display_name', 'manager_user__account_name')
# ★ 수정: list_filter 에 manager_user 사용
list_filter = ('manager_user',)
raw_id_fields = ('manager_user',)
# === ResourceCategory Admin ===
@admin.register(ResourceCategory)
class ResourceCategoryAdmin(admin.ModelAdmin):
list_display = ('category_id', 'category_name')
search_fields = ('category_name',)
# === ResourceInfo Admin ===
@admin.register(ResourceInfo)
class ResourceInfoAdmin(admin.ModelAdmin):
# ★ 수정: list_display 에 purchase_date, register_date, update_date 등 최신 필드 사용
list_display = ('resource_id', 'resource_name', 'category', 'serial_num', 'user', 'purchase_date', 'register_date', 'update_date')
# ★ 수정: list_filter 에 purchase_date, register_date 등 추가
list_filter = ('category', 'user__group', 'purchase_date', 'register_date')
# ★ 수정: search_fields 에 사용자 관련 필드 수정
search_fields = ('resource_name', 'resource_code', 'serial_num', 'user__display_name', 'user__account_name', 'comments', 'manufacturer')
date_hierarchy = 'register_date'
raw_id_fields = ('category', 'user')
# === 로그 Admin 설정 (읽기 전용) ===
class ReadOnlyAdminMixin:
"""Admin에서 추가, 변경, 삭제를 비활성화하는 Mixin"""
def has_add_permission(self, request): return False
def has_change_permission(self, request, obj=None): return False
def has_delete_permission(self, request, obj=None): return False # 삭제도 막음
@admin.register(LogAddResource)
class LogAddResourceAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): # Mixin 상속
# ★ 수정: list_display 에 최신 스키마 필드 반영
list_display = ('log_id', 'log_date', 'resource_id', 'resource_name', 'actor_info', 'register_date') # 예시
list_filter = ('log_date',)
search_fields = ('resource_name', 'serial_num', 'actor_description') # actor_description 검색 추가
date_hierarchy = 'log_date'
# actor_info 필드를 위한 계산된 필드 (admin_user_id 또는 actor_description 표시)
@admin.display(description='작업자/주체')
def actor_info(self, obj):
# admin_user_id 를 이용해 auth_user 테이블에서 사용자 이름 가져오기 (성능 주의)
# 또는 간단히 obj.admin_user_id 나 obj.actor_description 표시
if obj.admin_user_id:
# 실제로는 여기서 User 모델을 조회해야 함
return f"Admin ID: {obj.admin_user_id}"
return obj.actor_description or 'Unknown'
@admin.register(LogDeleteResource)
class LogDeleteResourceAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'resource_id', 'resource_name', 'actor_info', 'register_date') # 예시
list_filter = ('log_date',)
search_fields = ('resource_name', 'serial_num', 'actor_description')
date_hierarchy = 'log_date'
@admin.display(description='작업자/주체')
def actor_info(self, obj):
if obj.admin_user_id: return f"Admin ID: {obj.admin_user_id}"
return obj.actor_description or 'Unknown'
class ReadOnlyAdminMixin:
"""Admin에서 추가, 변경, 삭제를 비활성화하는 Mixin"""
def has_add_permission(self, request): return False
def has_change_permission(self, request, obj=None): return False
def has_delete_permission(self, request, obj=None): return False
# actor_info 계산 필드는 공통으로 사용 가능
@admin.display(description='작업자/주체')
def actor_info(self, obj):
# admin_user_id 가 외래키라면 User 모델 직접 조회 가능
# if obj.admin_user: return obj.admin_user.username
if obj.admin_user_id:
# 성능 고려 시 실제 User 모델 조회는 주석 처리하고 ID만 표시
# try:
# user = User.objects.get(pk=obj.admin_user_id)
# return user.username
# except User.DoesNotExist:
# return f"Admin ID: {obj.admin_user_id} (Not Found)"
return f"Admin ID: {obj.admin_user_id}"
return obj.actor_description or 'System/Unknown'
# === 각 로그 모델 Admin 등록 ===
@admin.register(LogUpdateResource)
class LogUpdateResourceAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'resource_id', 'resource_name', 'actor_info') # 변경 후 정보 표시
list_filter = ('log_date',)
search_fields = ('resource_id', 'resource_name', 'serial_num', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogAddUser)
class LogAddUserAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'user_id', 'display_name', 'account_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('user_id', 'display_name', 'account_name', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogUpdateUser)
class LogUpdateUserAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'user_id', 'new_display_name', 'new_account_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('user_id', 'old_display_name', 'new_display_name', 'old_account_name', 'new_account_name', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogDeleteUser)
class LogDeleteUserAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'user_id', 'display_name', 'account_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('user_id', 'display_name', 'account_name', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogAddGroup)
class LogAddGroupAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'group_id', 'group_name', 'manager_user_id', 'actor_info')
list_filter = ('log_date',)
search_fields = ('group_id', 'group_name', 'manager_user_id', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogUpdateGroup)
class LogUpdateGroupAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'group_id', 'new_group_name', 'new_manager_user_id', 'actor_info')
list_filter = ('log_date',)
search_fields = ('group_id', 'old_group_name', 'new_group_name', 'old_manager_user_id', 'new_manager_user_id', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogDeleteGroup)
class LogDeleteGroupAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'group_id', 'group_name', 'manager_user_id', 'actor_info')
list_filter = ('log_date',)
search_fields = ('group_id', 'group_name', 'manager_user_id', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogAddCategory)
class LogAddCategoryAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'category_id', 'category_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('category_id', 'category_name', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogUpdateCategory)
class LogUpdateCategoryAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'category_id', 'old_category_name', 'new_category_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('category_id', 'old_category_name', 'new_category_name', 'actor_description')
date_hierarchy = 'log_date'
@admin.register(LogDeleteCategory)
class LogDeleteCategoryAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ('log_id', 'log_date', 'category_id', 'category_name', 'actor_info')
list_filter = ('log_date',)
search_fields = ('category_id', 'category_name', 'actor_description')
date_hierarchy = 'log_date'

6
apps/web/gyber/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GyberConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'gyber'

View File

@ -0,0 +1,24 @@
# gyber/auth_utils.py
# 그룹으로 권한 관리
from django.contrib.auth.models import User # User 모델 사용 시
def is_admin_user(user):
""" 사용자가 'Admin' 그룹에 속하거나 슈퍼유저인지 확인 """
if not user.is_authenticated: # 혹시 모를 익명 사용자 방지
return False
return user.is_superuser or user.groups.filter(name='Admin').exists()
def is_viewer_user(user):
""" 사용자가 'Viewer' 그룹에 속하거나 슈퍼유저인지 확인 """
if not user.is_authenticated:
return False
return user.is_superuser or user.groups.filter(name='Viewer').exists()
def dashboard_only_user(user): # 대시보드만 보는 사용자
""" 사용자가 'Dashboard Viewers' 그룹에만 속하고 다른 관리자 그룹에는 속하지 않는지 확인 (더 복잡한 경우) """
if not user.is_authenticated:
return False
is_dashboard_viewer = user.groups.filter(name='Dashboard Viewers').exists()
# is_other_manager = user.groups.filter(name__in=['Admin', 'Viewer']).exists()
# return is_dashboard_viewer and not is_other_manager and not user.is_superuser
return user.is_superuser or user.groups.filter(name='Dashboard Viewers').exists()

View File

@ -0,0 +1,69 @@
# /data/gyber/apps/web/gyber/context_processors.py
from .auth_utils import is_admin_user, is_viewer_user, dashboard_only_user
def theme_processor(request):
"""
현재 요청에 적용할 테마 (dark 또는 light)를 결정하여 템플릿 컨텍스트에 추가합니다.
1. 쿠키('theme')에 저장된 값이 있으면 그 값을 사용합니다.
2. 쿠키가 없으면 시스템 환경설정(prefers-color-scheme)을 따릅니다.
"""
theme_value = request.COOKIES.get('theme')
is_dark_theme = False # 기본값은 라이트 테마
if theme_value == 'dark':
is_dark_theme = True
elif theme_value is None: # 쿠키에 명시적인 설정이 없을 경우
# HTTP_SEC_CH_PREFERS_COLOR_SCHEME 헤더는 클라이언트 힌트이며, 모든 브라우저/환경에서 항상 제공되지는 않음
# 또한, 이 헤더는 HTTPS 연결에서만 전송될 수 있음 (request.is_secure() 확인)
# JavaScript의 window.matchMedia('(prefers-color-scheme: dark)') 가 더 신뢰성 있는 방법이지만,
# 서버 사이드에서는 한계가 있음.
# 여기서는 JavaScript에서 localStorage에 저장하고, 필요시 쿠키와 동기화하는 방식을 보완적으로 사용한다고 가정.
# 또는 초기 로드 시 JavaScript가 data-bs-theme을 설정하는 것을 기본으로 하고,
# 서버 사이드 렌더링 시 깜빡임 방지를 위해 이 컨텍스트 프로세서를 보조적으로 사용.
# 아래 로직은 base.html의 JS 로직과 유사하게 맞춤.
if request.is_secure: # HTTPS 연결일 때만 클라이언트 힌트 고려 가능성
# HTTP_SEC_CH_PREFERS_COLOR_SCHEME 헤더는 request.META에 대문자로, 하이픈은 언더스코어로 변환되어 들어올 수 있음
# 예: 'HTTP_SEC_CH_PREFERS_COLOR_SCHEME'
# Django는 일반적으로 request.headers 딕셔너리를 통해 접근하는 것을 권장
preferred_scheme_header = request.headers.get('Sec-CH-Prefers-Color-Scheme', '').lower()
if 'dark' in preferred_scheme_header:
is_dark_theme = True
# AJAX 요청 여부 조건은 여기서 제외하거나, 특정 목적이 있다면 주석으로 명시
# if request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest':
# pass # AJAX 요청 시에는 테마 관련 로직을 다르게 처리할 수 있음
# logger = logging.getLogger('gyber.context_processors') # 필요시 로깅
# logger.debug(f"Theme processor: cookie='{theme_value}', is_dark_theme={is_dark_theme}")
return {'is_dark_theme': is_dark_theme}
def auth_context(request):
"""
인증된 사용자의 주요 권한 상태를 컨텍스트에 추가합니다.
모든 템플릿에서 user.is_authenticated 는 기본적으로 사용 가능합니다.
여기서는 추가적인 그룹 기반 권한 상태를 제공합니다.
"""
user = request.user
if not user.is_authenticated:
return {
'user_is_admin_group_member': False,
'user_is_viewer_group_member': False, # 예시
'user_dashboard_only': False, # 예시
# ... 기타 권한 변수 기본값 ...
}
# auth_utils.py의 함수들을 사용하여 권한 확인
is_admin = is_admin_user(user)
is_viewer = is_viewer_user(user) # 예시, 실제 사용하는 함수로 대체
dashboard_only = dashboard_only_user(user) # 예시, 실제 사용하는 함수로 대체
# logger.debug(f"Auth context for user '{user.username}': is_admin={is_admin}, dashboard_only={dashboard_only}")
return {
'user_is_admin_group_member': is_admin,
'user_is_viewer_group_member': is_viewer,
'user_dashboard_only': dashboard_only,
# ... 기타 필요한 권한 관련 변수 ...
# 'user_is_resource_manager': is_resource_manager(user), # 필요시 추가
}

View File

@ -0,0 +1,17 @@
# /data/gyber/apps/web/gyber/db/__init__.py
# 필요한 함수들을 직접 노출시키거나, 모듈 자체를 노출시킬 수 있음
# 예시 1: 모듈 노출
from . import base
from . import resource
from . import user
from . import group
from . import category
from . import audit
from . import dashboard
# 예시 2: 주요 함수 직접 노출 (필요한 것만 선택적으로)
# from .resource import get_all_resources, get_resource_by_id, add_new_resource # ... 등등
# from .user import get_user_list, get_user_by_id # ... 등등
# ...

View File

@ -0,0 +1,63 @@
# /data/gyber/apps/web/gyber/db/audit.py
import logging
from django.db import connections, DatabaseError, InterfaceError, OperationalError
from .base import execute_procedure, dictfetchall
logger = logging.getLogger(__name__)
# --- 내부 헬퍼 함수: 로그 조회 및 페이지네이션 처리 ---
def _get_logs_with_pagination(proc_name, search_term=None, start_date=None, end_date=None, page_num=1, page_size=20):
"""
지정된 로그 조회 프로시저를 호출하고 결과와 총 개수를 반환합니다.
(오류 발생 시 ([], 0) 반환)
"""
page_num = max(1, page_num or 1)
page_size = max(1, page_size or 20)
params = [search_term, start_date, end_date, page_num, page_size]
logger.debug(f"Calling {proc_name} with params: {params}")
log_list = []
total_count = 0
try:
with connections['default'].cursor() as cursor:
cursor.callproc(proc_name, params)
log_list = dictfetchall(cursor)
if cursor.nextset():
total_count_result = dictfetchall(cursor)
total_count = total_count_result[0]['total_count'] if total_count_result else 0
else:
total_count = len(log_list) if page_num == 1 and len(log_list) < page_size else -1
logger.warning(f"{proc_name} did not return total_count result set.")
logger.debug(f"{proc_name} returned {len(log_list)} logs, total_count: {total_count}")
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling {proc_name} with params {params}: {e}", exc_info=True)
except Exception as e:
logger.error(f"Unexpected error calling {proc_name} with params {params}: {e}", exc_info=True)
return log_list, total_count
# --- 감사 로그(Audit) 관련 함수 ---
def get_resource_audit_logs(search_term=None, start_date=None, end_date=None, page_num=1, page_size=20):
"""자산 관련 감사 로그 조회 (검색, 기간, 페이징)"""
return _get_logs_with_pagination('sp_get_resource_audit_logs', search_term, start_date, end_date, page_num, page_size)
def get_user_audit_logs(search_term=None, start_date=None, end_date=None, page_num=1, page_size=20):
"""사용자 관련 감사 로그 조회 (검색, 기간, 페이징)"""
return _get_logs_with_pagination('sp_get_user_audit_logs', search_term, start_date, end_date, page_num, page_size)
# ★ 수정: 그룹 로그 함수 - _get_logs_with_pagination 호출
def get_group_audit_logs(search_term=None, start_date=None, end_date=None, page_num=1, page_size=20):
"""그룹(부서) 관련 감사 로그 조회 (검색, 기간, 페이징)"""
return _get_logs_with_pagination(
'sp_get_group_audit_logs', # 호출할 프로시저 이름
search_term, start_date, end_date, page_num, page_size
)
# ★ 수정: 카테고리 로그 함수 - _get_logs_with_pagination 호출
def get_category_audit_logs(search_term=None, start_date=None, end_date=None, page_num=1, page_size=20):
"""카테고리 관련 감사 로그 조회 (검색, 기간, 페이징)"""
return _get_logs_with_pagination(
'sp_get_category_audit_logs', # 호출할 프로시저 이름
search_term, start_date, end_date, page_num, page_size
)

90
apps/web/gyber/db/base.py Normal file
View File

@ -0,0 +1,90 @@
# /data/gyber/apps/web/gyber/db/base.py
import logging
from collections import namedtuple
from django.db import connections, DatabaseError, InterfaceError, OperationalError
logger = logging.getLogger(__name__)
def dictfetchall(cursor):
"""커서 실행 결과(모든 행)를 딕셔너리 리스트로 반환합니다."""
columns = [col[0] for col in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
def namedtuplefetchall(cursor):
"""커서 실행 결과(모든 행)를 네임드 튜플 리스트로 반환합니다."""
desc = cursor.description
nt_result = namedtuple('Result', [col[0] for col in desc])
return [nt_result(*row) for row in cursor.fetchall()]
def execute_procedure(proc_name, params=None, fetch_mode='all_dicts', cursor_name='default'): # 기본 fetch_mode 변경(dict -> all_dicts : 결과값의 무한루프로 인해...)
"""
지정된 저장 프로시저를 실행하고 결과를 반환하는 범용 함수.
Args:
proc_name (str): 실행할 프로시저 이름.
params (list | tuple, optional): 프로시저에 전달할 파라미터 리스트.
fetch_mode (str, optional): 결과 반환 방식
'all_dicts': 모든 행을 딕셔너리 리스트로. (기본값)
'all_tuples': 모든 행을 네임드 튜플 리스트로.
'one_dict': 첫 번째 행을 딕셔너리로 (결과 없으면 None).
'one_tuple': 첫 번째 행을 네임드 튜플로 (결과 없으면 None).
'none': 결과를 반환하지 않음 (성공 시 True, DB 오류 시 False).
cursor_name (str, optional): 사용할 Django DB 연결 이름.
Returns:
mixed: fetch_mode에 따른 결과. DB 연결/실행 오류 시 None (fetch_mode='none'은 False).
"""
logger.debug(f"Executing procedure: {proc_name} with params: {params}, fetch_mode: {fetch_mode}")
try:
with connections[cursor_name].cursor() as cursor:
if params:
cursor.callproc(proc_name, params)
else:
cursor.callproc(proc_name)
if fetch_mode == 'all_dicts':
result = dictfetchall(cursor)
elif fetch_mode == 'all_tuples':
result = namedtuplefetchall(cursor)
elif fetch_mode == 'one_dict':
rows = dictfetchall(cursor)
result = rows[0] if rows else None
elif fetch_mode == 'one_tuple':
rows = namedtuplefetchall(cursor)
result = rows[0] if rows else None
elif fetch_mode == 'none':
result = True # 오류 없이 실행 완료
else:
logger.warning(f"Unknown fetch_mode '{fetch_mode}' for procedure {proc_name}. Defaulting to all_dicts.")
result = dictfetchall(cursor) # 정의되지 않은 모드면 기본값으로 처리
# 결과셋이 여러 개일 경우를 대비해 모두 소진 (특히 fetch_mode='none' 이거나 첫 번째 결과셋만 사용하는 경우)
# cursor.nextset()은 다음 결과셋이 있으면 True, 없으면 False 또는 None 반환
# while cursor.nextset():
# pass
# 주의: 위 로직은 프로시저가 SELECT 외에 다른 작업(예: OUT 파라미터 설정 후 SELECT)을 할 때
# 의도치 않게 다음 결과셋으로 넘어가 버릴 수 있음.
# 프로시저가 단일 SELECT 결과만 반환한다고 가정할 때는 괜찮음.
# OUT 파라미터를 사용하는 프로시저의 경우, 호출부에서 직접 cursor를 다루는 것이 나을 수 있음.
logger.debug(f"Procedure {proc_name} executed. Fetch mode: {fetch_mode}. Result preview: {str(result)[:200] if result is not True else 'True'}")
return result
except (DatabaseError, InterfaceError, OperationalError) as db_err:
# SIGNAL 로 발생한 사용자 정의 오류도 이 예외로 잡힐 수 있음
error_message = str(db_err)
if '45000' in error_message: # SIGNAL 로 발생한 사용자 정의 오류 코드 (프로시저에서 사용했다면)
try:
import re
match = re.search(r"'(.*?)'", error_message)
clean_message = match.group(1) if match else error_message.split(':', 1)[-1].strip()
logger.warning(f"Procedure {proc_name} (SIGNAL): {clean_message}")
except:
logger.warning(f"Procedure {proc_name} (SIGNAL, raw): {error_message}")
else:
logger.error(f"Database error executing procedure {proc_name} with params {params}: {db_err}", exc_info=True)
return False if fetch_mode == 'none' else [] # 데이터 반환을 기대하는 모드에서는 오류 시 빈 리스트 반환
except Exception as e:
logger.error(f"Unexpected error executing procedure {proc_name} with params {params}: {e}", exc_info=True)
return False if fetch_mode == 'none' else [] # 데이터 반환을 기대하는 모드에서는 오류 시 빈 리스트 반환

View File

@ -0,0 +1,135 @@
# /data/gyber/apps/web/gyber/db/category.py
import logging
from django.db import connections, DatabaseError, InterfaceError, OperationalError
from .base import execute_procedure, dictfetchall
logger = logging.getLogger(__name__)
# --- 자산 카테고리(Category) 관련 함수 ---
def get_all_categories():
"""모든 자산 카테고리 목록을 조회합니다."""
return execute_procedure('sp_get_all_categories', fetch_mode='all_dicts')
def get_category_by_id(category_id):
"""특정 카테고리 ID로 상세 정보를 조회합니다."""
logger.debug(f"Calling sp_get_category_by_id with category_id: {category_id}")
result = execute_procedure('sp_get_category_by_id', [category_id], fetch_mode='one_dict')
if not result:
logger.warning(f"sp_get_category_by_id did not find category with ID: {category_id}")
return result
def add_new_category(admin_user_id, actor_description, category_name):
"""
새로운 카테고리를 추가합니다. (ID 자동 할당)
프로시저: sp_add_category (OUT: p_new_category_id(index 3), p_result_message(index 4))
Returns:
tuple: (success: bool, message: str, new_category_id: int|None)
"""
params = [
admin_user_id, actor_description, category_name,
None, # OUT p_new_category_id placeholder
None # OUT p_result_message placeholder
]
call_params = params[:-2]
logger.debug(f"Calling sp_add_category with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_add_category', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_add_category_3, @_sp_add_category_4;")
result = cursor.fetchone()
if result:
new_category_id = result[0]
message = result[1]
# MariaDB TINYINT는 Python int로 반환됨
if message and message.endswith('추가 완료.') and new_category_id is not None and isinstance(new_category_id, int):
logger.info(f"sp_add_category succeeded: {message} (New ID: {new_category_id})")
# ID 타입이 int 여야 함 (TINYINT UNSIGNED -> Python int)
return True, message, int(new_category_id)
else:
logger.warning(f"sp_add_category reported an issue: {message} (New ID: {new_category_id})")
return False, message if message else "카테고리 추가 실패", None
else:
logger.error("Failed to retrieve OUT parameters from sp_add_category.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다.", None
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_add_category: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}", None
except Exception as e:
logger.error(f"Unexpected error calling sp_add_category: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}", None
def update_category_name(admin_user_id, actor_description, category_id, new_category_name):
"""
카테고리 이름을 수정합니다.
프로시저: sp_update_category_name (OUT: p_result_message - index 4)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, category_id, new_category_name,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_update_category_name for category_id {category_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_update_category_name', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_update_category_name_4;")
result = cursor.fetchone()
if result:
message = result[0]
if message and message.endswith('이름 수정 완료.'):
logger.info(f"sp_update_category_name succeeded: {message}")
return True, message
else:
logger.warning(f"sp_update_category_name reported an issue: {message}")
return False, message if message else "카테고리 이름 수정 실패"
else:
logger.error("Failed to retrieve OUT parameter from sp_update_category_name.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_update_category_name: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_update_category_name: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def delete_category(admin_user_id, actor_description, category_id):
"""
카테고리를 삭제합니다.
프로시저: sp_delete_category (OUT: p_result_message - index 3)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, category_id,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_delete_category for category_id {category_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_delete_category', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_delete_category_3;")
result = cursor.fetchone()
if result:
message = result[0]
if message and message.endswith('삭제 완료.'):
logger.info(f"sp_delete_category succeeded: {message}")
return True, message
else: # 자산 존재 등
logger.warning(f"sp_delete_category reported an issue: {message}")
return False, message if message else "카테고리 삭제 실패"
else:
logger.error("Failed to retrieve OUT parameter from sp_delete_category.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_delete_category: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_delete_category: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"

View File

@ -0,0 +1,15 @@
# /data/gyber/apps/web/gyber/db/dashboard.py
import logging
from .base import execute_procedure
logger = logging.getLogger(__name__)
# --- 대시보드 관련 함수 ---
def get_dashboard_summary():
"""대시보드 요약 정보 조회."""
return execute_procedure('sp_get_dashboard_summary', fetch_mode='one_dict')
def get_assets_count_by_category():
"""카테고리별 자산 개수 조회."""
return execute_procedure('sp_get_assets_count_by_category', fetch_mode='all_dicts')

133
apps/web/gyber/db/group.py Normal file
View File

@ -0,0 +1,133 @@
# /data/gyber/apps/web/gyber/db/group.py
import logging
from django.db import connections, DatabaseError, InterfaceError, OperationalError
from .base import execute_procedure, dictfetchall
logger = logging.getLogger(__name__)
# --- 그룹(Group/부서) 관련 함수 ---
def get_all_groups():
"""모든 그룹(부서) 목록을 이름순으로 조회합니다."""
return execute_procedure('sp_get_all_groups', fetch_mode='all_dicts')
def get_group_by_id(group_id):
"""특정 그룹(부서) ID로 상세 정보를 조회합니다 (관리자 정보 포함)."""
logger.debug(f"Calling sp_get_group_by_id with group_id: {group_id}")
result = execute_procedure('sp_get_group_by_id', [group_id], fetch_mode='one_dict')
if not result:
logger.warning(f"sp_get_group_by_id did not find group with ID: {group_id}")
return result
def add_new_group(admin_user_id, actor_description, group_name, manager_user_id):
"""
새로운 그룹(부서)을 추가합니다.
프로시저: sp_add_group (OUT: p_new_group_id(index 4), p_result_message(index 5))
Returns:
tuple: (success: bool, message: str, new_group_id: int|None)
"""
params = [
admin_user_id, actor_description, group_name, manager_user_id,
None, # OUT p_new_group_id placeholder
None # OUT p_result_message placeholder
]
call_params = params[:-2]
logger.debug(f"Calling sp_add_group with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_add_group', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_add_group_4, @_sp_add_group_5;")
result = cursor.fetchone()
if result:
new_group_id = result[0]
message = result[1]
if message and message.endswith('추가 완료.') and new_group_id is not None:
logger.info(f"sp_add_group succeeded: {message} (New ID: {new_group_id})")
return True, message, new_group_id
else:
logger.warning(f"sp_add_group reported an issue: {message} (New ID: {new_group_id})")
return False, message if message else "그룹 추가 실패", None
else:
logger.error("Failed to retrieve OUT parameters from sp_add_group.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다.", None
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_add_group: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}", None
except Exception as e:
logger.error(f"Unexpected error calling sp_add_group: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}", None
def update_group(admin_user_id, actor_description, group_id, group_name, manager_user_id):
"""
그룹(부서) 정보를 수정합니다.
프로시저: sp_update_group (OUT: p_result_message - index 5)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, group_id, group_name, manager_user_id,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_update_group for group_id {group_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_update_group', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_update_group_5;")
result = cursor.fetchone()
if result:
message = result[0]
if message and message.endswith('정보 수정 완료.'):
logger.info(f"sp_update_group succeeded: {message}")
return True, message
else:
logger.warning(f"sp_update_group reported an issue: {message}")
return False, message if message else "그룹 수정 실패"
else:
logger.error("Failed to retrieve OUT parameter from sp_update_group.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_update_group: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_update_group: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def delete_group(admin_user_id, actor_description, group_id):
"""
그룹(부서)을 삭제합니다.
프로시저: sp_delete_group (OUT: p_result_message - index 3)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, group_id,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_delete_group for group_id {group_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_delete_group', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_delete_group_3;")
result = cursor.fetchone()
if result:
message = result[0]
if message and message.endswith('삭제 완료.'):
logger.info(f"sp_delete_group succeeded: {message}")
return True, message
else: # 멤버 존재 등
logger.warning(f"sp_delete_group reported an issue: {message}")
return False, message if message else "그룹 삭제 실패"
else:
logger.error("Failed to retrieve OUT parameter from sp_delete_group.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_delete_group: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_delete_group: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"

View File

@ -0,0 +1,244 @@
# /data/gyber/apps/web/gyber/db/resource.py
import logging
from django.db import connections, DatabaseError, InterfaceError, OperationalError
from .base import execute_procedure, dictfetchall
logger = logging.getLogger(__name__)
# --- 자산(Resource) 관련 함수 ---
def get_all_resources(page_num, page_size, sort_column, sort_direction, category_id=None, group_id=None, user_id=None):
"""
자산 목록을 조회합니다 (페이징, 정렬, 필터링 포함).
프로시저: sp_get_all_resources (결과셋 2개: 목록, 총 개수)
반환값: (resource_list: list[dict], total_count: int)
"""
params = [page_num, page_size, sort_column, sort_direction, category_id, group_id, user_id]
logger.debug(f"Calling sp_get_all_resources with params: {params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_get_all_resources', params)
resource_list = dictfetchall(cursor)
total_count = 0
if cursor.nextset():
total_count_result = dictfetchall(cursor)
total_count = total_count_result[0]['total_count'] if total_count_result else 0
logger.debug(f"sp_get_all_resources returned {len(resource_list)} resources, total_count: {total_count}")
return resource_list, total_count
except Exception as e:
logger.error(f"Error in get_all_resources: {e}", exc_info=True)
return [], 0
def get_resources_by_search(search_term, page_num, page_size, sort_column, sort_direction, category_id=None, group_id=None, user_id=None):
"""
자산 목록을 검색하여 조회합니다 (페이징, 정렬, 필터링 포함).
프로시저: sp_get_resources_by_search (결과셋 2개: 목록, 총 개수)
반환값: (resource_list: list[dict], total_count: int)
"""
params = [search_term, page_num, page_size, sort_column, sort_direction, category_id, group_id, user_id]
logger.debug(f"Calling sp_get_resources_by_search with params: {params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_get_resources_by_search', params)
resource_list = dictfetchall(cursor)
total_count = 0
if cursor.nextset():
total_count_result = dictfetchall(cursor)
total_count = total_count_result[0]['total_count'] if total_count_result else 0
logger.debug(f"sp_get_resources_by_search returned {len(resource_list)} resources, total_count: {total_count}")
return resource_list, total_count
except Exception as e:
logger.error(f"Error in get_resources_by_search: {e}", exc_info=True)
return [], 0
def get_resource_by_id(resource_id):
"""
특정 자산 ID로 상세 정보를 조회합니다.
프로시저: sp_get_resource_by_id
반환값: dict (자산 정보) 또는 None
"""
return execute_procedure('sp_get_resource_by_id', [resource_id], fetch_mode='one_dict')
def add_new_resource(admin_user_id, actor_description, category_id, resource_code, manufacturer,
resource_name, serial_num, spec_value, spec_unit, user_id, comments, purchase_date, is_locked): # register_date는 프로시저에서 NOW() 사용
"""
새로운 자산을 추가합니다.
프로시저: sp_add_resource (OUT: p_new_resource_id - 13번째, index 12)
Args:
purchase_date: 구매일 (Nullable)
Returns:
tuple: (success: bool, message: str, new_resource_id: int|None)
"""
params = [
admin_user_id, actor_description, category_id, resource_code, manufacturer,
resource_name, serial_num, spec_value, spec_unit, user_id, comments, purchase_date, is_locked,
None # OUT p_new_resource_id placeholder
]
call_params = params[:-1] # OUT 파라미터 제외하고 로깅
logger.debug(f"Calling sp_add_resource with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_add_resource', params)
# OUT 파라미터 가져오기 (p_new_resource_id 인덱스 12)
cursor.execute("SELECT @_sp_add_resource_12;")
result = cursor.fetchone()
if result:
new_id = result[0]
if new_id is not None and isinstance(new_id, int):
message = f"자산 (ID: {new_id}) 추가 성공."
logger.info(message)
return True, message, new_id
else:
# 프로시저 내부 오류 또는 ID 반환 실패 (거의 발생하지 않음)
message = "자산 추가는 실행되었으나 ID를 가져오지 못했습니다."
logger.warning(message)
return False, message, None
else:
logger.error("Failed to retrieve OUT parameter p_new_resource_id from sp_add_resource.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다.", None
except (DatabaseError, InterfaceError, OperationalError) as e:
# 프로시저 내부 SIGNAL 로 발생한 오류 처리
error_message = str(e)
if '45000' in error_message: # SIGNAL 로 발생한 사용자 정의 오류 코드
# MySQL/MariaDB 오류 메시지에서 실제 텍스트 추출 (Connector 마다 다를 수 있음)
# 예: (1644, "이미 등록된 시리얼 번호입니다.") -> "이미 등록된 시리얼 번호입니다."
try:
# 정규식 또는 문자열 처리로 메시지 부분만 추출
import re
match = re.search(r"'(.*?)'", error_message)
if match:
clean_message = match.group(1)
else: # 다른 형식의 오류 메시지 대비
clean_message = error_message.split(':', 1)[-1].strip() if ':' in error_message else error_message
except:
clean_message = error_message # 실패 시 원본 메시지
logger.warning(f"sp_add_resource reported an issue: {clean_message}")
return False, clean_message, None
else: # 일반 DB 오류
logger.error(f"Database error calling sp_add_resource: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}", None
except Exception as e:
logger.error(f"Unexpected error calling sp_add_resource: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}", None
def update_resource(admin_user_id, actor_description, resource_id, category_id, resource_code, manufacturer,
resource_name, serial_num, spec_value, spec_unit, user_id, comments, purchase_date, is_locked):
"""
자산 정보를 수정합니다. (프로시저에 OUT 파라미터 없음)
프로시저: sp_update_resource
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, resource_id, category_id, resource_code, manufacturer,
resource_name, serial_num, spec_value, spec_unit, user_id, comments, purchase_date, is_locked
]
logger.debug(f"Calling sp_update_resource for resource_id {resource_id} with params: {params}")
try:
# execute_procedure는 성공 시 True, DB 오류 시 False 반환 (fetch_mode='none')
success = execute_procedure('sp_update_resource', params, fetch_mode='none')
if success:
message = f"자산 (ID: {resource_id}) 정보 수정 성공."
logger.info(message)
return True, message
else:
# execute_procedure 내부에서 이미 오류 로깅됨
# SIGNAL 오류는 execute_procedure 에서 잡히지 않고 예외로 나올 것임
return False, "자산 정보 수정 중 데이터베이스 오류 발생."
except (DatabaseError, InterfaceError, OperationalError) as e:
# 프로시저 내부 SIGNAL 오류 처리
error_message = str(e)
if '45000' in error_message:
try:
import re
match = re.search(r"'(.*?)'", error_message)
clean_message = match.group(1) if match else error_message
except:
clean_message = error_message
logger.warning(f"sp_update_resource reported an issue: {clean_message}")
return False, clean_message
else:
logger.error(f"Database error calling sp_update_resource: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_update_resource: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def delete_resource(admin_user_id, actor_description, resource_id):
"""
자산을 삭제합니다. (프로시저에 OUT 파라미터 없음)
프로시저: sp_delete_resource
Returns:
tuple: (success: bool, message: str)
"""
params = [admin_user_id, actor_description, resource_id]
logger.debug(f"Calling sp_delete_resource for resource_id {resource_id} with params: {params}")
try:
success = execute_procedure('sp_delete_resource', params, fetch_mode='none')
if success:
message = f"자산 (ID: {resource_id}) 삭제 성공."
logger.info(message)
return True, message
else:
return False, "자산 삭제 중 데이터베이스 오류 발생."
except (DatabaseError, InterfaceError, OperationalError) as e:
# 프로시저 내부 SIGNAL 오류 처리
error_message = str(e)
if '45000' in error_message:
try:
import re
match = re.search(r"'(.*?)'", error_message)
clean_message = match.group(1) if match else error_message
except:
clean_message = error_message
logger.warning(f"sp_delete_resource reported an issue: {clean_message}")
return False, clean_message
else:
logger.error(f"Database error calling sp_delete_resource: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_delete_resource: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def get_resources_by_account(account_name):
"""
특정 계정(사용자)에게 할당된 자산 목록을 조회합니다.
프로시저: sp_get_resources_by_account (account_name 파라미터 사용)
"""
params = [account_name]
logger.debug(f"Calling sp_get_resources_by_account with account_name: {account_name}")
result = execute_procedure('sp_get_resources_by_account', params, fetch_mode='dict')
if result is None: return [] # 오류 시 빈 리스트
# 프로시저가 사용자를 못 찾으면 빈 결과를 반환하므로, 결과가 []인 것은 정상일 수 있음
logger.debug(f"sp_get_resources_by_account returned {len(result)} resources.")
return result
def get_all_resources_for_export(sort_column, sort_direction, category_id=None, group_id=None, user_id=None):
"""
모든 자산 목록을 조회합니다 (내보내기용 - 페이징 없음).
프로시저: sp_get_all_resources_export
"""
params = [sort_column, sort_direction, category_id, group_id, user_id]
logger.debug(f"Calling sp_get_all_resources_export with params: {params}")
# execute_procedure가 딕셔너리 리스트를 반환한다고 가정
return execute_procedure('sp_get_all_resources_export', params, fetch_mode='all_dicts')
def get_resources_by_search_for_export(search_term, sort_column, sort_direction, category_id=None, group_id=None, user_id=None):
"""
검색된 모든 자산 목록을 조회합니다 (내보내기용 - 페이징 없음).
프로시저: sp_get_resources_by_search_export
"""
params = [search_term, sort_column, sort_direction, category_id, group_id, user_id]
logger.debug(f"Calling sp_get_resources_by_search_export with params: {params}")
return execute_procedure('sp_get_resources_by_search_export', params, fetch_mode='all_dicts')

205
apps/web/gyber/db/user.py Normal file
View File

@ -0,0 +1,205 @@
# /data/gyber/apps/web/gyber/db/user.py
import logging
from django.db import connections, DatabaseError, InterfaceError, OperationalError
from .base import execute_procedure, dictfetchall
logger = logging.getLogger(__name__)
# --- 사용자(User) 관련 함수 ---
def get_user_list(search_term=None, page_num=1, page_size=20, sort_column='name', sort_direction='asc', group_id=None):
"""
사용자 목록을 검색, 페이징, 정렬, 필터링하여 조회합니다.
프로시저: sp_get_user_list (결과셋 2개: 목록, 총 개수)
Args:
sort_column (str, optional): 정렬 컬럼 ('name', 'account', 'group', 'assets'). Defaults to 'name'.
Returns:
tuple: (user_list: list[dict], total_count: int)
"""
params = [search_term, page_num, page_size, sort_column, sort_direction, group_id]
logger.debug(f"Calling sp_get_user_list with params: {params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_get_user_list', params)
user_list = dictfetchall(cursor)
total_count = 0
if cursor.nextset():
total_count_result = dictfetchall(cursor)
total_count = total_count_result[0]['total_count'] if total_count_result else 0
logger.debug(f"sp_get_user_list returned {len(user_list)} users, total_count: {total_count}")
return user_list, total_count
except Exception as e:
logger.error(f"Error in get_user_list: {e}", exc_info=True)
return [], 0
def get_user_by_id(user_id):
"""
특정 사용자 ID로 사용자 정보를 조회합니다.
프로시저: sp_get_user_by_id
"""
logger.debug(f"Calling sp_get_user_by_id with user_id: {user_id}")
result = execute_procedure('sp_get_user_by_id', [user_id], fetch_mode='one_dict')
if not result:
logger.warning(f"sp_get_user_by_id did not find user with ID: {user_id}")
return result
def get_all_users():
"""모든 사용자 목록 간략 조회 (폼 선택용 - "표시이름 [계정명]")."""
return execute_procedure('sp_get_all_users', fetch_mode='all_dicts')
def add_new_user(admin_user_id, actor_description, display_name, account_name, group_id):
"""
새로운 사용자를 추가합니다.
프로시저: sp_add_user (OUT: p_new_user_id(index 5), p_result_message(index 6))
Returns:
tuple: (success: bool, message: str, new_user_id: int|None)
"""
params = [
admin_user_id, actor_description, display_name, account_name, group_id,
None, # OUT p_new_user_id placeholder
None # OUT p_result_message placeholder
]
call_params = params[:-2]
logger.debug(f"Calling sp_add_user with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_add_user', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_add_user_5, @_sp_add_user_6;")
result = cursor.fetchone()
if result:
new_user_id = result[0]
message = result[1]
# 프로시저가 성공 메시지 반환 + ID 할당 시 성공
if message and message.endswith('추가 완료.') and new_user_id is not None:
logger.info(f"sp_add_user succeeded: {message} (New ID: {new_user_id})")
return True, message, new_user_id
else: # 프로시저 내 비즈니스 로직 실패 (예: 중복) 또는 DB 오류
logger.warning(f"sp_add_user reported an issue: {message} (New ID: {new_user_id})")
# 메시지가 null 이어도 실패로 간주
return False, message if message else "사용자 추가 실패 (원인 미상)", None
else:
logger.error("Failed to retrieve OUT parameters from sp_add_user.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다.", None
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_add_user: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}", None
except Exception as e:
logger.error(f"Unexpected error calling sp_add_user: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}", None
def update_user(admin_user_id, actor_description, user_id, display_name, account_name, group_id):
"""
사용자 정보를 수정합니다.
프로시저: sp_update_user (OUT: p_result_message - index 6)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, user_id, display_name, account_name, group_id,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_update_user for user_id {user_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_update_user', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_update_user_6;")
result = cursor.fetchone()
if result:
message = result[0]
# 프로시저 성공 메시지 패턴 확인
if message and message.endswith('정보 수정 완료.'):
logger.info(f"sp_update_user succeeded: {message}")
return True, message
else: # 프로시저 내 비즈니스 로직 실패 (예: 중복) 또는 DB 오류
logger.warning(f"sp_update_user reported an issue: {message}")
return False, message if message else "사용자 수정 실패 (원인 미상)"
else:
logger.error("Failed to retrieve OUT parameter from sp_update_user.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_update_user: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_update_user: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def delete_user(admin_user_id, actor_description, user_id):
"""
사용자를 삭제합니다.
프로시저: sp_delete_user (OUT: p_result_message - index 3)
Returns:
tuple: (success: bool, message: str)
"""
params = [
admin_user_id, actor_description, user_id,
None # OUT p_result_message placeholder
]
call_params = params[:-1]
logger.debug(f"Calling sp_delete_user for user_id {user_id} with params (excluding OUT): {call_params}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc('sp_delete_user', params)
# OUT 파라미터 가져오기
cursor.execute("SELECT @_sp_delete_user_3;")
result = cursor.fetchone()
if result:
message = result[0]
# 프로시저 성공 메시지 패턴 확인
if message and message.endswith('삭제 완료.'):
logger.info(f"sp_delete_user succeeded: {message}")
return True, message
else: # 프로시저 내 비즈니스 로직 실패 (예: 할당 자산 존재)
logger.warning(f"sp_delete_user reported an issue: {message}")
return False, message if message else "사용자 삭제 실패 (원인 미상)"
else:
logger.error("Failed to retrieve OUT parameter from sp_delete_user.")
return False, "프로시저 결과(OUT 파라미터)를 가져오는데 실패했습니다."
except (DatabaseError, InterfaceError, OperationalError) as e:
logger.error(f"Database error calling sp_delete_user: {e}", exc_info=True)
return False, f"데이터베이스 오류: {e}"
except Exception as e:
logger.error(f"Unexpected error calling sp_delete_user: {e}", exc_info=True)
return False, f"알 수 없는 오류: {e}"
def get_users_for_autocomplete_paginated(search_term, page_num, page_size, limit_for_total_count_calc=1000):
"""
자동완성용 사용자 목록을 검색 (페이지네이션 지원).
프로시저: sp_search_users_for_autocomplete_paged (가칭, 두 개의 결과셋 반환: 목록, 총 개수)
반환값: tuple (user_list: list[dict], total_count: int) 또는 오류 시 (None, 0)
limit_for_total_count_calc: FOUND_ROWS() 성능을 위해 너무 많은 데이터에 대해 계산하지 않도록 제한 (선택적)
"""
params = [search_term, page_num, page_size, limit_for_total_count_calc]
proc_name = 'sp_search_users_for_autocomplete_paged'
logger.debug(f"Calling {proc_name} with term: {search_term}, page: {page_num}, size: {page_size}")
try:
with connections['default'].cursor() as cursor:
cursor.callproc(proc_name, params)
user_list = dictfetchall(cursor) # 첫 번째 결과셋: 페이지네이션된 사용자 목록
total_count = 0
if cursor.nextset(): # 두 번째 결과셋으로 이동
count_result = cursor.fetchone() # FOUND_ROWS()는 단일 행, 단일 컬럼
if count_result:
total_count = count_result[0] # 첫 번째 컬럼 값
logger.debug(f"{proc_name} returned {len(user_list)} users for page {page_num}, total_found: {total_count}")
return user_list, total_count
except Exception as e:
logger.error(f"Error in {proc_name}: {e}", exc_info=True)
return None, 0 # 오류 시

189
apps/web/gyber/forms.py Normal file
View File

@ -0,0 +1,189 @@
# /data/gyber/apps/web/gyber/forms.py
from django import forms
import logging
# db_utils 임포트는 GroupForm __init__ 에서만 사용하므로 해당 위치에서 임포트하거나 제거 후 뷰에서 전달
# from .db.user import get_all_users # __init__에서 로드 시 필요
logger = logging.getLogger(__name__)
# --- 기본 선택지 ---
DEFAULT_CHOICES = [('', '---------')]
# --- 자산 폼 ---
class ResourceForm(forms.Form):
"""자산 정보 추가/수정을 위한 폼"""
category = forms.ChoiceField(
label='자산 카테고리',
required=True,
choices=DEFAULT_CHOICES, # 초기값, __init__ 또는 뷰에서 덮어씀
widget=forms.Select(attrs={'class': 'form-select'})
)
resource_code = forms.CharField(
label='관리 코드',
max_length=100,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
manufacturer = forms.CharField(
label='제조사',
max_length=100,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
resource_name = forms.CharField(
label='제품명/모델명',
max_length=100,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
serial_num = forms.CharField(
label='시리얼 번호',
max_length=200,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
spec_value = forms.DecimalField(
label='사양 값',
max_digits=10,
decimal_places=2,
required=False,
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'})
)
# 사양 단위 (선택) - DB 스키마 확인 후 필요시 수정
# 현재는 하드코딩된 값 사용
SPEC_UNIT_CHOICES = [
('', '---------'),
(1, 'MHz'), (2, 'MB'), (3, 'GB'), (4, 'TB'), (5, 'PB'), (6, 'Inch'),
# 필요에 따라 다른 단위 추가 (예: Volt, Watt 등)
]
spec_unit = forms.ChoiceField(
label='사양 단위',
choices=SPEC_UNIT_CHOICES,
required=False,
widget=forms.Select(attrs={'class': 'form-select'})
)
user = forms.IntegerField(
label='할당 사용자', # 라벨 변경
required=False,
# choices=DEFAULT_CHOICES, # 초기값
# widget=forms.Select(attrs={'class': 'form-select'})
widget=forms.HiddenInput() # 실제로는 숨겨진 필드로 user_id를 받음
)
comments = forms.CharField(
label='비고', # 라벨 변경
required=False,
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3})
)
# ★ 변경: create_date -> purchase_date
purchase_date = forms.DateField(
label='구매 일자', # 라벨 변경
required=False, # 구매일자는 선택 사항일 수 있음
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
)
is_locked = forms.BooleanField( # ★★★ 잠금 필드 ★★★
label='자동 동기화 제외 (잠금)',
required=False, # HTML 폼에서는 체크 안 하면 False(0)로 전달됨
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
help_text='체크 시 스캔을 통한 자동 정보 업데이트/삭제 대상에서 제외됩니다. (체크: 잠금(1), 해제: 잠금해제(0))' # DB 값 명시
)
def __init__(self, *args, **kwargs):
# 뷰에서 choices 전달받아 설정하는 방식 권장
category_choices = kwargs.pop('category_choices', DEFAULT_CHOICES)
# user_choices = kwargs.pop('user_choices', DEFAULT_CHOICES)
# spec_unit_choices = kwargs.pop('spec_unit_choices', self.SPEC_UNIT_CHOICES) # 단위도 동적 처리 시
super().__init__(*args, **kwargs)
# 전달받은 choices 설정
self.fields['category'].choices = category_choices
# self.fields['user'].choices = user_choices
# self.fields['spec_unit'].choices = spec_unit_choices
# 필드 순서 조정
self.order_fields([
'category', 'resource_name', 'manufacturer', 'serial_num',
'resource_code', 'spec_value', 'spec_unit',
'purchase_date', 'is_locked', 'comments'
])
# --- 사용자 폼 ---
class UserForm(forms.Form):
"""사용자 정보 추가/수정을 위한 폼"""
# ★ 변경: user_name -> display_name, account_name 추가
display_name = forms.CharField(
label='표시 이름',
max_length=100,
required=False, # DB 스키마 상 NULL 허용
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '예: 홍길동'})
)
account_name = forms.CharField(
label='계정 이름 (ID)', # 라벨 변경
max_length=255,
required=True, # DB 스키마 상 NOT NULL, UNIQUE
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '예: gildong.hong'})
)
# email_address 는 user_info 테이블에는 없지만, OIDC 인증 등 다른 곳에서 필요할 수 있어 유지
# 만약 Gyber 시스템 내에서 전혀 사용되지 않는다면 제거 고려
# email_address = forms.EmailField(
# label='이메일 주소',
# max_length=254,
# required=False,
# widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': '예: user@example.com'}),
# help_text="필요시 입력하세요."
# )
group = forms.ChoiceField(
label='소속 그룹(부서)', # 라벨 변경
required=False,
choices=DEFAULT_CHOICES, # 초기값
widget=forms.Select(attrs={'class': 'form-select'})
)
def __init__(self, *args, **kwargs):
# 뷰에서 group_choices 전달받아 설정
group_choices = kwargs.pop('group_choices', DEFAULT_CHOICES)
super().__init__(*args, **kwargs)
self.fields['group'].choices = group_choices
# 필드 순서 (email_address 제거 시 아래 라인도 수정)
self.order_fields(['display_name', 'account_name', 'group']) # 'email_address' 제거
# --- 그룹(부서) 폼 ---
class GroupForm(forms.Form):
"""그룹(부서) 정보 추가/수정을 위한 폼"""
group_name = forms.CharField(
label='그룹(부서) 이름',
max_length=100,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '예: IT 개발팀'})
)
# ★ 변경: 필드 이름 manager_user
manager_user = forms.ChoiceField(
label='그룹 관리자', # 라벨 변경
required=False,
choices=DEFAULT_CHOICES, # 초기값
widget=forms.Select(attrs={'class': 'form-select'})
)
# __init__ 에서 DB 호출 대신, 뷰에서 user_choices 를 전달하는 방식으로 통일
# def __init__(self, *args, **kwargs):
# user_choices = kwargs.pop('user_choices', DEFAULT_CHOICES)
# super().__init__(*args, **kwargs)
# self.fields['manager_user'].choices = user_choices
# --- 카테고리 폼 ---
class CategoryForm(forms.Form):
"""자산 카테고리 이름 추가/수정을 위한 폼"""
category_name = forms.CharField(
label='카테고리 이름',
max_length=40,
required=True,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '예: 노트북'})
)
# 동적 choices 불필요, __init__ 수정 없음

View File

@ -0,0 +1,75 @@
# Generated by Django 5.2 on 2025-04-22 06:22
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='GroupInfo',
fields=[
('group_id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='그룹ID')),
('group_name', models.CharField(max_length=100, verbose_name='그룹이름')),
],
options={
'verbose_name': '그룹 정보',
'verbose_name_plural': '그룹 정보 목록',
'db_table': 'group_info',
'managed': False,
},
),
migrations.CreateModel(
name='ResourceCategory',
fields=[
('category_id', models.SmallIntegerField(primary_key=True, serialize=False, verbose_name='카테고리 ID')),
('category_name', models.CharField(max_length=40, verbose_name='카테고리 이름')),
],
options={
'verbose_name': '자산 카테고리',
'verbose_name_plural': '자산 카테고리 목록',
'db_table': 'resource_category',
'managed': False,
},
),
migrations.CreateModel(
name='ResourceInfo',
fields=[
('resource_id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='자산ID')),
('resource_code', models.CharField(blank=True, max_length=100, null=True, verbose_name='관리용 자산 코드')),
('manufacturer', models.CharField(blank=True, max_length=100, null=True, verbose_name='제조사')),
('resource_name', models.CharField(max_length=100, verbose_name='제품명')),
('serial_num', models.CharField(blank=True, max_length=200, null=True, unique=True, verbose_name='제품 시리얼 번호')),
('spec_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='사양 값')),
('spec_unit', models.SmallIntegerField(blank=True, choices=[(1, 'GHz'), (2, 'MB'), (3, 'GB'), (4, 'TB'), (5, 'PB'), (6, 'inch')], null=True, verbose_name='사양 단위')),
('comments', models.CharField(blank=True, max_length=200, null=True, verbose_name='추가 설명')),
('create_date', models.DateTimeField(verbose_name='최초 등록일(구매일)')),
('update_date', models.DateTimeField(blank=True, null=True, verbose_name='변경 일자(사용자 변경 등)')),
],
options={
'verbose_name': '자산 정보',
'verbose_name_plural': '자산 정보 목록',
'db_table': 'resource_info',
'managed': False,
},
),
migrations.CreateModel(
name='UserInfo',
fields=[
('user_id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='사용자ID_웹툴전용')),
('user_name', models.CharField(max_length=100, verbose_name='사용자 이름')),
('email_address', models.CharField(max_length=100, verbose_name='사용자 Email주소')),
],
options={
'verbose_name': '사용자 정보',
'verbose_name_plural': '사용자 정보 목록',
'db_table': 'user_info',
'managed': False,
},
),
]

View File

278
apps/web/gyber/models.py Normal file
View File

@ -0,0 +1,278 @@
# /data/gyber/apps/web/gyber/models.py
from django.db import models
from django.utils import timezone # 기본 시간 설정을 위해
# === 핵심 정보 모델 ===
class GroupInfo(models.Model):
group_id = models.BigAutoField(primary_key=True, help_text='그룹 고유 ID (PK)')
group_name = models.CharField(unique=True, max_length=100, help_text='그룹(부서) 이름 (UNIQUE)')
manager_user = models.ForeignKey(
'UserInfo',
models.SET_NULL,
db_column='manager_user_id',
blank=True, null=True, related_name='managed_groups',
verbose_name='그룹 관리자',
help_text='그룹 관리자 사용자 ID (FK, user_info.user_id)'
)
class Meta:
managed = False
db_table = 'group_info'
verbose_name = '그룹(부서) 정보'
verbose_name_plural = '그룹(부서) 정보'
def __str__(self):
return self.group_name
class UserInfo(models.Model):
user_id = models.BigAutoField(primary_key=True, help_text='사용자 고유 ID (PK)')
display_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='표시 이름', help_text='표시 이름 (AD의 displayName)')
account_name = models.CharField(unique=True, max_length=255, verbose_name='계정 이름 (ID)', help_text='계정 이름 (로그인 ID 등, UNIQUE)')
group = models.ForeignKey(
GroupInfo,
models.SET_NULL,
db_column='group_id',
blank=True, null=True,
verbose_name='소속 그룹(부서)',
help_text='소속 그룹 ID (FK, group_info.group_id)'
)
class Meta:
managed = False
db_table = 'user_info'
verbose_name = '사용자 정보'
verbose_name_plural = '사용자 정보'
def __str__(self):
return self.display_name or self.account_name
class ResourceCategory(models.Model):
category_id = models.PositiveSmallIntegerField(primary_key=True, verbose_name='카테고리 ID', help_text='카테고리 ID (PK, 0-255)')
category_name = models.CharField(unique=True, max_length=40, verbose_name='카테고리 이름', help_text='카테고리 이름 (UNIQUE)')
class Meta:
managed = False
db_table = 'resource_category'
verbose_name = '자산 카테고리'
verbose_name_plural = '자산 카테고리'
def __str__(self):
return self.category_name
class ResourceInfo(models.Model):
resource_id = models.BigAutoField(primary_key=True, verbose_name='자산 ID', help_text='자산 고유 ID (PK)')
category = models.ForeignKey(ResourceCategory, models.DO_NOTHING, db_column='category_id', verbose_name='카테고리', help_text='자산 카테고리 ID (FK)')
resource_code = models.CharField(max_length=100, blank=True, null=True, verbose_name='관리 코드', help_text='관리 코드 (자산 번호 등)')
manufacturer = models.CharField(max_length=100, blank=True, null=True, verbose_name='제조사', help_text='제조사')
resource_name = models.CharField(max_length=100, verbose_name='제품명/모델명', help_text='제품명 또는 모델명')
serial_num = models.CharField(unique=True, max_length=200, blank=True, null=True, verbose_name='시리얼 번호', help_text='시리얼 번호 (UNIQUE)')
spec_value = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name='사양 값', help_text='주요 사양 값 (숫자)')
spec_unit = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='사양 단위 ID', help_text='사양 단위 ID')
user = models.ForeignKey(UserInfo, models.SET_NULL, db_column='user_id', blank=True, null=True, verbose_name='할당 사용자', help_text='현재 할당된 사용자 ID (FK)')
comments = models.CharField(max_length=200, blank=True, null=True, verbose_name='비고', help_text='비고 또는 추가 설명')
purchase_date = models.DateField(blank=True, null=True, verbose_name='구매 일자', help_text='구매 일자')
# default=timezone.now 와 editable=False 를 추가하면 Django Admin 등에서 편의성 증가
register_date = models.DateTimeField(default=timezone.now, editable=False, verbose_name='시스템 등록 일시', help_text='시스템 최초 등록 일시')
update_date = models.DateTimeField(blank=True, null=True, editable=False, verbose_name='최종 수정 일시', help_text='최종 정보 수정 일시')
is_locked = models.BooleanField(default=False, verbose_name='자동 동기화 제외 (잠금)', help_text='체크 시 자동 스캔/동기화 대상에서 제외됩니다.') # ★★★ 잠금 필드 추가 ★★★
class Meta:
managed = False
db_table = 'resource_info'
verbose_name = '자산 정보'
verbose_name_plural = '자산 정보'
def __str__(self):
return self.resource_name
# === 추상 기본 로그 모델 ===
class BaseLog(models.Model):
"""모든 로그 테이블의 공통 필드를 정의하는 추상 기본 클래스"""
log_id = models.BigAutoField(primary_key=True)
# default=timezone.now 로 설정하면 모델 생성 시 자동으로 시간 기록 (DB default 와 별개)
log_date = models.DateTimeField(default=timezone.now, editable=False, verbose_name='로그 시간')
# Django User 모델을 직접 참조하는 대신 IntegerField 사용 유지 (Admin 에서 조회 시 성능 이점)
admin_user_id = models.IntegerField(blank=True, null=True, verbose_name='Admin User ID')
actor_description = models.CharField(max_length=100, blank=True, null=True, verbose_name='작업 주체 설명')
class Meta:
abstract = True # 이 모델 자체는 DB 테이블로 생성되지 않음
managed = False # 기본적으로 하위 모델도 DB 스키마 관리 안 함
ordering = ['-log_date'] # 기본 정렬 순서 (최신순)
# actor_info 를 위한 property (Admin 등에서 사용)
@property
def actor_info(self):
if self.admin_user_id:
# 실제로는 여기서 User 모델을 조회해야 함 (성능 주의!)
# from django.contrib.auth import get_user_model
# User = get_user_model()
# try:
# user = User.objects.get(pk=self.admin_user_id)
# return user.username
# except User.DoesNotExist:
# return f"Admin ID: {self.admin_user_id} (삭제됨?)"
return f"Admin ID: {self.admin_user_id}" # 간단히 ID만 표시
return self.actor_description or "System/Unknown"
# === 자산 관련 로그 모델 (BaseLog 상속) ===
class LogAddResource(BaseLog):
resource_id = models.BigIntegerField(verbose_name='자산 ID')
category_id = models.PositiveSmallIntegerField(verbose_name='카테고리 ID')
resource_code = models.CharField(max_length=100, blank=True, null=True, verbose_name='관리 코드')
manufacturer = models.CharField(max_length=100, blank=True, null=True, verbose_name='제조사')
resource_name = models.CharField(max_length=100, verbose_name='제품명')
serial_num = models.CharField(max_length=200, blank=True, null=True, verbose_name='시리얼 번호')
spec_value = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name='사양 값')
spec_unit = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='사양 단위 ID')
user_id = models.BigIntegerField(blank=True, null=True, verbose_name='사용자 ID')
comments = models.CharField(max_length=200, blank=True, null=True, verbose_name='비고')
purchase_date = models.DateField(blank=True, null=True, verbose_name='구매 일자')
register_date = models.DateTimeField(verbose_name='등록 일시')
class Meta(BaseLog.Meta): # 부모 Meta 상속 및 재정의
db_table = 'log_add_resource'
verbose_name = '자산 추가 로그'
verbose_name_plural = '자산 추가 로그'
class LogUpdateResource(BaseLog):
resource_id = models.BigIntegerField(verbose_name='수정된 자산 ID')
category_id = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='변경 후 카테고리 ID')
resource_code = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 후 관리 코드')
manufacturer = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 후 제조사')
resource_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 후 제품명')
serial_num = models.CharField(max_length=200, blank=True, null=True, verbose_name='변경 후 시리얼 번호')
spec_value = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name='변경 후 사양 값')
spec_unit = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='변경 후 사양 단위 ID')
user_id = models.BigIntegerField(blank=True, null=True, verbose_name='변경 후 사용자 ID')
comments = models.CharField(max_length=200, blank=True, null=True, verbose_name='변경 후 비고')
purchase_date = models.DateField(blank=True, null=True, verbose_name='변경 후 구매 일자')
class Meta(BaseLog.Meta):
db_table = 'log_update_resource'
verbose_name = '자산 수정 로그'
verbose_name_plural = '자산 수정 로그'
class LogDeleteResource(BaseLog):
resource_id = models.BigIntegerField(verbose_name='삭제된 자산 ID')
category_id = models.PositiveSmallIntegerField(verbose_name='삭제 시 카테고리 ID')
resource_code = models.CharField(max_length=100, blank=True, null=True, verbose_name='삭제 시 관리 코드')
manufacturer = models.CharField(max_length=100, blank=True, null=True, verbose_name='삭제 시 제조사')
resource_name = models.CharField(max_length=100, verbose_name='삭제 시 제품명')
serial_num = models.CharField(max_length=200, blank=True, null=True, verbose_name='삭제 시 시리얼 번호')
spec_value = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name='삭제 시 사양 값')
spec_unit = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='삭제 시 사양 단위 ID')
user_id = models.BigIntegerField(blank=True, null=True, verbose_name='삭제 시 사용자 ID')
comments = models.CharField(max_length=200, blank=True, null=True, verbose_name='삭제 시 비고')
purchase_date = models.DateField(blank=True, null=True, verbose_name='삭제 시 구매 일자')
register_date = models.DateTimeField(verbose_name='삭제 시 등록 일시')
class Meta(BaseLog.Meta):
db_table = 'log_delete_resource'
verbose_name = '자산 삭제 로그'
verbose_name_plural = '자산 삭제 로그'
# === 사용자 관련 로그 모델 (BaseLog 상속) ===
class LogAddUser(BaseLog):
user_id = models.BigIntegerField(verbose_name='추가된 사용자 ID')
display_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='추가된 표시 이름')
account_name = models.CharField(max_length=255, verbose_name='추가된 계정 이름')
group_id = models.BigIntegerField(blank=True, null=True, verbose_name='추가된 그룹 ID')
class Meta(BaseLog.Meta):
db_table = 'log_add_user'
verbose_name = '사용자 추가 로그'
verbose_name_plural = '사용자 추가 로그'
class LogUpdateUser(BaseLog):
user_id = models.BigIntegerField(verbose_name='수정된 사용자 ID')
old_display_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 전 표시 이름')
old_account_name = models.CharField(max_length=255, blank=True, null=True, verbose_name='변경 전 계정 이름')
old_group_id = models.BigIntegerField(blank=True, null=True, verbose_name='변경 전 그룹 ID')
new_display_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 후 표시 이름')
new_account_name = models.CharField(max_length=255, verbose_name='변경 후 계정 이름')
new_group_id = models.BigIntegerField(blank=True, null=True, verbose_name='변경 후 그룹 ID')
class Meta(BaseLog.Meta):
db_table = 'log_update_user'
verbose_name = '사용자 수정 로그'
verbose_name_plural = '사용자 수정 로그'
class LogDeleteUser(BaseLog):
user_id = models.BigIntegerField(verbose_name='삭제된 사용자 ID')
display_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='삭제 시점 표시 이름')
account_name = models.CharField(max_length=255, verbose_name='삭제 시점 계정 이름')
group_id = models.BigIntegerField(blank=True, null=True, verbose_name='삭제 시점 그룹 ID')
class Meta(BaseLog.Meta):
db_table = 'log_delete_user'
verbose_name = '사용자 삭제 로그'
verbose_name_plural = '사용자 삭제 로그'
# === 그룹 관련 로그 모델 (BaseLog 상속) ===
class LogAddGroup(BaseLog):
group_id = models.BigIntegerField(verbose_name='추가된 그룹 ID')
group_name = models.CharField(max_length=100, verbose_name='추가된 그룹 이름')
manager_user_id = models.BigIntegerField(blank=True, null=True, verbose_name='추가된 그룹 관리자 ID')
class Meta(BaseLog.Meta):
db_table = 'log_add_group'
verbose_name = '그룹 추가 로그'
verbose_name_plural = '그룹 추가 로그'
class LogUpdateGroup(BaseLog):
group_id = models.BigIntegerField(verbose_name='수정된 그룹 ID')
old_group_name = models.CharField(max_length=100, blank=True, null=True, verbose_name='변경 전 그룹 이름')
old_manager_user_id = models.BigIntegerField(blank=True, null=True, verbose_name='변경 전 관리자 ID')
new_group_name = models.CharField(max_length=100, verbose_name='변경 후 그룹 이름')
new_manager_user_id = models.BigIntegerField(blank=True, null=True, verbose_name='변경 후 관리자 ID')
class Meta(BaseLog.Meta):
db_table = 'log_update_group'
verbose_name = '그룹 수정 로그'
verbose_name_plural = '그룹 수정 로그'
class LogDeleteGroup(BaseLog):
group_id = models.BigIntegerField(verbose_name='삭제된 그룹 ID')
group_name = models.CharField(max_length=100, verbose_name='삭제 시점 그룹 이름')
manager_user_id = models.BigIntegerField(blank=True, null=True, verbose_name='삭제 시점 관리자 ID')
class Meta(BaseLog.Meta):
db_table = 'log_delete_group'
verbose_name = '그룹 삭제 로그'
verbose_name_plural = '그룹 삭제 로그'
# === 카테고리 관련 로그 모델 (BaseLog 상속) ===
class LogAddCategory(BaseLog):
category_id = models.PositiveSmallIntegerField(verbose_name='추가된 카테고리 ID')
category_name = models.CharField(max_length=40, verbose_name='추가된 카테고리 이름')
class Meta(BaseLog.Meta):
db_table = 'log_add_category'
verbose_name = '카테고리 추가 로그'
verbose_name_plural = '카테고리 추가 로그'
class LogUpdateCategory(BaseLog):
category_id = models.PositiveSmallIntegerField(verbose_name='수정된 카테고리 ID')
old_category_name = models.CharField(max_length=40, blank=True, null=True, verbose_name='변경 전 카테고리 이름')
new_category_name = models.CharField(max_length=40, verbose_name='변경 후 카테고리 이름')
class Meta(BaseLog.Meta):
db_table = 'log_update_category'
verbose_name = '카테고리 수정 로그'
verbose_name_plural = '카테고리 수정 로그'
class LogDeleteCategory(BaseLog):
category_id = models.PositiveSmallIntegerField(verbose_name='삭제된 카테고리 ID')
category_name = models.CharField(max_length=40, verbose_name='삭제 시점 카테고리 이름')
class Meta(BaseLog.Meta):
db_table = 'log_delete_category'
verbose_name = '카테고리 삭제 로그'
verbose_name_plural = '카테고리 삭제 로그'

117
apps/web/gyber/oidc.py Normal file
View File

@ -0,0 +1,117 @@
# /data/gyber/apps/web/gyber/auth.py (또는 oidc.py)
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from django.contrib.auth.models import User, Group
# from .models import UserInfo # UserInfo 연동 필요 시 임포트
import logging
logger = logging.getLogger('gyber.oidc')
class CustomOIDCAuthenticationBackend(OIDCAuthenticationBackend):
def create_user(self, claims):
"""
OIDC 클레임을 기반으로 새 Django User 객체를 생성합니다.
라이브러리의 기본 create_user 대신 이 메소드가 호출됩니다.
"""
logger.info("=== Custom Backend: create_user called ===")
logger.debug(f"Claims for new user: {claims}")
email = claims.get('email')
if not email:
logger.warning("create_user: 'email' claim not found. Using 'sub'.")
username = claims.get('sub', f"oidc_user_new_{claims.get('sub', 'unknown')}") # 고유 ID 생성
else:
username = email # ★★★ username 에 email 사용 ★★★
try:
user = self.UserModel.objects.create_user(
username=username, # 계산된 username 사용
email=email or '',
first_name=claims.get('given_name', ''),
last_name=claims.get('family_name', '')
)
logger.info(f"Created new Django user: {username} (PK: {user.pk})")
try:
default_group_name = 'Dashboard Viewers'
# 그룹을 가져오거나, 없으면 생성합니다.
dashboard_viewer_group, group_created_now = Group.objects.get_or_create(name=default_group_name)
if group_created_now:
logger.info(f"Default group '{default_group_name}' was newly created.")
else:
logger.debug(f"Default group '{default_group_name}' already exists.")
# ★★★ 사용자를 그룹에 추가 (그룹 존재 여부와 관계없이 항상 실행) ★★★
if not user.groups.filter(name=default_group_name).exists(): # 이미 속해있지 않은 경우에만 추가
user.groups.add(dashboard_viewer_group)
logger.info(f"User '{user.username}' successfully added to '{default_group_name}' group.")
else:
logger.info(f"User '{user.username}' is already a member of '{default_group_name}' group.")
except Exception as e:
logger.error(f"Error during group assignment for new user '{user.username}' to group '{default_group_name}': {e}", exc_info=True)
# --- UserInfo 동기화 로직 (필요시 여기에 추가) ---
# try:
# UserInfo.objects.create(email_address=email, user_name=claims.get('name', ...))
# except Exception as e: logger.error(...)
# --- UserInfo 동기화 끝 ---
except Exception as e:
logger.error(f"Error creating Django user '{username}': {e}", exc_info=True)
# 사용자 생성 실패 시 None을 반환하거나 예외를 다시 발생시킬 수 있음
return None
return user
def update_user(self, user, claims):
"""
기존 Django User 객체를 OIDC 클레임으로 업데이트합니다.
"""
logger.info(f"=== Custom Backend: update_user called for {user.username} ===")
logger.debug(f"Claims for existing user: {claims}")
user.first_name = claims.get('given_name', '')
user.last_name = claims.get('family_name', '')
# email 이나 username 은 보통 업데이트하지 않거나 신중하게 처리
# if claims.get('email'): user.email = claims.get('email')
user.save()
logger.info(f"Updated Django user: {user.username}")
try:
default_group_name = 'Dashboard Viewers'
dashboard_viewer_group, created = Group.objects.get_or_create(name=default_group_name)
if created:
logger.info(f"Default group '{default_group_name}' was created during user update.")
if not user.groups.filter(name=default_group_name).exists():
user.groups.add(dashboard_viewer_group)
logger.info(f"Existing user '{user.username}' added to '{default_group_name}' group.")
else:
logger.debug(f"User '{user.username}' is already in '{default_group_name}' group.")
except Exception as e:
logger.error(f"Error ensuring user '{user.username}' is in default group '{default_group_name}': {e}", exc_info=True)
# --- UserInfo 동기화 로직 (필요시 여기에 추가) ---
# try:
# UserInfo.objects.update_or_create(email_address=user.email, defaults={...})
# except Exception as e: logger.error(...)
# --- UserInfo 동기화 끝 ---
return user
# (선택 사항) 사용자 필터링 로직 변경
# def filter_users_by_claims(self, claims):
# """
# 클레임을 기반으로 기존 사용자를 찾는 로직을 변경 가능.
# 기본값은 OIDC_RP_USERNAME_CLAIM 에 해당하는 필드 검색.
# """
# email = claims.get('email')
# if not email:
# return self.UserModel.objects.none()
# # 예: email 필드로 사용자 찾기
# try:
# return self.UserModel.objects.filter(email__iexact=email)
# except self.UserModel.DoesNotExist:
# return self.UserModel.objects.none()

56
apps/web/gyber/signals.py Normal file
View File

@ -0,0 +1,56 @@
# gyber/signals.py (새 파일 생성)
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import ResourceInfo, LogAddResource, LogDeleteResource
import datetime
@receiver(post_save, sender=ResourceInfo)
def log_resource_save(sender, instance, created, **kwargs):
if created: # 새로 생성된 경우
LogAddResource.objects.create(
log_date=datetime.datetime.now(),
resource_id=instance.resource_id,
category_id=instance.category_id,
resource_code=instance.resource_code,
manufacturer=instance.manufacturer,
resource_name=instance.resource_name,
serial_num=instance.serial_num,
spec_value=instance.spec_value,
spec_unit=instance.spec_unit,
user_id=instance.user.user_id if instance.user else None,
comments=instance.comments,
create_date=instance.create_date
# 필요한 모든 필드 채우기
)
else: # 수정된 경우
# 수정 로그 테이블이 있다면 여기에 기록 (현재 스키마에는 없음)
# 또는 LogAddResource에 수정 플래그를 추가하여 기록할 수도 있음
pass # 현재는 수정 로그 테이블이 없으므로 넘어감
@receiver(post_delete, sender=ResourceInfo)
def log_resource_delete(sender, instance, **kwargs):
LogDeleteResource.objects.create(
log_date=datetime.datetime.now(),
resource_id=instance.resource_id,
category_id=instance.category_id,
resource_code=instance.resource_code,
manufacturer=instance.manufacturer,
resource_name=instance.resource_name,
serial_num=instance.serial_num,
spec_value=instance.spec_value,
spec_unit=instance.spec_unit,
user_id=instance.user.user_id if instance.user else None,
comments=instance.comments,
create_date=instance.create_date
# 필요한 모든 필드 채우기
)
# gyber/apps.py 에 시그널 등록
from django.apps import AppConfig
class GyberConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'gyber'
def ready(self):
import gyber.signals # 시그널 임포트

View File

@ -0,0 +1,49 @@
{# /data/gyber/apps/web/templates/gyber/category_form.html #}
{% extends "base.html" %}
{% load widget_tweaks %} {# widget_tweaks 로드 #}
{% block title %}{% if is_edit_mode %}카테고리 이름 수정{% else %}새 카테고리 추가{% endif %} - Gyber{% endblock %}
{% block content %}
<h1 class="mb-4">
{% if is_edit_mode %}
카테고리 이름 수정 <small class="text-muted">(ID: {{ category_id }})</small>
{% else %}
새 자산 카테고리 추가
{% endif %}
</h1>
{% if is_edit_mode and category_data %}
<p class="lead">현재 이름: <strong>{{ category_data.category_name|default:"-" }}</strong></p>
{% endif %}
{# form action URL (변경 없음) #}
<form method="post" action="{% if is_edit_mode %}{% url 'gyber:category_edit' category_id %}{% else %}{% url 'gyber:category_add' %}{% endif %}" novalidate>
{% csrf_token %}
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %} <p class="mb-0">{{ error }}</p> {% endfor %}
</div>
{% endif %}
{# --- 폼 필드 렌더링 (category_name) --- #}
{% for field in form %} {# CategoryForm에는 category_name 필드 하나만 있음 #}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }} {% if field.field.required %}<span class="text-danger">*</span>{% endif %}</label>
{# ★ widget_tweaks 적용 #}
{% if field.errors %}{{ field|add_class:"form-control is-invalid" }}{% else %}{{ field|add_class:"form-control" }}{% endif %}
{% if field.help_text %}<small class="form-text text-muted">{{ field.help_text }}</small>{% endif %}
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
{# --- 폼 필드 렌더링 끝 --- #}
{# 버튼 #}
<button type="submit" class="btn btn-primary">
{% if is_edit_mode %}이름 수정 완료{% else %}카테고리 추가{% endif %}
</button>
<a href="{% url 'gyber:category_list' %}" class="btn btn-secondary">취소</a>
</form>
{% endblock %}

View File

@ -0,0 +1,67 @@
{# /data/gyber/apps/web/templates/gyber/category_list.html #}
{% extends "base.html" %}
{% load static %} {# static 로드 (필요시) #}
{% block title %}자산 카테고리 관리 - Gyber{% endblock %}
{% block extra_head %}
<style>
.table th { white-space: nowrap; }
.align-middle th, .align-middle td { vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>자산 카테고리 관리</h1>
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:category_add' %}" class="btn btn-success btn-sm"> {# btn-sm 추가 #}
<i class="fas fa-plus"></i> 새 카테고리 추가
</a>
{% endif %}
</div>
{# 카테고리 목록 테이블 #}
<div class="table-responsive">
<table class="table table-striped table-hover table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 10%;">카테고리 ID</th>
<th>카테고리 이름</th>
{# <th>사용 자산 수</th> #} {# 주석 처리 유지 (필요시 뷰/프로시저 수정) #}
<th style="width: 10%;">액션</th>
</tr>
</thead>
<tbody>
{% for category in category_list %}
<tr>
<td>{{ category.category_id }}</td>
<td>{{ category.category_name }}</td>
{# <td>{{ category.asset_count|default:"N/A" }}</td> #} {# 주석 처리 유지 #}
<td class="text-nowrap"> {# 액션 버튼 줄바꿈 방지 #}
{# 카테고리 수정 버튼 #}
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:category_edit' category.category_id %}" class="btn btn-sm btn-outline-secondary me-1" title="카테고리 이름 수정">
<i class="fas fa-edit"></i>
</a>
{# 카테고리 삭제 버튼 #}
<form action="{% url 'gyber:category_delete' category.category_id %}" method="post" class="d-inline" onsubmit="return confirm('정말로 \'{{ category.category_name|escapejs }}\' 카테고리를 삭제하시겠습니까?\n이 카테고리를 사용하는 자산이 없어야 삭제 가능합니다.');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="카테고리 삭제">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
{# ★ colspan 수정 (자산 수 컬럼 주석 처리 시 3개) #}
<td colspan="3" class="text-center">등록된 자산 카테고리가 없습니다.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,53 @@
{# /data/gyber/apps/web/templates/gyber/category_log_list.html #}
{% extends "gyber/log_base.html" %} {# 기본 로그 템플릿 상속 #}
{% block log_page_title %}카테고리 관리 로그{% endblock %}
{% block log_header %}카테고리 관리 로그 목록{% endblock %}
{# 검색 폼은 log_base.html의 기본 폼 사용 #}
{% block log_table_headers %}
{# 카테고리 로그에 맞는 헤더 정의 #}
<tr>
<th style="width: 15%;">시간</th>
<th style="width: 15%;">작업자/주체</th>
<th style="width: 10%;">로그 타입</th>
<th style="width: 10%;">대상 카테고리 ID</th>
<th style="width: 25%;">대상 카테고리 정보 (로그 시점)</th> {# 카테고리 이름 표시 #}
<th>상세 내용</th>
</tr>
{% endblock %}
{% block log_table_body %}
{% for log in audit_logs %}
<tr>
<td>{{ log.log_date|date:"Y-m-d H:i:s" }}</td>
<td class="log-actor" title="{{ log.actor_info|default:"-" }}">{{ log.actor_info|default:"-" }}</td>
<td>
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
</td>
<td>
{% if log.target_id is not None %}
{# 카테고리 ID 클릭 시 카테고리 목록 페이지로? (선택 사항) #}
{{ log.target_id }}
{% else %} - {% endif %}
</td>
{# ★ 필드 이름 확인: target_info_at_log #}
<td>{{ log.target_info_at_log|default:"-" }}</td>
<td class="log-details">{{ log.details|default:"-" }}</td>
</tr>
{% empty %}
{% block log_empty_row %} {# 비어있을 때 메시지 재정의 #}
<tr>
<td colspan="6" class="text-center">카테고리 관련 로그가 없습니다.</td> {# 컬럼 수 6개 #}
</tr>
{% endblock %}
{% endfor %}
{% endblock %}
{# 페이지네이션은 log_base.html 의 기본 UI 사용 #}

View File

@ -0,0 +1,209 @@
{% extends "base.html" %}
{% load static %}
{% block title %}대시보드 - Gyber{% endblock %} {# 타이틀 변경 #}
{% block extra_head %}
{# Chart.js 와 기본 스타일은 base.html 에 포함되어 있다고 가정 #}
{# 만약 base.html 에 없다면 여기서 로드 필요 #}
{# <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> #}
<style>
.card-link:hover { text-decoration: underline; }
.stretched-link::after { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; content: ""; }
.chart-container { position: relative; height: 300px; width: 100%; }
/* 대시보드 카드 높이 일치 및 내용 정렬 */
.dashboard-card { height: 100%; }
.dashboard-card .card-body { display: flex; flex-direction: column; justify-content: center; align-items: center; }
.dashboard-card .card-footer { text-align: center; background-color: transparent !important; border-top: 1px solid rgba(255, 255, 255, 0.2) !important; }
</style>
{% endblock %}
{% block content %}
<h1 class="mb-4">대시보드</h1>
{# --- 요약 정보 카드 --- #}
<div class="row mb-4">
{# 총 자산 수 카드 #}
<div class="col-xl-3 col-md-6 mb-4"> {# 반응형 컬럼 조정 및 mb-4 추가 #}
<div class="card text-white bg-primary dashboard-card"> {# dashboard-card 클래스 추가 #}
<div class="card-body">
<div class="fs-3 fw-bold">{{ summary.total_assets|default:0 }}</div> {# 폰트 크기 조정 #}
<div>총 자산</div> {# 텍스트 위치 조정 #}
</div>
<div class="card-footer">
<a href="{% url 'gyber:resource_list' %}" class="text-white stretched-link card-link">자세히 보기 <i class="fas fa-arrow-circle-right"></i></a>
</div>
</div>
</div>
{# 총 사용자 수 카드 #}
<div class="col-xl-3 col-md-6 mb-4">
<div class="card text-white bg-success dashboard-card">
<div class="card-body">
<div class="fs-3 fw-bold">{{ summary.total_users|default:0 }}</div>
<div>총 사용자</div>
</div>
<div class="card-footer">
{# ★ URL 이름 변경: user_status_list -> user_list #}
<a href="{% url 'gyber:user_list' %}" class="text-white stretched-link card-link">자세히 보기 <i class="fas fa-arrow-circle-right"></i></a>
</div>
</div>
</div>
{# 총 부서 수 카드 #}
<div class="col-xl-3 col-md-6 mb-4">
<div class="card text-white bg-info dashboard-card">
<div class="card-body">
<div class="fs-3 fw-bold">{{ summary.total_groups|default:0 }}</div>
<div>총 그룹(부서)</div> {# 텍스트 수정 #}
</div>
<div class="card-footer">
{# ★ 그룹 목록 페이지 URL 로 변경 #}
<a href="{% url 'gyber:group_list' %}" class="text-white stretched-link card-link">자세히 보기 <i class="fas fa-arrow-circle-right"></i></a>
</div>
</div>
</div>
{# 미할당 자산 수 카드 #}
<div class="col-xl-3 col-md-6 mb-4">
<div class="card text-white bg-warning dashboard-card">
<div class="card-body">
<div class="fs-3 fw-bold">{{ summary.unassigned_assets|default:0 }}</div>
<div>미할당 자산</div>
</div>
<div class="card-footer">
{# ★ 미할당 자산 필터링 URL 수정: user_id=-1 또는 특정 플래그 사용 고려 #}
{# resource_list 뷰에서 user_id=-1 과 같은 값을 미할당 필터로 인식하도록 수정 필요 #}
{# 예시: user_id=-1 을 미할당 필터로 사용 #}
<a href="{% url 'gyber:resource_list' %}?user_id=-1" class="text-white stretched-link card-link">자세히 보기 <i class="fas fa-arrow-circle-right"></i></a>
</div>
</div>
</div>
</div>
{# --- 추가 정보 섹션 (카테고리별 현황, 최근 로그 등) --- #}
<div class="row">
{# 카테고리별 자산 현황 (차트 + 테이블) #}
<div class="col-lg-6 mb-4"> {# 반응형 컬럼 조정 #}
<div class="card h-100">
<div class="card-header">
<i class="fas fa-chart-pie me-1"></i>
카테고리별 자산 현황
</div>
<div class="card-body">
<div class="chart-container mb-3">
<canvas id="categoryChart"></canvas>
</div>
{# 테이블 표시 (변경 없음) #}
{% if category_counts %}
<table class="table table-sm table-hover">
<thead><tr><th>카테고리명</th><th class="text-end">수량</th></tr></thead>
<tbody>
{% for item in category_counts %}
<tr>
<td><a href="{% url 'gyber:resource_list' %}?category={{ item.category_id }}">{{ item.category_name }}</a></td>
<td class="text-end">{{ item.asset_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mt-3 text-center">카테고리별 자산 데이터가 없습니다.</p> {# 가운데 정렬 추가 #}
{% endif %}
</div>
</div>
</div>
{# 최근 활동 로그 (자산 로그 표시) #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-history me-1"></i> 최근 활동 로그 (자산)</span>
{# ★ 전체 자산 로그 보기 링크 (URL 이름 확인) #}
<a href="{% url 'gyber:resource_log_list' %}" class="btn btn-sm btn-outline-secondary">전체 자산 로그 보기 »</a>
</div>
<div class="card-body p-0">
{% if recent_logs %}
<ul class="list-group list-group-flush">
{% for log in recent_logs %}
<li class="list-group-item d-flex justify-content-between align-items-start py-2 px-3">
<div class="ms-1 me-auto">
<small class="text-muted d-block mb-1">{{ log.log_date|date:"Y-m-d H:i" }}</small>
<div>
{# ★ 로그 타입 배지 및 메시지 형식 개선 #}
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
<strong class="ms-1">{{ log.actor_info|default:"Unknown" }}</strong> 님이
{# ★ 상세 내용 표시 (프로시저 반환값 details 사용) #}
<span class="fw-light">{{ log.details|default:"작업을 수행했습니다." }}</span>
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted p-3 text-center">최근 활동 로그가 없습니다.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{# JavaScript (차트 그리는 부분) #}
{% block extra_js %}
{# base.html 에 Chart.js 가 로드되어 있다고 가정 #}
<script>
document.addEventListener("DOMContentLoaded", function() { // DOM 로드 후 실행
const ctx = document.getElementById('categoryChart');
if (ctx) {
try {
const categoryLabels = JSON.parse('{{ category_labels_json|safe }}');
const categoryData = JSON.parse('{{ category_data_json|safe }}');
if (categoryLabels && categoryLabels.length > 0 && categoryData && categoryData.length > 0 && categoryLabels.length === categoryData.length) {
new Chart(ctx, {
type: 'doughnut', // 또는 'pie'
data: {
labels: categoryLabels,
datasets: [{
label: '자산 수',
data: categoryData,
hoverOffset: 4
// 색상 자동 할당됨, 필요시 backgroundColor 배열 지정 가능
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }, // 범례 위치 변경
tooltip: {
callbacks: { // 툴팁 내용 커스텀
label: function(context) {
let label = context.label || '';
if (label) { label += ': '; }
if (context.parsed !== null) { label += context.parsed + '개'; } // '개' 단위 추가
return label;
}
}
}
}
}
});
} else {
console.log("No valid data for category chart.");
// 데이터 없을 때 메시지 표시
ctx.parentElement.innerHTML = '<p class="text-muted text-center my-5">카테고리별 자산 데이터가 없습니다.</p>';
}
} catch (e) {
console.error("Error initializing category chart:", e);
ctx.parentElement.innerHTML = '<p class="text-danger text-center my-5">차트를 불러오는 중 오류가 발생했습니다.</p>';
}
} else {
console.warn("Chart canvas element 'categoryChart' not found.");
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,48 @@
{# /data/gyber/apps/web/templates/gyber/group_form.html #}
{% extends "base.html" %}
{% load widget_tweaks %} {# widget_tweaks 로드 #}
{% block title %}{% if is_edit_mode %}그룹(부서) 수정{% else %}새 그룹(부서) 추가{% endif %} - Gyber{% endblock %}
{% block content %}
<h1 class="mb-4">{% if is_edit_mode %}그룹(부서) 정보 수정{% else %}새 그룹(부서) 추가{% endif %}</h1>
{# form action URL (변경 없음) #}
<form method="post" action="{% if is_edit_mode %}{% url 'gyber:group_edit' group_id %}{% else %}{% url 'gyber:group_add' %}{% endif %}" novalidate>
{% csrf_token %}
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %} <p class="mb-0">{{ error }}</p> {% endfor %}
</div>
{% endif %}
{# --- 폼 필드 렌더링 (수정: manager_user 필드명 사용) --- #}
{# 그룹 이름 #}
<div class="mb-3">
<label for="{{ form.group_name.id_for_label }}" class="form-label">{{ form.group_name.label }}{% if form.group_name.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.group_name.errors %}{{ form.group_name|add_class:"form-control is-invalid" }}{% else %}{{ form.group_name|add_class:"form-control" }}{% endif %}
{% if form.group_name.help_text %}<small class="form-text text-muted">{{ form.group_name.help_text }}</small>{% endif %}
{% for error in form.group_name.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# ★ 그룹 관리자 선택 (필드 이름: manager_user) #}
<div class="mb-3">
<label for="{{ form.manager_user.id_for_label }}" class="form-label">{{ form.manager_user.label }}{% if form.manager_user.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.manager_user.errors %}{{ form.manager_user|add_class:"form-select is-invalid" }}{% else %}{{ form.manager_user|add_class:"form-select" }}{% endif %}
{% if form.manager_user.help_text %}<small class="form-text text-muted">{{ form.manager_user.help_text }}</small>{% endif %}
{% for error in form.manager_user.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# --- 폼 필드 렌더링 끝 --- #}
{# 저장 버튼 #}
<button type="submit" class="btn btn-primary">
{% if is_edit_mode %}수정 완료{% else %}그룹 추가{% endif %}
</button>
{# 취소 버튼 #}
<a href="{% url 'gyber:group_list' %}" class="btn btn-secondary">취소</a>
</form>
{% endblock %}

View File

@ -0,0 +1,75 @@
{# /data/gyber/apps/web/templates/gyber/group_list.html #}
{% extends "base.html" %}
{% load static %} {# static 로드 (필요시) #}
{% block title %}그룹(부서) 관리 - Gyber{% endblock %}
{% block extra_head %}
<style>
/* 필요시 추가 스타일 정의 */
.table th { white-space: nowrap; }
.align-middle th, .align-middle td { vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>그룹(부서) 관리</h1>
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:group_add' %}" class="btn btn-success btn-sm"> {# btn-sm 추가 #}
<i class="fas fa-plus"></i> 새 그룹 추가
</a>
{% endif %}
</div>
{# 그룹 목록 테이블 #}
<div class="table-responsive">
<table class="table table-striped table-hover table-sm align-middle">
<thead class="table-light">
<tr>
<th>그룹 ID</th>
<th>그룹(부서) 이름</th>
{# ★ 그룹 매니저 컬럼 추가 (뷰에서 manager_display_name 또는 유사 정보 전달 필요) #}
<th>그룹 관리자</th>
<th style="width: 10%;">액션</th>
</tr>
</thead>
<tbody>
{% for group in group_list %}
<tr>
<td>{{ group.group_id }}</td>
<td>{{ group.group_name }}</td>
{# ★ 관리자 표시: 뷰에서 group_list 에 manager_display_name 등을 추가해서 전달해야 함 #}
{# 예시: 뷰에서 group_data = get_group_by_id(group.group_id) 로 조회 후 추가 #}
{# 주의: N+1 쿼리 문제 발생 가능성 높음. get_all_groups 프로시저 수정 권장 #}
<td>{{ group.manager_display_name|default:"<span class='text-muted'>미지정</span>"|safe }}</td> {# 임시 표시 #}
<td class="text-nowrap"> {# 액션 버튼 줄바꿈 방지 #}
{# 그룹 수정 버튼 #}
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:group_edit' group.group_id %}" class="btn btn-sm btn-outline-secondary me-1" title="그룹 정보 수정">
<i class="fas fa-edit"></i>
</a>
{# 그룹 삭제 버튼 #}
<form action="{% url 'gyber:group_delete' group.group_id %}" method="post" class="d-inline" onsubmit="return confirm('정말로 \'{{ group.group_name|escapejs }}\' 그룹(부서)을 삭제하시겠습니까?\n소속된 사용자가 없어야 삭제 가능합니다.');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="그룹 삭제">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
{# ★ 컬럼 수에 맞게 colspan 수정 (관리자 컬럼 추가로 4개) #}
<td colspan="4" class="text-center">등록된 그룹(부서) 정보가 없습니다.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# 페이징 UI (현재 미구현, 필요시 추가) #}
{% endblock %}

View File

@ -0,0 +1,55 @@
{# /data/gyber/apps/web/templates/gyber/group_log_list.html #}
{% extends "gyber/log_base.html" %} {# 기본 로그 템플릿 상속 #}
{% block log_page_title %}그룹(부서) 관리 로그{% endblock %}
{% block log_header %}그룹(부서) 관리 로그 목록{% endblock %}
{# 검색 폼은 log_base.html의 기본 폼 사용 #}
{% block log_table_headers %}
{# 그룹 로그에 맞는 헤더 정의 #}
<tr>
<th style="width: 15%;">시간</th>
<th style="width: 15%;">작업자/주체</th>
<th style="width: 10%;">로그 타입</th>
<th style="width: 10%;">대상 그룹 ID</th>
<th style="width: 25%;">대상 그룹 정보 (로그 시점)</th> {# 그룹 이름 표시 #}
<th>상세 내용</th>
</tr>
{% endblock %}
{% block log_table_body %}
{% for log in audit_logs %}
<tr>
<td>{{ log.log_date|date:"Y-m-d H:i:s" }}</td>
<td class="log-actor" title="{{ log.actor_info|default:"-" }}">{{ log.actor_info|default:"-" }}</td>
<td>
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
</td>
<td>
{% if log.target_id %}
{# 그룹 ID 클릭 시 그룹 목록 페이지로? (선택 사항) #}
{# <a href="{% url 'gyber:group_list' %}?query={{ log.target_id }}"> #}
{{ log.target_id }}
{# </a> #}
{% else %} - {% endif %}
</td>
{# ★ 필드 이름 확인: target_info_at_log #}
<td>{{ log.target_info_at_log|default:"-" }}</td>
<td class="log-details">{{ log.details|default:"-" }}</td>
</tr>
{% empty %}
{% block log_empty_row %} {# 비어있을 때 메시지 재정의 #}
<tr>
<td colspan="6" class="text-center">그룹(부서) 관련 로그가 없습니다.</td> {# 컬럼 수 6개 #}
</tr>
{% endblock %}
{% endfor %}
{% endblock %}
{# 페이지네이션은 log_base.html 의 기본 UI 사용 #}

View File

@ -0,0 +1,148 @@
{# /data/gyber/apps/web/templates/gyber/log_base.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}{% block log_page_title %}감사 로그{% endblock %} - Gyber{% endblock %}
{% block extra_head %}
<style>
/* 로그 테이블 스타일 */
.log-table th, .log-table td { vertical-align: middle; }
.log-table th { white-space: nowrap; }
.log-details { white-space: pre-wrap; word-break: break-word; } /* 상세 내용 줄바꿈 처리 */
.log-actor { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 작업자 이름 길 경우 처리 */
/* 검색 폼 라벨 스타일 (선택 사항) */
.log-filter-form .form-label {
font-size: 0.875rem; /* 약간 작은 폰트 */
/* margin-bottom: 0.25rem; */ /* 라벨과 입력 필드 사이 간격 조절 */
}
</style>
{% block log_extra_head %}{% endblock %} {# 각 페이지별 추가 스타일 #}
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{% block log_header %}{{ log_type_title|default:"감사" }} 로그 목록{% endblock %}</h1>
{% block log_header_actions %}{% endblock %} {# 추가 버튼 등 #}
</div>
{# === 검색 및 필터 폼 블록 (수정됨) === #}
{% block log_filters %}
{# 검색 폼: 가독성 및 접근성 개선 #}
<form method="get" class="mb-4 p-3 border rounded bg-body-tertiary log-filter-form"> {# 폼 배경 및 패딩, 클래스 추가 #}
<div class="row g-3 align-items-end"> {# g-3: 컬럼 간 간격, align-items-end: 버튼 정렬 #}
{# 검색어 입력 #}
<div class="col-lg-4 col-md-12"> {# 넓은 화면에서는 1/3, 중간 화면에서는 전체 너비 #}
<label for="query" class="form-label fw-semibold">검색어</label> {# 강조 추가 #}
<input type="text" class="form-control form-control-sm" id="query" name="query" placeholder="작업자, 대상 ID/정보, 상세 내용 등" value="{{ search_query|default:'' }}">
</div>
{# 시작일 입력 #}
<div class="col-lg-3 col-md-4"> {# 화면 크기에 따른 너비 조정 #}
<label for="start_date" class="form-label fw-semibold">조회 시작일</label>
<input type="date" class="form-control form-control-sm" id="start_date" name="start_date" value="{{ start_date|default:'' }}">
</div>
{# 종료일 입력 #}
<div class="col-lg-3 col-md-4">
<label for="end_date" class="form-label fw-semibold">조회 종료일</label>
<input type="date" class="form-control form-control-sm" id="end_date" name="end_date" value="{{ end_date|default:'' }}">
</div>
{# 버튼 영역 #}
<div class="col-lg-2 col-md-4 text-end text-lg-start"> {# 정렬 조정 #}
<button type="submit" class="btn btn-primary btn-sm w-100 mb-1 mb-md-0"><i class="fas fa-search"></i> 검색</button> {# 버튼 너비 조정 #}
{# 검색 조건 있을 때만 초기화 버튼 표시 #}
{% if search_query or start_date or end_date %}
<a href="{% url request.resolver_match.view_name %}" class="btn btn-secondary btn-sm w-100"><i class="fas fa-times"></i> 초기화</a> {# 버튼 너비 조정 #}
{% endif %}
</div>
</div> {# end of .row #}
</form>
{% endblock %}
{# === 검색 폼 블록 끝 === #}
{# 결과 수 표시 (페이지네이션과 연동) #}
{% if total_count >= 0 %} {# total_count가 유효할 때만 표시 #}
<p class="text-muted mb-2">
{% if search_query or start_date or end_date %}
검색/필터 결과: {{ total_count }} 건
{% else %}
총 {{ total_count }} 건의 로그
{% endif %}
{% if total_pages > 0 %} (페이지 {{ current_page }} / {{ total_pages }}) {% endif %}
</p>
{% endif %}
<div class="table-responsive">
<table class="table table-striped table-hover table-sm log-table">
<thead class="table-light">
{# 테이블 헤더: 각 로그 페이지에서 재정의 #}
{% block log_table_headers %}
<tr>
<th style="width: 15%;">시간</th>
<th style="width: 15%;">작업자/주체</th>
<th style="width: 10%;">로그 타입</th>
<th>상세 내용</th>
</tr>
{% endblock %}
</thead>
<tbody>
{# 테이블 본문: 각 로그 페이지에서 재정의 #}
{% block log_table_body %}
{% for log in audit_logs %}
{# 기본 로그 표시 (자식 템플릿에서 재정의 안 할 경우 대비) #}
<tr>
<td>{{ log.log_date|date:"Y-m-d H:i:s" }}</td>
<td class="log-actor" title="{{ log.actor_info|default:"-" }}">{{ log.actor_info|default:"-" }}</td>
<td>
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
</td>
<td class="log-details">{{ log.details|default:"-" }}</td>
</tr>
{% empty %}
{# 비어있을 경우 메시지: 각 로그 페이지에서 재정의 #}
{% block log_empty_row %}
<tr>
{# 기본 colspan은 4, 자식 템플릿에서 컬럼 수에 맞게 재정의 필요 #}
<td colspan="4" class="text-center">로그가 없습니다.</td>
</tr>
{% endblock %}
{% endfor %}
{% endblock %}
</tbody>
</table>
</div>
{# === 페이지네이션 UI 블록 === #}
{% block pagination %}
{% if total_pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item {% if current_page == 1 %}disabled{% endif %}"><a class="page-link" href="?page=1&{{ query_params_all }}" aria-label="First">««</a></li>
<li class="page-item {% if not has_previous %}disabled{% endif %}"><a class="page-link" href="?page={{ previous_page_number }}&{{ query_params_all }}" aria-label="Previous">«</a></li>
{% for num in page_numbers %}
<li class="page-item {% if num == current_page %}active{% endif %}">
{% if num == current_page %}<span class="page-link">{{ num }}</span>
{% else %}<a class="page-link" href="?page={{ num }}&{{ query_params_all }}">{{ num }}</a>
{% endif %}
</li>
{% endfor %}
<li class="page-item {% if not has_next %}disabled{% endif %}"><a class="page-link" href="?page={{ next_page_number }}&{{ query_params_all }}" aria-label="Next">»</a></li>
<li class="page-item {% if current_page == total_pages %}disabled{% endif %}"><a class="page-link" href="?page={{ total_pages }}&{{ query_params_all }}" aria-label="Last">»»</a></li>
</ul>
</nav>
{% endif %}
{% endblock %}
{% endblock %}
{% block extra_js %}
{% block log_extra_js %}{% endblock %} {# 각 페이지별 추가 JS #}
{% endblock %}

View File

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}자산 상세: {{ resource.resource_name|default:"정보 없음" }} - Gyber{% endblock %}
{% block content %}
{% if resource %}
{# Bootstrap card 컴포넌트 사용 - 구조 개선 #}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">자산 상세 정보 - {{ resource.resource_name }} (ID: {{ resource.resource_id }})</h5>
<div>
<a href="{% url 'gyber:resource_edit' resource.resource_id %}" class="btn btn-sm btn-warning me-1" title="수정하기"><i class="fas fa-edit"></i> 수정</a>
<button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ resource.resource_id }}" title="삭제하기"><i class="fas fa-trash-alt"></i> 삭제</button>
</div>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">자산 ID</dt>
<dd class="col-sm-9">{{ resource.resource_id }}</dd>
<dt class="col-sm-3">제품명/모델명</dt> {# 라벨 변경 #}
<dd class="col-sm-9">{{ resource.resource_name }}</dd>
<dt class="col-sm-3">자동 동기화 상태</dt>
<dd class="col-sm-9">
{% if resource.is_locked %}
<span class="text-danger"><strong>잠김 (자동 동기화 제외)</strong></span>
{% else %}
<span class="text-success">활성 (자동 동기화 대상)</span>
{% endif %}
</dd>
<dt class="col-sm-3">카테고리</dt>
<dd class="col-sm-9">{{ resource.category_name|default:"-" }}</dd>
<dt class="col-sm-3">관리 코드</dt>
<dd class="col-sm-9">{{ resource.resource_code|default:"-" }}</dd>
<dt class="col-sm-3">제조사</dt>
<dd class="col-sm-9">{{ resource.manufacturer|default:"-" }}</dd>
<dt class="col-sm-3">시리얼 번호</dt>
<dd class="col-sm-9">{{ resource.serial_num|default:"-" }}</dd>
<dt class="col-sm-3">사양</dt>
<dd class="col-sm-9">
{% if resource.spec_value is not None %}
{{ resource.spec_value }}
{% if resource.spec_unit == 1 %} MHz
{% elif resource.spec_unit == 2 %} MB
{% elif resource.spec_unit == 3 %} GB
{% elif resource.spec_unit == 4 %} TB
{% elif resource.spec_unit == 5 %} PB
{% elif resource.spec_unit == 6 %} Inch
{% else %} (단위: {{ resource.spec_unit }})
{% endif %}
{% else %} - {% endif %}
</dd>
{# ★ 사용자 정보 표시 (user_display_name 사용) #}
<dt class="col-sm-3">현재 사용자</dt>
<dd class="col-sm-9">{{ resource.user_display_name|default:"<span class='text-muted'>미지정</span>"|safe }}</dd>
<dt class="col-sm-3">사용자 부서</dt>
<dd class="col-sm-9">{{ resource.group_name|default:"-" }}</dd>
{# ★ 날짜 필드 변경 (purchase_date, register_date, update_date) #}
<dt class="col-sm-3">구매 일자</dt>
<dd class="col-sm-9">{{ resource.purchase_date|date:"Y-m-d"|default:"-" }}</dd>
<dt class="col-sm-3">시스템 등록 일시</dt>
<dd class="col-sm-9">{{ resource.register_date|date:"Y-m-d H:i:s"|default:"-" }}</dd> {# 시간까지 표시 #}
<dt class="col-sm-3">최종 수정 일시</dt>
<dd class="col-sm-9">{{ resource.update_date|date:"Y-m-d H:i:s"|default:"-" }}</dd> {# 시간까지 표시 #}
<dt class="col-sm-3">비고</dt> {# 라벨 변경 #}
<dd class="col-sm-9">{{ resource.comments|linebreaksbr|default:"-" }}</dd> {# linebreaksbr 필터 사용 유지 #}
</dl>
</div>
<div class="card-footer text-end"> {# 카드 푸터 추가 #}
<a href="{% url 'gyber:resource_list' %}" class="btn btn-secondary">목록으로</a>
</div>
</div>
{# 삭제 확인 모달 (구조는 resource_list.html 과 동일하게 유지) #}
<div class="modal fade" id="deleteModal{{ resource.resource_id }}" tabindex="-1" aria-labelledby="deleteModalLabel{{ resource.resource_id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel{{ resource.resource_id }}">자산 삭제 확인</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p><strong>ID:</strong> {{ resource.resource_id }}</p>
<p><strong>자산명:</strong> {{ resource.resource_name }}</p>
<p class="text-danger">이 자산을 정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<form action="{% url 'gyber:resource_delete' resource.resource_id %}" method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">삭제 실행</button>
</form>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning" role="alert">
해당 자산 정보를 찾을 수 없습니다.
</div>
<a href="{% url 'gyber:resource_list' %}" class="btn btn-secondary">목록으로 돌아가기</a>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,215 @@
{# /data/gyber/apps/web/templates/gyber/resource_form.html #}
{% extends "base.html" %}
{% load widget_tweaks %}
{% block title %}
{% if is_edit_mode %} 자산 수정 (ID: {{ resource_id }}) {% else %} 새 자산 추가 {% endif %} - Gyber
{% endblock %}
{% block content %}
{% if is_edit_mode %}
<h1>자산 수정 (ID: {{ resource_id }})</h1>
{% else %}
<h1>새 자산 추가</h1>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %} <p class="mb-0">{{ error }}</p> {% endfor %}
</div>
{% endif %}
{# --- 폼 필드 렌더링 (수정: purchase_date 추가, create_date 제거) --- #}
{# 카테고리 #}
<div class="mb-3">
<label for="{{ form.category.id_for_label }}" class="form-label">{{ form.category.label }}{% if form.category.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{# is_invalid 클래스 추가 위해 조건 분기 #}
{% if form.category.errors %}
{{ form.category|add_class:"form-select is-invalid" }}
{% else %}
{{ form.category|add_class:"form-select" }} {# add_class 필터 사용 #}
{% endif %}
{% if form.category.help_text %}<small class="form-text text-muted">{{ form.category.help_text }}</small>{% endif %}
{% for error in form.category.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# 제품명 #}
<div class="mb-3">
<label for="{{ form.resource_name.id_for_label }}" class="form-label">{{ form.resource_name.label }}{% if form.resource_name.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.resource_name.errors %}{{ form.resource_name|add_class:"form-control is-invalid" }}{% else %}{{ form.resource_name|add_class:"form-control" }}{% endif %}
{% if form.resource_name.help_text %}<small class="form-text text-muted">{{ form.resource_name.help_text }}</small>{% endif %}
{% for error in form.resource_name.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# 관리 코드 #}
<div class="mb-3">
<label for="{{ form.resource_code.id_for_label }}" class="form-label">{{ form.resource_code.label }}{% if form.resource_code.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.resource_code.errors %}{{ form.resource_code|add_class:"form-control is-invalid" }}{% else %}{{ form.resource_code|add_class:"form-control" }}{% endif %}
{% for error in form.resource_code.errors %}<div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
{# 제조사 #}
<div class="mb-3">
<label for="{{ form.manufacturer.id_for_label }}" class="form-label">{{ form.manufacturer.label }}{% if form.manufacturer.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.manufacturer.errors %}{{ form.manufacturer|add_class:"form-control is-invalid" }}{% else %}{{ form.manufacturer|add_class:"form-control" }}{% endif %}
{% for error in form.manufacturer.errors %}<div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
{# 시리얼 번호 #}
<div class="mb-3">
<label for="{{ form.serial_num.id_for_label }}" class="form-label">{{ form.serial_num.label }}{% if form.serial_num.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.serial_num.errors %}{{ form.serial_num|add_class:"form-control is-invalid" }}{% else %}{{ form.serial_num|add_class:"form-control" }}{% endif %}
{% for error in form.serial_num.errors %}<div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
{# 사양 값 & 단위 #}
<div class="row mb-3">
<div class="col-md-8">
<label for="{{ form.spec_value.id_for_label }}" class="form-label">{{ form.spec_value.label }}{% if form.spec_value.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.spec_value.errors %}{{ form.spec_value|add_class:"form-control is-invalid" }}{% else %}{{ form.spec_value|add_class:"form-control" }}{% endif %}
{% for error in form.spec_value.errors %} <div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
<div class="col-md-4">
<label for="{{ form.spec_unit.id_for_label }}" class="form-label">{{ form.spec_unit.label }}{% if form.spec_unit.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.spec_unit.errors %}{{ form.spec_unit|add_class:"form-select is-invalid" }}{% else %}{{ form.spec_unit|add_class:"form-select" }}{% endif %}
{% for error in form.spec_unit.errors %} <div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
</div>
{# ★ 변경: purchase_date 필드 렌더링 #}
<div class="mb-3">
<label for="{{ form.purchase_date.id_for_label }}" class="form-label">{{ form.purchase_date.label }}{% if form.purchase_date.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.purchase_date.errors %}{{ form.purchase_date|add_class:"form-control is-invalid" }}{% else %}{{ form.purchase_date|add_class:"form-control" }}{% endif %}
{% if form.purchase_date.help_text %}<small class="form-text text-muted">{{ form.purchase_date.help_text }}</small>{% endif %}
{% for error in form.purchase_date.errors %} <div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
{# ★★★ 사용자 선택 필드 (Select2 적용) ★★★ #}
<div class="mb-3">
<label for="user_search_box" class="form-label">{{ form.user.label }}</label>
{# 실제 user_id를 저장할 숨겨진 필드 #}
{{ form.user }} {# widget=forms.HiddenInput() 으로 설정됨 #}
{# Select2를 적용할 텍스트 입력 필드처럼 보이는 select 요소 #}
<select id="user_search_box" name="user_search_box_display_temp" class="form-control"> {# name을 form.user와 다르게 하여 직접 제출 방지 #}
{# ★★★ 수정 모드에서 초기 선택된 사용자 표시 ★★★ #}
{% if is_edit_mode and initial_selected_user_id and initial_selected_user_name %}
<option value="{{ initial_selected_user_id }}" selected="selected">{{ initial_selected_user_name }}</option>
{% elif not form.initial.user %} {# 추가 모드이거나, 수정 모드에서 사용자가 없을 경우 #}
<option value="" selected="selected">사용자 이름 또는 계정으로 검색...</option>
{% endif %}
</select>
{% if form.user.help_text %}<small class="form-text text-muted">{{ form.user.help_text }}</small>{% endif %}
{% for error in form.user.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# ★★★ is_locked 필드 (체크박스) 렌더링 수정 ★★★ #}
<div class="mb-3 form-check">
{% if form.is_locked.errors %}
{% render_field form.is_locked class="form-check-input is-invalid" %} {# 오류 시 is-invalid 추가 #}
{% else %}
{% render_field form.is_locked class="form-check-input" %} {# 기본 클래스 #}
{% endif %}
<label class="form-check-label" for="{{ form.is_locked.id_for_label }}">
{{ form.is_locked.label }}
</label>
{% if form.is_locked.help_text %}
<small class="form-text text-muted d-block">{{ form.is_locked.help_text }}</small>
{% endif %}
{% for error in form.is_locked.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
{# 비고 #}
<div class="mb-3">
<label for="{{ form.comments.id_for_label }}" class="form-label">{{ form.comments.label }}{% if form.comments.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.comments.errors %}{{ form.comments|add_class:"form-control is-invalid" }}{% else %}{{ form.comments|add_class:"form-control" }}{% endif %}
{% if form.comments.help_text %}<small class="form-text text-muted">{{ form.comments.help_text }}</small>{% endif %}
{% for error in form.comments.errors %} <div class="invalid-feedback d-block">{{ error }}</div> {% endfor %}
</div>
{# ★ 제거: create_date 필드 렌더링 제거됨 #}
{# --- 폼 필드 렌더링 끝 --- #}
{# 제출 버튼 및 취소 버튼 #}
<div class="mt-4">
<button type="submit" class="btn btn-primary">저장하기</button>
<a href="{% url 'gyber:resource_list' %}" class="btn btn-secondary">취소</a>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> {# jQuery 로드 (Select2 의존성) #}
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script> {# Select2 JS 로드 #}
<script>
$(document).ready(function() {
var initialUserIdForSelect2 = $("#{{ form.user.id_for_label }}").val(); // 숨겨진 필드의 초기 ID 값
$('#user_search_box').select2({
placeholder: '사용자 이름 또는 계정으로 검색',
allowClear: true,
minimumInputLength: 1,
ajax: {
url: "{% url 'gyber:api_search_users' %}",
dataType: 'json',
delay: 250,
data: function (params) {
return {
term: params.term,
page: params.page || 1
};
},
processResults: function (data, params) {
params.page = params.page || 1;
return {
results: data.results, // API가 {'results': [...], 'pagination': {'more': ...}} 반환 가정
pagination: {
more: data.pagination && data.pagination.more // API 응답의 pagination.more 사용
}
};
},
cache: true
},
theme: "bootstrap-5", // ★★★ Bootstrap 5 테마 적용 ★★★
width: '100%' // ★★★ 부모 요소 너비에 맞춤 (form-control 과 유사한 효과) ★★★
});
// Select2에서 사용자를 선택하면 숨겨진 user 필드(ID)에 값을 설정
$('#user_search_box').on('select2:select', function (e) {
var data = e.params.data;
$("#{{ form.user.id_for_label }}").val(data.id); // 숨겨진 input에 ID 저장
});
// Select2에서 선택을 해제하면 숨겨진 user 필드 값을 비움
$('#user_search_box').on('select2:unselect', function (e) {
$("#{{ form.user.id_for_label }}").val(''); // 숨겨진 input 값 비우기
});
// (선택 사항) 수정 모드에서 초기값 설정 - 템플릿 <option selected>으로 처리했다면 불필요할 수 있음
// var initialUserId = $("#{{ form.user.id_for_label }}").val();
// if (initialUserId && $('#user_search_box').val() !== initialUserId) {
// // 만약 초기 <option>이 없고, initialUserId가 있다면 AJAX로 사용자 정보를 가져와
// // 새로운 <option>을 만들고 선택된 상태로 만들어야 함.
// // 예:
// // $.ajax({
// // type: 'GET',
// // url: '/api/users/' + initialUserId + '/' // 특정 사용자 정보를 가져오는 API (별도 구현 필요)
// // }).then(function (data) {
// // var option = new Option(data.text, data.id, true, true);
// // $('#user_search_box').append(option).trigger('change');
// // $('#user_search_box').trigger({
// // type: 'select2:select',
// // params: { data: data }
// // });
// // });
// }
});
</script>
{% endblock %}

View File

@ -0,0 +1,88 @@
{# /data/gyber/apps/web/templates/gyber/resource_list.html #}
{% extends "base.html" %}
{% load static %}
{% load l10n %} {# localize 필터 사용 위해 #}
{% block title %}자산 목록 - Gyber{% endblock %}
{% block extra_head %}
<style>
/* 스타일 유지 */
.table th { white-space: nowrap; }
@media (max-width: 767.98px) { .hide-on-mobile { display: none !important; } }
.controls-table { table-layout: auto; }
.controls-table td { vertical-align: bottom; padding-top: 0; padding-bottom: 0.25rem; padding-right: 1rem; }
.controls-table tr:first-child td { padding-bottom: 0.1rem; }
.controls-table td:last-child { padding-right: 0; }
.controls-table .form-text { margin-top: 0.1rem; margin-bottom: 0; }
.ellipsis-cell { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-filter-info { background-color: var(--bs-info-bg-subtle); border: 1px solid var(--bs-info-border-subtle); padding: 0.5rem 1rem; border-radius: 0.375rem; margin-bottom: 1rem; display: inline-block; }
.sort-icon { margin-left: 0.3em; opacity: 0.7; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>자산 목록</h1>
{# ★★★ 버튼들을 그룹화하기 위한 div 추가 또는 기존 div 활용 ★★★ #}
<div>
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:resource_add' %}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> 새 자산 추가
</a>
{% endif %}
{# ★★★ CSV 내보내기 버튼을 "새 자산 추가" 버튼 옆으로 이동 ★★★ #}
<a href="{% url 'gyber:export_resources_csv' %}?{{ request.GET.urlencode }}" class="btn btn-outline-success btn-sm ms-2"> {# ms-2 로 왼쪽 마진 추가 #}
<i class="fas fa-file-csv"></i> CSV 내보내기
</a>
</div>
</div>
{# 사용자 필터 정보 표시 #}
{% if current_user_id and current_user_id > 0 %}
<div class="user-filter-info">
<i class="fas fa-user-check me-2"></i>
<strong>{% if filtered_user_info %}{{ filtered_user_info.display_name|default:filtered_user_info.account_name|default:current_user_id }}{% else %}사용자 ID {{ current_user_id }}{% endif %}</strong> 님의 자산만 표시 중입니다.
<a href="{% url 'gyber:resource_list' %}" class="btn btn-sm btn-outline-secondary ms-2" title="모든 필터 해제 및 전체 자산 보기"><i class="fas fa-times"></i> 전체 보기</a>
</div>
{% elif current_user_id == -1 %}
<div class="user-filter-info">
<i class="fas fa-user-slash me-2"></i> <strong>미할당</strong> 자산만 표시 중입니다.
<a href="{% url 'gyber:resource_list' %}" class="btn btn-sm btn-outline-secondary ms-2" title="모든 필터 해제 및 전체 자산 보기"><i class="fas fa-times"></i> 전체 보기</a>
</div>
{% endif %}
{# === 상단 컨트롤 영역 포함 === #}
{% include "includes/gyber/resource_controls.html" with request=request search_query=search_query page_size=page_size sort_by=sort_by sort_dir=sort_dir query_params_no_search=query_params_no_search query_params_no_filter=query_params_no_filter current_category=current_category category_list=category_list current_group=current_group group_list=group_list valid_page_sizes=valid_page_sizes current_user_id=current_user_id %}
{# === 결과 수 표시 === #}
<p class="text-muted mb-2">
{% if total_count >= 0 %}
{% if search_query or current_category or current_group or current_user_id %}
검색/필터 결과: {{ total_count|localize }} 건
{% else %}
총 {{ total_count|localize }} 건의 자산
{% endif %}
{% if total_pages > 0 %} (페이지 {{ current_page }} / {{ total_pages }}) {% endif %}
{% else %}
검색/필터 결과: (개수 정보 없음)
{% endif %}
</p>
{# === 자산 목록 테이블 포함 === #}
{% include "includes/gyber/resource_table.html" with resource_list=resource_list query_params_all=query_params_all sort_by=sort_by sort_dir=sort_dir search_query=search_query current_category=current_category current_group=current_group current_user_id=current_user_id %}
{# === 페이지네이션 UI 포함 === #}
{% include "includes/pagination.html" with current_page=current_page total_pages=total_pages page_numbers=page_numbers has_previous=has_previous previous_page_number=previous_page_number has_next=has_next next_page_number=next_page_number query_params_all=query_params_all %}
{# === 삭제 확인 모달 포함 === #}
{% for resource_item in resource_list %}
{% url 'gyber:resource_delete' resource_item.resource_id as delete_url %}
{# ★ 수정: 모달 ID 접두사와 item_id 만 전달 #}
{% include "includes/confirm_delete_modal.html" with modal_id_prefix="resource-delete-modal-" item_id=resource_item.resource_id item_name=resource_item.resource_name item_type="자산" delete_url=delete_url %}
{% endfor %}
{% endblock %}
{% block extra_js %}
{# JavaScript는 특별히 필요하지 않음 #}
{% endblock %}

View File

@ -0,0 +1,55 @@
{# /data/gyber/apps/web/templates/gyber/resource_log_list.html #}
{% extends "gyber/log_base.html" %} {# 기본 로그 템플릿 상속 #}
{% block log_page_title %}자산 감사 로그{% endblock %}
{% block log_header %}자산 감사 로그 목록{% endblock %}
{# 검색 폼은 log_base.html의 기본 폼 사용 (log_filters 블록 재정의 안 함) #}
{% block log_table_headers %}
{# 자산 로그에 맞는 헤더 정의 #}
<tr>
<th style="width: 15%;">시간</th>
<th style="width: 15%;">작업자/주체</th>
<th style="width: 10%;">로그 타입</th>
<th style="width: 10%;">자산 ID</th>
<th style="width: 20%;">자산 정보 (로그 시점)</th> {# 이름 + 시리얼 번호 표시 가능 #}
<th>상세 내용</th> {# 상세 내용 컬럼 너비 자동 조절 #}
</tr>
{% endblock %}
{% block log_table_body %}
{% for log in audit_logs %}
<tr>
<td>{{ log.log_date|date:"Y-m-d H:i:s" }}</td>
<td class="log-actor" title="{{ log.actor_info|default:"-" }}">{{ log.actor_info|default:"-" }}</td>
<td>
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
</td>
<td>
{% if log.target_id %}
<a href="{% url 'gyber:resource_detail' log.target_id %}" title="자산 상세 보기">{{ log.target_id }}</a>
{% else %} - {% endif %}
</td>
{# 대상 정보: 이름과 시리얼 함께 표시 #}
<td>
{{ log.target_info_at_log|default:"-" }}
{% if log.serial_num %} <small class="text-muted"> (SN: {{ log.serial_num }})</small>{% endif %}
</td>
<td class="log-details">{{ log.details|default:"-" }}</td>
</tr>
{% empty %}
{% block log_empty_row %} {# 비어있을 때 메시지 재정의 #}
<tr>
<td colspan="6" class="text-center">자산 관련 로그가 없습니다.</td> {# 컬럼 수 6개 #}
</tr>
{% endblock %}
{% endfor %}
{% endblock %}
{# 페이지네이션은 log_base.html 의 기본 UI 사용 (pagination 블록 재정의 안 함) #}

View File

@ -0,0 +1,66 @@
{# /data/gyber/apps/web/templates/gyber/user_form.html #}
{% extends "base.html" %}
{% load widget_tweaks %} {# widget_tweaks 로드 추가 #}
{% block title %}{% if is_edit_mode %}사용자 수정{% else %}사용자 추가{% endif %} - Gyber{% endblock %}
{% block content %}
<h1 class="mb-4">{% if is_edit_mode %}사용자 정보 수정{% else %}새 사용자 추가{% endif %}</h1>
{# form action URL 은 user_add 또는 user_edit (변경 없음) #}
<form method="post" action="{% if is_edit_mode %}{% url 'gyber:user_edit' user_id %}{% else %}{% url 'gyber:user_add' %}{% endif %}" novalidate>
{% csrf_token %}
{# Non-field errors #}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %} <p class="mb-0">{{ error }}</p> {% endfor %}
</div>
{% endif %}
{# --- 폼 필드 렌더링 (수정: display_name, account_name 사용, email_address 제거됨) --- #}
{# ★ 필드 이름 변경: display_name #}
<div class="mb-3">
<label for="{{ form.display_name.id_for_label }}" class="form-label">{{ form.display_name.label }}{% if form.display_name.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.display_name.errors %}{{ form.display_name|add_class:"form-control is-invalid" }}{% else %}{{ form.display_name|add_class:"form-control" }}{% endif %}
{% if form.display_name.help_text %}<small class="form-text text-muted">{{ form.display_name.help_text }}</small>{% endif %}
{% for error in form.display_name.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# ★ 필드 이름 변경: account_name #}
<div class="mb-3">
<label for="{{ form.account_name.id_for_label }}" class="form-label">{{ form.account_name.label }}{% if form.account_name.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.account_name.errors %}{{ form.account_name|add_class:"form-control is-invalid" }}{% else %}{{ form.account_name|add_class:"form-control" }}{% endif %}
{% if form.account_name.help_text %}<small class="form-text text-muted">{{ form.account_name.help_text }}</small>{% endif %}
{% for error in form.account_name.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# ★ 제거: email_address 필드 (forms.py에서 주석 처리 시) #}
{# {% if form.email_address %}
<div class="mb-3">
<label for="{{ form.email_address.id_for_label }}" class="form-label">{{ form.email_address.label }}{% if form.email_address.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.email_address.errors %}{{ form.email_address|add_class:"form-control is-invalid" }}{% else %}{{ form.email_address|add_class:"form-control" }}{% endif %}
{% if form.email_address.help_text %}<small class="form-text text-muted">{{ form.email_address.help_text }}</small>{% endif %}
{% for error in form.email_address.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{% endif %} #}
{# 그룹 #}
<div class="mb-3">
<label for="{{ form.group.id_for_label }}" class="form-label">{{ form.group.label }}{% if form.group.field.required %} <span class="text-danger">*</span>{% endif %}</label>
{% if form.group.errors %}{{ form.group|add_class:"form-select is-invalid" }}{% else %}{{ form.group|add_class:"form-select" }}{% endif %}
{% if form.group.help_text %}<small class="form-text text-muted">{{ form.group.help_text }}</small>{% endif %}
{% for error in form.group.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{# --- 폼 필드 렌더링 끝 --- #}
{# 저장 버튼 #}
<button type="submit" class="btn btn-primary">
{% if is_edit_mode %}수정 완료{% else %}사용자 추가{% endif %}
</button>
{# ★ 취소 버튼 URL 변경: user_status_list -> user_list #}
<a href="{% url 'gyber:user_list' %}" class="btn btn-secondary">취소</a>
</form>
{% endblock %}

View File

@ -0,0 +1,64 @@
{# /data/gyber/apps/web/templates/gyber/user_list.html #}
{% extends "base.html" %}
{% load static %}
{% load l10n %} {# localize 필터 #}
{% block title %}사용자 목록 - Gyber{% endblock %}
{% block extra_head %}
<style>
.sort-icon { margin-left: 0.3em; opacity: 0.7; }
.align-middle th, .align-middle td { vertical-align: middle; }
@media (max-width: 767.98px) { .hide-on-mobile { display: none !important; } }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>사용자 목록</h1>
{% if user_is_admin_group_member %}
<a href="{% url 'gyber:user_add' %}" class="btn btn-success btn-sm">
<i class="fas fa-user-plus"></i> 사용자 추가
</a>
{% endif %}
</div>
{# === 상단 컨트롤 영역 포함 === #}
{# 경로: templates/includes/user/user_controls.html #}
{% include "includes/user/user_controls.html" with request=request search_query=search_query group_list=group_list current_group=current_group valid_page_sizes=valid_page_sizes page_size=page_size sort_by=sort_by sort_dir=sort_dir %}
{# === 결과 수 표시 === #}
<p class="text-muted mb-2">
{% if total_count >= 0 %}
{% if search_query or current_group %}
검색/필터 결과: {{ total_count|localize }} 명
{% else %}
총 {{ total_count|localize }} 명의 사용자
{% endif %}
{% if total_pages > 0 %} (페이지 {{ current_page }} / {{ total_pages }}) {% endif %}
{% else %}
검색/필터 결과: (개수 정보 없음)
{% endif %}
</p>
{# === 사용자 목록 테이블 포함 === #}
{# 경로: templates/includes/user/user_table.html #}
{% include "includes/user/user_table.html" with user_list=user_list query_params_all=query_params_all sort_by=sort_by sort_dir=sort_dir search_query=search_query current_group=current_group %}
{# === 페이지네이션 UI 포함 === #}
{# 경로: templates/includes/pagination.html (공통) #}
{% include "includes/pagination.html" with current_page=current_page total_pages=total_pages page_numbers=page_numbers has_previous=has_previous previous_page_number=previous_page_number has_next=has_next next_page_number=next_page_number query_params_all=query_params_all %}
{# === 삭제 확인 모달 포함 === #}
{# 경로: templates/includes/confirm_delete_modal.html (공통) #}
{% for user_item in user_list %} {# 테이블과 동일한 변수명 사용 #}
{% url 'gyber:user_delete' user_item.user_id as delete_url %}
{# 모달 ID 접두사를 'user-delete-modal-' 로 지정 #}
{% include "includes/confirm_delete_modal.html" with modal_id_prefix="user-delete-modal-" item_id=user_item.user_id item_name=user_item.user_display_name item_type="사용자" delete_url=delete_url %}
{% endfor %}
{% endblock %}
{% block extra_js %}
{# JavaScript는 특별히 필요하지 않음 #}
{% endblock %}

View File

@ -0,0 +1,58 @@
{# /data/gyber/apps/web/templates/gyber/user_log_list.html #}
{% extends "gyber/log_base.html" %} {# 기본 로그 템플릿 상속 #}
{% block log_page_title %}사용자 관리 로그{% endblock %}
{% block log_header %}사용자 관리 로그 목록{% endblock %}
{# 검색 폼은 log_base.html의 기본 폼 사용 #}
{% block log_table_headers %}
{# 사용자 로그에 맞는 헤더 정의 #}
<tr>
<th style="width: 15%;">시간</th>
<th style="width: 15%;">작업자/주체</th>
<th style="width: 10%;">로그 타입</th>
<th style="width: 10%;">대상 사용자 ID</th>
<th style="width: 20%;">대상 사용자 정보 (로그 시점)</th> {# 이름, 계정 등 #}
<th style="width: 15%;">대상 부서 (로그 시점)</th>
<th>상세 내용</th>
</tr>
{% endblock %}
{% block log_table_body %}
{% for log in audit_logs %}
<tr>
<td>{{ log.log_date|date:"Y-m-d H:i:s" }}</td>
<td class="log-actor" title="{{ log.actor_info|default:"-" }}">{{ log.actor_info|default:"-" }}</td>
<td>
{% if log.log_type == 'ADD' %}<span class="badge bg-success">추가</span>
{% elif log.log_type == 'UPDATE' %}<span class="badge bg-warning text-dark">수정</span>
{% elif log.log_type == 'DELETE' %}<span class="badge bg-danger">삭제</span>
{% else %}<span class="badge bg-secondary">{{ log.log_type|default:"-" }}</span>
{% endif %}
</td>
<td>
{% if log.target_id %}
{# 사용자 ID 클릭 시 사용자 목록 페이지에서 해당 사용자 필터링? (선택 사항) #}
{# 예: <a href="{% url 'gyber:user_list' %}?query={{ log.target_id }}" title="사용자 정보 검색"> #}
{{ log.target_id }}
{# </a> #}
{% else %} - {% endif %}
</td>
{# ★ 필드 이름 확인: target_info_at_log #}
<td>{{ log.target_info_at_log|default:"-" }}</td>
{# ★ 필드 이름 확인: group_name_at_log #}
<td>{{ log.group_name_at_log|default:"미지정" }}</td>
<td class="log-details">{{ log.details|default:"-" }}</td>
</tr>
{% empty %}
{% block log_empty_row %} {# 비어있을 때 메시지 재정의 #}
<tr>
<td colspan="7" class="text-center">사용자 관련 로그가 없습니다.</td> {# 컬럼 수 7개 #}
</tr>
{% endblock %}
{% endfor %}
{% endblock %}
{# 페이지네이션은 log_base.html 의 기본 UI 사용 #}

56
apps/web/gyber/urls.py Normal file
View File

@ -0,0 +1,56 @@
# /data/gyber/apps/web/gyber/urls.py
from django.urls import path
# 변경된 views 임포트 방식
from .views import dashboard, resource, user, group, category, audit, auth, export_views
from .views import api as api_views
app_name = 'gyber'
urlpatterns = [
# 대시보드
path('dashboard/', dashboard.dashboard_view, name='dashboard'),
# 자산 관리
path('resources/', resource.resource_list, name='resource_list'),
path('resources/add/', resource.resource_add, name='resource_add'),
path('resources/<int:resource_id>/', resource.resource_detail, name='resource_detail'),
path('resources/<int:resource_id>/edit/', resource.resource_edit, name='resource_edit'),
path('resources/<int:resource_id>/delete/', resource.resource_delete, name='resource_delete'),
# 사용자 관리 (user_status_list -> user_list 로 이름 변경)
path('users/', user.user_list_view, name='user_list'),
path('users/add/', user.user_add, name='user_add'),
path('users/<int:user_id>/edit/', user.user_edit, name='user_edit'),
path('users/<int:user_id>/delete/', user.user_delete, name='user_delete'),
# 그룹(부서) 관리
path('groups/', group.group_list, name='group_list'),
path('groups/add/', group.group_add, name='group_add'),
path('groups/<int:group_id>/edit/', group.group_edit, name='group_edit'),
path('groups/<int:group_id>/delete/', group.group_delete, name='group_delete'),
# 카테고리 관리
path('categories/', category.category_list, name='category_list'),
path('categories/add/', category.category_add, name='category_add'),
path('categories/<int:category_id>/edit/', category.category_edit, name='category_edit'),
path('categories/<int:category_id>/delete/', category.category_delete, name='category_delete'),
# 로그 조회
path('logs/resources/', audit.resource_log_list_view, name='resource_log_list'),
path('logs/users/', audit.user_log_list_view, name='user_log_list'),
path('logs/groups/', audit.group_log_list_view, name='group_log_list'),
path('logs/categories/', audit.category_log_list_view, name='category_log_list'),
# 루트 경로 (예: 대시보드로 리디렉션 또는 다른 기본 페이지)
# path('', lambda request: redirect('gyber:dashboard'), name='index'), # 예시
# 커스텀 로그아웃 URL
path('logout/', auth.custom_oidc_logout, name='custom_logout'),
# 사용자 검색 API
path('api/users/search/', api_views.search_users, name='api_search_users'),
# 리소스 목록 내보내기
path('export_views/export/csv/', export_views.export_resources_csv, name='export_resources_csv'),
]

901
apps/web/gyber/views.py_old Normal file
View File

@ -0,0 +1,901 @@
# /data/gyber/apps/web/gyber/views.py
# 코드가 너무 길어져서 각 기능별 모듈화 및 캡슐화 진행
import logging
import math
import json
from django.shortcuts import render, redirect # get_object_or_404 제거
from django.http import Http404, HttpResponseForbidden # QueryDict 제거
from django.urls import reverse
from django.contrib import messages
from django.contrib.auth.decorators import login_required
# db_utils 함수 임포트 (관련 함수들 확인)
from .db_utils import (
get_all_resources, get_resource_by_id, add_new_resource,
update_resource, delete_resource, get_resources_by_search,
get_all_categories, get_all_groups, get_all_users,
get_user_by_id, add_new_user, update_user, delete_user,
get_user_status_list,
get_audit_logs, get_user_audit_logs, # 로그 함수 임포트
get_dashboard_summary, get_assets_count_by_category,
get_all_groups, get_group_by_id, add_new_group, update_group, delete_group, # 그룹 관리 함수 임포트
get_all_categories, get_category_by_id, update_category_name, delete_category, add_new_category, # 카테고리 관리 함수 임포트
get_audit_logs, get_user_audit_logs,
get_group_audit_logs, get_category_audit_logs # 신규 로그 함수 임포트
)
# 폼 임포트
from .forms import ResourceForm, UserForm, GroupForm, CategoryForm
# 로거 설정
logger = logging.getLogger(__name__)
# --- 자산 목록 뷰 (수정: 사용자 필터 처리 추가) ---
@login_required # 자산 목록 조회도 로그인 필요 가정
def resource_list(request):
"""자산 목록을 보여주는 뷰 함수 (검색, 페이징, 정렬, 필터링 포함)"""
# --- GET 파라미터 가져오기 (user_id 추가) ---
search_query = request.GET.get('query', None)
try: page_number = int(request.GET.get('page', 1)); page_number = max(1, page_number)
except ValueError: page_number = 1
try:
page_size = int(request.GET.get('page_size', 20))
valid_page_sizes = [10, 20, 50, 100]
if page_size not in valid_page_sizes: page_size = 20
except ValueError: page_size = 20
sort_by = request.GET.get('sort', 'id')
sort_dir = request.GET.get('dir', 'desc')
category_filter = request.GET.get('category', None)
group_filter = request.GET.get('group', None)
user_filter = request.GET.get('user_id', None) # 사용자 ID 필터 파라미터 가져오기
# --- 유효성 검사 (정렬 컬럼 및 필터 값) ---
allowed_sort_columns = ['id', 'name', 'category', 'code', 'user', 'group', 'serial', 'created', 'updated']
if sort_by not in allowed_sort_columns: sort_by = 'id'
if sort_dir not in ['asc', 'desc']: sort_dir = 'desc'
try: current_category = int(category_filter) if category_filter else None
except ValueError: current_category = None
try: current_group = int(group_filter) if group_filter else None
except ValueError: current_group = None
try: current_user_id = int(user_filter) if user_filter else None # 사용자 ID 정수 변환
except ValueError: current_user_id = None
# --- 데이터 조회 (수정된 db_utils 함수 호출) ---
if search_query:
logger.debug(f"Searching resources with query='{search_query}', page={page_number}, size={page_size}, sort='{sort_by}', dir='{sort_dir}', category={current_category}, group={current_group}, user={current_user_id}")
resource_list_page, total_count = get_resources_by_search(
search_term=search_query, page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group,
user_id=current_user_id # user_id 전달
)
else:
logger.debug(f"Fetching all resources, page={page_number}, size={page_size}, sort='{sort_by}', dir='{sort_dir}', category={current_category}, group={current_group}, user={current_user_id}")
resource_list_page, total_count = get_all_resources(
page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group,
user_id=current_user_id # user_id 전달
)
# --- 페이지네이션 정보 계산 (동일) ---
try: total_pages = math.ceil(total_count / page_size)
except ZeroDivisionError: total_pages = 1
if page_number > total_pages and total_pages > 0: page_number = total_pages
page_range_size = 5; half_range = page_range_size // 2
start_page = max(page_number - half_range, 1)
end_page = min(page_number + half_range, total_pages)
if end_page - start_page + 1 < page_range_size:
if start_page == 1: end_page = min(start_page + page_range_size - 1, total_pages)
elif end_page == total_pages: start_page = max(end_page - page_range_size + 1, 1)
page_numbers = range(start_page, end_page + 1)
# --- 템플릿용 쿼리 파라미터 문자열 생성 (동일) ---
query_params_all = request.GET.copy()
if 'page' in query_params_all: del query_params_all['page']
query_params_all_str = query_params_all.urlencode()
query_params_no_filter = request.GET.copy()
if 'page' in query_params_no_filter: del query_params_no_filter['page']
if 'category' in query_params_no_filter: del query_params_no_filter['category']
if 'group' in query_params_no_filter: del query_params_no_filter['group']
if 'user_id' in query_params_no_filter: del query_params_no_filter['user_id'] # user_id 도 필터에서 제외
query_params_no_filter_str = query_params_no_filter.urlencode()
query_params_no_search = request.GET.copy()
if 'page' in query_params_no_search: del query_params_no_search['page']
if 'query' in query_params_no_search: del query_params_no_search['query']
query_params_no_search_str = query_params_no_search.urlencode()
# --- 필터 옵션 로드 (동일) ---
try: category_list = get_all_categories()
except Exception as e: logger.error(f"Error loading category list: {e}"); category_list = []
try: group_list = get_all_groups()
except Exception as e: logger.error(f"Error loading group list: {e}"); group_list = []
# --- 필터링된 사용자 정보 로드 (선택 사항: 화면 표시용) ---
filtered_user_info = None
if current_user_id:
try:
filtered_user_info = get_user_by_id(current_user_id)
if not filtered_user_info:
logger.warning(f"User filter applied for non-existent user_id: {current_user_id}")
except Exception as e:
logger.error(f"Error loading user info for filter display (user_id: {current_user_id}): {e}")
# --- 컨텍스트 업데이트 (사용자 필터 정보 추가) ---
context = {
'resource_list': resource_list_page,
'total_count': total_count,
'page_size': page_size,
'valid_page_sizes': valid_page_sizes, # 페이지 크기 옵션 추가
'current_page': page_number,
'total_pages': total_pages,
'page_numbers': page_numbers,
'has_previous': page_number > 1,
'has_next': page_number < total_pages,
'previous_page_number': page_number - 1,
'next_page_number': page_number + 1,
'search_query': search_query,
'sort_by': sort_by,
'sort_dir': sort_dir,
'query_params_all': query_params_all_str,
'query_params_no_filter': query_params_no_filter_str,
'query_params_no_search': query_params_no_search_str,
'category_list': category_list,
'group_list': group_list,
'current_category': current_category,
'current_group': current_group,
'current_user_id': current_user_id, # 현재 필터링된 사용자 ID 전달
'filtered_user_info': filtered_user_info, # 필터링된 사용자 정보 전달 (표시용)
# 'section': 'resource_list' # 필요시 네비게이션용 section 추가
}
return render(request, 'gyber/resource_list.html', context) # 템플릿 수정 필요
# --- 자산 상세 뷰 ---
@login_required
def resource_detail(request, resource_id):
"""특정 자산의 상세 정보를 보여주는 뷰 함수"""
resource = get_resource_by_id(resource_id)
if resource is None:
raise Http404("해당 ID의 자산을 찾을 수 없습니다.")
context = {
'resource': resource,
}
return render(request, 'gyber/resource_detail.html', context)
# --- 자산 추가 뷰 ---
@login_required
def resource_add(request):
"""새 자산 추가 뷰 함수"""
try:
categories = get_all_categories()
category_choices = [('', '---------')] + [(cat['category_id'], cat['category_name']) for cat in categories]
except Exception as e:
logger.error(f"Error loading categories for form: {e}", exc_info=True)
messages.error(request, "카테고리 목록을 불러오는 중 오류가 발생했습니다.")
category_choices = [('', '로드 오류')]
try:
users = get_all_users()
user_choices = [('', '---------')] + [(user['user_id'], user['user_display_name']) for user in users]
except Exception as e:
logger.error(f"Error loading users for form: {e}", exc_info=True)
messages.error(request, "사용자 목록을 불러오는 중 오류가 발생했습니다.")
user_choices = [('', '로드 오류')]
if request.method == 'POST':
form = ResourceForm(request.POST)
form.fields['category'].choices = category_choices
form.fields['user'].choices = user_choices
if form.is_valid():
cleaned_data = form.cleaned_data
user_id_val = cleaned_data.get('user') if cleaned_data.get('user') else None
spec_unit_val = cleaned_data.get('spec_unit') if cleaned_data.get('spec_unit') else None
try:
new_resource_id = add_new_resource(
admin_user_id=request.user.id, actor_description=None,
category_id=cleaned_data['category'], resource_code=cleaned_data.get('resource_code'),
manufacturer=cleaned_data.get('manufacturer'), resource_name=cleaned_data['resource_name'],
serial_num=cleaned_data.get('serial_num'), spec_value=cleaned_data.get('spec_value'),
spec_unit=spec_unit_val, user_id=user_id_val,
comments=cleaned_data.get('comments'), create_date=cleaned_data['create_date']
)
if new_resource_id:
messages.success(request, f"'{cleaned_data['resource_name']}' 자산이 성공적으로 추가되었습니다. (ID: {new_resource_id})")
return redirect(reverse('gyber:resource_list'))
else:
messages.error(request, "자산 추가 중 오류가 발생했습니다. (DB 저장 실패)")
except Exception as e:
logger.error(f"Exception during adding resource: {e}", exc_info=True)
messages.error(request, f"자산 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
form = ResourceForm()
form.fields['category'].choices = category_choices
form.fields['user'].choices = user_choices
context = { 'form': form, 'is_edit_mode': False }
return render(request, 'gyber/resource_form.html', context)
# --- 자산 수정 뷰 함수 ---
@login_required
def resource_edit(request, resource_id):
"""자산 정보 수정 뷰 함수"""
resource = get_resource_by_id(resource_id)
if resource is None:
raise Http404("수정할 자산을 찾을 수 없습니다.")
try:
categories = get_all_categories()
category_choices = [('', '---------')] + [(cat['category_id'], cat['category_name']) for cat in categories]
except Exception as e: logger.error(f"Error loading categories: {e}"); category_choices = [('', '로드 오류')]
try:
users = get_all_users()
user_choices = [('', '---------')] + [(user['user_id'], user['user_display_name']) for user in users]
except Exception as e: logger.error(f"Error loading users: {e}"); user_choices = [('', '로드 오류')]
if request.method == 'POST':
form = ResourceForm(request.POST)
form.fields['category'].choices = category_choices
form.fields['user'].choices = user_choices
if form.is_valid():
cleaned_data = form.cleaned_data
user_id_val = cleaned_data.get('user') if cleaned_data.get('user') else None
spec_unit_val = cleaned_data.get('spec_unit') if cleaned_data.get('spec_unit') else None
try:
# ★ update_resource 반환값 처리 개선 필요 가정 (성공 시 True)
success = update_resource(
admin_user_id=request.user.id, actor_description=None, resource_id=resource_id,
category_id=cleaned_data['category'], resource_code=cleaned_data.get('resource_code'),
manufacturer=cleaned_data.get('manufacturer'), resource_name=cleaned_data['resource_name'],
serial_num=cleaned_data.get('serial_num'), spec_value=cleaned_data.get('spec_value'),
spec_unit=spec_unit_val, user_id=user_id_val, comments=cleaned_data.get('comments')
)
if success: # ★ 성공 여부 확인
messages.success(request, f"자산(ID: {resource_id}) 정보가 성공적으로 수정되었습니다.")
return redirect(reverse('gyber:resource_detail', args=[resource_id]))
else:
# ★ 실패 메시지 (update_resource가 메시지를 반환하도록 수정 필요)
messages.error(request, "자산 정보 수정 중 오류가 발생했습니다.")
except Exception as e:
logger.error(f"Exception during updating resource {resource_id}: {e}", exc_info=True)
messages.error(request, f"자산 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
initial_data = resource.copy()
if initial_data.get('create_date'):
initial_data['create_date'] = initial_data['create_date'].strftime('%Y-%m-%d') if hasattr(initial_data['create_date'], 'strftime') else initial_data['create_date']
# user 필드의 초기값은 user_id로 설정
initial_data['user'] = resource.get('user_id')
form = ResourceForm(initial=initial_data)
form.fields['category'].choices = category_choices
form.fields['user'].choices = user_choices
context = { 'form': form, 'resource_id': resource_id, 'is_edit_mode': True }
return render(request, 'gyber/resource_form.html', context)
# --- 자산 삭제 뷰 함수 ---
@login_required
def resource_delete(request, resource_id):
"""자산 삭제 처리 뷰 함수 (POST 요청만 처리)"""
if request.method == 'POST':
resource = get_resource_by_id(resource_id)
if resource is None:
raise Http404("삭제할 자산을 찾을 수 없습니다.")
resource_name = resource.get('resource_name', f'ID: {resource_id}')
try:
# ★ delete_resource 반환값 처리 개선 필요 가정 (성공 시 True)
success = delete_resource(
admin_user_id=request.user.id, actor_description=None, resource_id=resource_id
)
if success: # ★ 성공 여부 확인
messages.success(request, f"'{resource_name}' 자산이 성공적으로 삭제되었습니다.")
else:
# ★ 실패 메시지 (delete_resource가 메시지를 반환하도록 수정 필요)
messages.error(request, f"'{resource_name}' 자산 삭제 중 오류가 발생했습니다.")
return redirect(reverse('gyber:resource_list'))
except Exception as e:
logger.error(f"Exception during deleting resource {resource_id}: {e}", exc_info=True)
messages.error(request, f"자산 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:resource_list'))
else:
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:resource_list'))
# --- 대시보드 뷰 함수 ---
@login_required
def dashboard_view(request):
logger.info(f"User {request.user.username} accessed dashboard.")
summary_data = get_dashboard_summary()
category_counts = get_assets_count_by_category()
recent_logs = get_audit_logs()[:5] # 최근 5개 자산 로그
category_labels_json = json.dumps([item.get('category_name', 'N/A') for item in category_counts])
category_data_json = json.dumps([item.get('asset_count', 0) for item in category_counts])
context = {
'summary': summary_data, 'category_counts': category_counts,
'category_labels_json': category_labels_json, 'category_data_json': category_data_json,
'recent_logs': recent_logs, 'section': 'dashboard'
}
return render(request, 'gyber/dashboard.html', context)
# --- 사용자 현황 뷰 함수 (수정됨) ---
@login_required
def user_status_view(request):
"""
사용자별 자산 현황 목록을 보여주는 뷰 함수 (검색, 페이징, 정렬, 필터링 포함)
"""
logger.info(f"User {request.user.username} accessed user status page.")
# --- GET 파라미터 가져오기 ---
search_query = request.GET.get('query', None)
try: page_number = int(request.GET.get('page', 1)); page_number = max(1, page_number)
except ValueError: page_number = 1
try:
page_size = int(request.GET.get('page_size', 20))
valid_page_sizes = [10, 20, 50, 100]
if page_size not in valid_page_sizes: page_size = 20
except ValueError: page_size = 20
sort_by = request.GET.get('sort', 'name'); sort_dir = request.GET.get('dir', 'asc')
group_filter = request.GET.get('group', None)
# --- 유효성 검사 ---
allowed_sort_columns = ['name', 'email', 'group', 'assets']
if sort_by not in allowed_sort_columns: sort_by = 'name'
if sort_dir not in ['asc', 'desc']: sort_dir = 'asc'
try: current_group = int(group_filter) if group_filter else None
except ValueError: current_group = None
# --- 데이터 조회 (수정된 db_utils 함수 호출) ---
user_status_list, total_count = get_user_status_list(
search_term=search_query, page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir, group_id=current_group
)
# --- 페이지네이션 정보 계산 ---
try: total_pages = math.ceil(total_count / page_size)
except ZeroDivisionError: total_pages = 1
if page_number > total_pages and total_pages > 0: page_number = total_pages
page_range_size = 5; half_range = page_range_size // 2
start_page = max(page_number - half_range, 1); end_page = min(page_number + half_range, total_pages)
if end_page - start_page + 1 < page_range_size:
if start_page == 1: end_page = min(start_page + page_range_size - 1, total_pages)
elif end_page == total_pages: start_page = max(end_page - page_range_size + 1, 1)
page_numbers = range(start_page, end_page + 1)
# --- 템플릿용 쿼리 파라미터 문자열 생성 ---
query_params_all = request.GET.copy(); query_params_all.pop('page', None)
query_params_all_str = query_params_all.urlencode()
query_params_no_filter = request.GET.copy(); query_params_no_filter.pop('page', None); query_params_no_filter.pop('group', None)
query_params_no_filter_str = query_params_no_filter.urlencode()
query_params_no_search = request.GET.copy(); query_params_no_search.pop('page', None); query_params_no_search.pop('query', None)
query_params_no_search_str = query_params_no_search.urlencode()
# --- 부서 목록 로드 (필터 드롭다운용) ---
try: group_list = get_all_groups()
except Exception as e: logger.error(f"Error loading group list for user status page: {e}", exc_info=True); group_list = []
# --- 컨텍스트 데이터 구성 ---
context = {
'user_status_list': user_status_list, 'total_count': total_count,
'page_size': page_size, 'valid_page_sizes': valid_page_sizes, # 페이지 크기 옵션 추가
'current_page': page_number, 'total_pages': total_pages, 'page_numbers': page_numbers,
'has_previous': page_number > 1, 'has_next': page_number < total_pages,
'previous_page_number': page_number - 1, 'next_page_number': page_number + 1,
'search_query': search_query, 'sort_by': sort_by, 'sort_dir': sort_dir,
'group_list': group_list, 'current_group': current_group,
'query_params_all': query_params_all_str,
'query_params_no_filter': query_params_no_filter_str,
'query_params_no_search': query_params_no_search_str,
'section': 'user_status'
}
return render(request, 'gyber/user_status_list.html', context)
# --- 사용자 추가 뷰 함수 ---
@login_required
def user_add(request):
"""새 사용자 추가 뷰 함수"""
logger.info(f"User {request.user.username} trying to add a new user.")
try:
groups = get_all_groups()
group_choices = [('', '---------')] + [(g['group_id'], g['group_name']) for g in groups]
except Exception as e:
logger.error(f"Error loading groups for UserForm: {e}", exc_info=True)
messages.error(request, "부서 목록을 불러오는 중 오류가 발생했습니다.")
group_choices = [('', '로드 오류')]
if request.method == 'POST':
form = UserForm(request.POST)
form.fields['group'].choices = group_choices # 유효성 검사 전에 설정
if form.is_valid():
cleaned_data = form.cleaned_data
user_name_val = cleaned_data.get('user_name')
email_address_val = cleaned_data['email_address']
group_id_val = cleaned_data.get('group')
if group_id_val == '': group_id_val = None
else:
try: group_id_val = int(group_id_val)
except (ValueError, TypeError): group_id_val = None
try:
success, message, new_user_id = add_new_user(
admin_user_id=request.user.id, actor_description=None,
user_name=user_name_val, email_address=email_address_val, group_id=group_id_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:user_status_list'))
else:
messages.error(request, f"사용자 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding user: {e}", exc_info=True)
messages.error(request, f"사용자 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
form = UserForm()
form.fields['group'].choices = group_choices # 빈 폼에도 설정
context = { 'form': form, 'is_edit_mode': False }
return render(request, 'gyber/user_form.html', context)
# --- 사용자 수정 뷰 함수 ---
@login_required
def user_edit(request, user_id):
"""사용자 정보 수정 뷰 함수"""
logger.info(f"User {request.user.username} trying to edit user ID: {user_id}.")
user_data = get_user_by_id(user_id)
if user_data is None:
logger.warning(f"Edit attempt failed: User ID {user_id} not found.")
raise Http404("수정할 사용자를 찾을 수 없습니다.")
try:
groups = get_all_groups()
group_choices = [('', '---------')] + [(g['group_id'], g['group_name']) for g in groups]
except Exception as e:
logger.error(f"Error loading groups for UserForm (edit): {e}", exc_info=True)
messages.error(request, "부서 목록을 불러오는 중 오류가 발생했습니다.")
group_choices = [('', '로드 오류')]
if request.method == 'POST':
form = UserForm(request.POST)
form.fields['group'].choices = group_choices # 유효성 검사 전에 설정
if form.is_valid():
cleaned_data = form.cleaned_data
user_name_val = cleaned_data.get('user_name')
email_address_val = cleaned_data['email_address']
group_id_val = cleaned_data.get('group')
if group_id_val == '': group_id_val = None
else:
try: group_id_val = int(group_id_val)
except (ValueError, TypeError): group_id_val = None
try:
success, message = update_user(
admin_user_id=request.user.id, actor_description=None, user_id=user_id,
user_name=user_name_val, email_address=email_address_val, group_id=group_id_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:user_status_list'))
else:
messages.error(request, f"사용자 정보 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating user {user_id}: {e}", exc_info=True)
messages.error(request, f"사용자 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
initial_data = {
'user_name': user_data.get('user_name'),
'email_address': user_data.get('email_address'),
'group': user_data.get('group_id')
}
form = UserForm(initial=initial_data)
form.fields['group'].choices = group_choices # 초기 데이터 채운 후 설정
context = { 'form': form, 'user_id': user_id, 'is_edit_mode': True }
return render(request, 'gyber/user_form.html', context)
# --- 사용자 삭제 뷰 함수 ---
@login_required
def user_delete(request, user_id):
"""사용자 삭제 처리 뷰 함수 (POST 요청만 처리)"""
logger.info(f"User {request.user.username} trying to delete user ID: {user_id}.")
if request.method == 'POST':
user_data = get_user_by_id(user_id)
if user_data is None:
logger.warning(f"Delete attempt failed: User ID {user_id} not found.")
messages.error(request, "삭제할 사용자를 찾을 수 없습니다.")
return redirect(reverse('gyber:user_status_list'))
user_display = user_data.get('user_name') or user_data.get('email_address') or f'ID: {user_id}'
try:
success, message = delete_user(
admin_user_id=request.user.id, actor_description=None, user_id=user_id
)
if success: messages.success(request, message)
else: messages.error(request, f"사용자 삭제 실패: {message}")
return redirect(reverse('gyber:user_status_list'))
except Exception as e:
logger.error(f"Exception during deleting user {user_id}: {e}", exc_info=True)
messages.error(request, f"사용자 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:user_status_list'))
else:
logger.warning(f"GET request denied for user delete view (user_id: {user_id}).")
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:user_status_list'))
# --- 로그 조회 뷰 함수들 ---
# 자산 로그 조회 뷰 함수 (이름 변경)
@login_required
def resource_log_list_view(request):
"""자산 관련 감사 로그 목록을 보여주는 뷰 함수"""
audit_logs = get_audit_logs() # 자산 로그 조회 함수 호출
context = {
'audit_logs': audit_logs,
'log_type_title': '자산' # 템플릿에 전달할 로그 타입 제목
}
return render(request, 'gyber/resource_log_list.html', context)
# 사용자 로그 조회 뷰 함수 (신규 추가)
@login_required
def user_log_list_view(request):
"""사용자 관리 감사 로그 목록을 보여주는 뷰 함수"""
user_audit_logs = get_user_audit_logs() # 사용자 로그 조회 함수 호출 (limit 기본값 사용)
context = {
'audit_logs': user_audit_logs, # 템플릿에서 사용할 변수 이름 (일관성 위해 audit_logs 사용)
'log_type_title': '사용자' # 템플릿에 전달할 로그 타입 제목
}
return render(request, 'gyber/user_log_list.html', context)
# ★ 신규: 그룹(부서) 로그 조회 뷰 함수
@login_required
def group_log_list_view(request):
"""그룹(부서) 관리 감사 로그 목록을 보여주는 뷰 함수"""
group_audit_logs = get_group_audit_logs() # 그룹 로그 조회 함수 호출
context = {
'audit_logs': group_audit_logs,
'log_type_title': '그룹(부서)'
}
# 그룹 로그용 새 템플릿 사용
return render(request, 'gyber/group_log_list.html', context)
# ★ 신규: 카테고리 로그 조회 뷰 함수
@login_required
def category_log_list_view(request):
"""자산 카테고리 관리 감사 로그 목록을 보여주는 뷰 함수"""
category_audit_logs = get_category_audit_logs() # 카테고리 로그 조회 함수 호출
context = {
'audit_logs': category_audit_logs,
'log_type_title': '카테고리'
}
# 카테고리 로그용 새 템플릿 사용
return render(request, 'gyber/category_log_list.html', context)
# --- 그룹(부서) 관리 뷰 함수들 (신규 추가) ---
@login_required
def group_list(request):
"""그룹(부서) 목록을 보여주는 뷰 함수"""
logger.info(f"User {request.user.username} accessed group list page.")
try:
# db_utils.get_all_groups() 는 정렬된 전체 목록을 반환함
# 페이징/검색이 필요하면 user_status_view 처럼 프로시저 및 뷰 수정 필요
# 현재는 전체 목록을 가져옴
groups = get_all_groups()
# 각 그룹에 속한 사용자 수 계산 (N+1 Query 문제 발생 가능성 있음)
# 성능 중요 시, sp_get_all_groups 프로시저에서 사용자 수를 함께 계산하도록 수정 권장
# 여기서는 간단하게 구현
# for group in groups:
# try:
# # 이 방식은 매우 비효율적! 프로시저에서 처리하는 것이 좋음
# with connections['default'].cursor() as cursor:
# cursor.execute("SELECT COUNT(*) FROM user_info WHERE group_id = %s", [group['group_id']])
# count_result = cursor.fetchone()
# group['member_count'] = count_result[0] if count_result else 0
# except Exception as e:
# logger.error(f"Error counting members for group {group['group_id']}: {e}")
# group['member_count'] = 'N/A'
except Exception as e:
logger.error(f"Error loading group list: {e}", exc_info=True)
messages.error(request, "그룹(부서) 목록을 불러오는 중 오류가 발생했습니다.")
groups = []
context = {
'group_list': groups,
'section': 'groups' # 네비게이션 활성화용
}
return render(request, 'gyber/group_list.html', context)
@login_required
def group_add(request):
"""새 그룹(부서) 추가 뷰 함수"""
logger.info(f"User {request.user.username} trying to add a new group.")
if request.method == 'POST':
# GroupForm 생성 시 __init__에서 사용자 목록(choices)이 자동으로 로드됨
form = GroupForm(request.POST)
if form.is_valid():
cleaned_data = form.cleaned_data
group_name_val = cleaned_data['group_name']
manager_user_id_val = cleaned_data.get('manager_user')
if manager_user_id_val == '': manager_user_id_val = None
else:
try: manager_user_id_val = int(manager_user_id_val)
except (ValueError, TypeError): manager_user_id_val = None
try:
success, message, new_group_id = add_new_group(
admin_user_id=request.user.id, actor_description=None,
group_name=group_name_val, manager_user_id=manager_user_id_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:group_list')) # 성공 시 목록으로
else:
messages.error(request, f"그룹(부서) 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding group: {e}", exc_info=True)
messages.error(request, f"그룹(부서) 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
form = GroupForm() # 빈 폼 생성 (__init__에서 choices 로드)
context = {
'form': form,
'is_edit_mode': False
}
return render(request, 'gyber/group_form.html', context)
@login_required
def group_edit(request, group_id):
"""그룹(부서) 정보 수정 뷰 함수"""
logger.info(f"User {request.user.username} trying to edit group ID: {group_id}.")
group_data = get_group_by_id(group_id)
if group_data is None:
logger.warning(f"Edit attempt failed: Group ID {group_id} not found.")
raise Http404("수정할 그룹(부서)을 찾을 수 없습니다.")
if request.method == 'POST':
form = GroupForm(request.POST) # __init__ 에서 choices 로드됨
if form.is_valid():
cleaned_data = form.cleaned_data
group_name_val = cleaned_data['group_name']
manager_user_id_val = cleaned_data.get('manager_user')
if manager_user_id_val == '': manager_user_id_val = None
else:
try: manager_user_id_val = int(manager_user_id_val)
except (ValueError, TypeError): manager_user_id_val = None
try:
success, message = update_group(
admin_user_id=request.user.id, actor_description=None,
group_id=group_id, group_name=group_name_val, manager_user_id=manager_user_id_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:group_list')) # 성공 시 목록으로
else:
messages.error(request, f"그룹(부서) 정보 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating group {group_id}: {e}", exc_info=True)
messages.error(request, f"그룹(부서) 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET 요청
# 기존 그룹 정보로 폼 초기화
initial_data = {
'group_name': group_data.get('group_name'),
'manager_user': group_data.get('user_id') # 매니저 ID를 초기값으로 사용
}
form = GroupForm(initial=initial_data) # __init__ 에서 choices 다시 로드됨
context = {
'form': form,
'group_id': group_id,
'is_edit_mode': True
}
return render(request, 'gyber/group_form.html', context)
@login_required
def group_delete(request, group_id):
"""그룹(부서) 삭제 처리 뷰 함수 (POST 요청만 처리)"""
logger.info(f"User {request.user.username} trying to delete group ID: {group_id}.")
if request.method == 'POST':
# 삭제 전 정보 확인 (메시지용)
group_data = get_group_by_id(group_id)
if group_data is None:
logger.warning(f"Delete attempt failed: Group ID {group_id} not found.")
messages.error(request, "삭제할 그룹(부서)을 찾을 수 없습니다.")
return redirect(reverse('gyber:group_list'))
group_name = group_data.get('group_name', f'ID: {group_id}')
try:
success, message = delete_group(
admin_user_id=request.user.id, actor_description=None, group_id=group_id
)
if success: messages.success(request, message)
else: messages.error(request, f"그룹(부서) 삭제 실패: {message}") # 실패 메시지(예: 멤버 존재) 표시
return redirect(reverse('gyber:group_list'))
except Exception as e:
logger.error(f"Exception during deleting group {group_id}: {e}", exc_info=True)
messages.error(request, f"그룹(부서) 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:group_list'))
else:
logger.warning(f"GET request denied for group delete view (group_id: {group_id}).")
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:group_list'))
# --- 자산 카테고리 관리 뷰 함수들 (신규 추가) ---
@login_required
def category_list(request):
"""자산 카테고리 목록을 보여주는 뷰 함수"""
logger.info(f"User {request.user.username} accessed category list page.")
try:
# db_utils.get_all_categories() 는 정렬된 전체 목록을 반환함
categories = get_all_categories()
# 각 카테고리에 속한 자산 수 계산 (N+1 Query 문제 주의!)
# 성능 중요 시, sp_get_all_categories 프로시저에서 자산 수를 함께 계산하도록 수정 권장
# 여기서는 간단하게 구현
# from django.db import connections # 추가 임포트 필요
# for category in categories:
# try:
# with connections['default'].cursor() as cursor:
# cursor.execute("SELECT COUNT(*) FROM resource_info WHERE category_id = %s", [category['category_id']])
# count_result = cursor.fetchone()
# category['asset_count'] = count_result[0] if count_result else 0
# except Exception as e:
# logger.error(f"Error counting assets for category {category['category_id']}: {e}")
# category['asset_count'] = 'N/A'
except Exception as e:
logger.error(f"Error loading category list: {e}", exc_info=True)
messages.error(request, "카테고리 목록을 불러오는 중 오류가 발생했습니다.")
categories = []
context = {
'category_list': categories,
'section': 'categories' # 네비게이션 활성화용
}
return render(request, 'gyber/category_list.html', context)
# 카테고리 추가 기능은 ID 관리 문제로 웹에서는 제공하지 않음
# def category_add(request): ...
# ★ 신규: 카테고리 추가 뷰 함수
@login_required
def category_add(request):
"""새 자산 카테고리 추가 뷰 함수"""
logger.info(f"User {request.user.username} trying to add a new category.")
if request.method == 'POST':
form = CategoryForm(request.POST) # 이름 필드만 있는 폼 사용
if form.is_valid():
cleaned_data = form.cleaned_data
category_name_val = cleaned_data['category_name']
try:
success, message, new_category_id = add_new_category(
admin_user_id=request.user.id, actor_description=None,
category_name=category_name_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:category_list')) # 성공 시 목록으로
else:
messages.error(request, f"카테고리 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding category: {e}", exc_info=True)
messages.error(request, f"카테고리 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
# 유효하지 않은 폼을 다시 보여줌 (입력값 유지됨)
else: # GET 요청
form = CategoryForm() # 빈 폼 생성
context = {
'form': form,
'is_edit_mode': False # 추가 모드임을 명시
}
# 추가/수정 모두 category_form.html 사용
return render(request, 'gyber/category_form.html', context)
@login_required
def category_edit(request, category_id):
"""카테고리 이름 수정 뷰 함수"""
logger.info(f"User {request.user.username} trying to edit category ID: {category_id}.")
category_data = get_category_by_id(category_id)
if category_data is None:
logger.warning(f"Edit attempt failed: Category ID {category_id} not found.")
raise Http404("수정할 카테고리를 찾을 수 없습니다.")
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
cleaned_data = form.cleaned_data
new_category_name_val = cleaned_data['category_name']
try:
success, message = update_category_name(
admin_user_id=request.user.id, actor_description=None,
category_id=category_id, new_category_name=new_category_name_val
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:category_list')) # 성공 시 목록으로
else:
messages.error(request, f"카테고리 이름 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating category {category_id}: {e}", exc_info=True)
messages.error(request, f"카테고리 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
# 유효하지 않은 폼을 다시 보여줌 (입력값 유지됨)
else: # GET 요청
# 기존 카테고리 정보로 폼 초기화
initial_data = {
'category_name': category_data.get('category_name')
}
form = CategoryForm(initial=initial_data)
context = {
'form': form,
'category_id': category_id, # 템플릿에서 ID 표시 등에 사용 가능
'category_data': category_data, # 현재 카테고리 정보 전달 (제목 등에 활용)
'is_edit_mode': True # 템플릿 구분을 위해 True 전달
}
# 카테고리 수정 폼 템플릿 (새로 만들어야 함)
return render(request, 'gyber/category_form.html', context)
@login_required
def category_delete(request, category_id):
"""카테고리 삭제 처리 뷰 함수 (POST 요청만 처리)"""
logger.info(f"User {request.user.username} trying to delete category ID: {category_id}.")
if request.method == 'POST':
# 삭제 전 정보 확인 (메시지용)
category_data = get_category_by_id(category_id)
if category_data is None:
logger.warning(f"Delete attempt failed: Category ID {category_id} not found.")
messages.error(request, "삭제할 카테고리를 찾을 수 없습니다.")
return redirect(reverse('gyber:category_list'))
category_name = category_data.get('category_name', f'ID: {category_id}')
try:
success, message = delete_category(
admin_user_id=request.user.id, actor_description=None, category_id=category_id
)
if success: messages.success(request, message)
else: messages.error(request, f"카테고리 삭제 실패: {message}") # 실패 메시지(예: 자산 존재) 표시
return redirect(reverse('gyber:category_list'))
except Exception as e:
logger.error(f"Exception during deleting category {category_id}: {e}", exc_info=True)
messages.error(request, f"카테고리 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:category_list'))
else:
logger.warning(f"GET request denied for category delete view (category_id: {category_id}).")
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:category_list'))

View File

@ -0,0 +1,11 @@
# /data/gyber/apps/web/gyber/views/__init__.py
# 이 파일은 비워두거나, 편의상 각 모듈을 임포트 할 수 있습니다.
# 예:
# from . import dashboard
# from . import resource
# from . import user
# from . import group
# from . import category
# from . import audit
from . import auth
# 여기서는 비워두겠습니다. urls.py에서 명시적으로 임포트합니다.

View File

@ -0,0 +1,45 @@
# /data/gyber/apps/web/gyber/views/api.py
import logging
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required, user_passes_test
from ..db import user as db_user # DB 인터페이스 모듈
from django.urls import reverse_lazy
from ..auth_utils import is_admin_user
logger = logging.getLogger('gyber.views.api')
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def search_users(request):
term = request.GET.get('term', '').strip()
try:
page = int(request.GET.get('page', 1)) # ★★★ 요청에서 'page' 파라미터 가져오기 ★★★
page = max(1, page) # 페이지 번호는 최소 1
except ValueError:
page = 1
page_size = 10 # 한 페이지에 보여줄 항목 수 (Select2와 일치 또는 설정 가능하게)
if not term or len(term) < 1:
# 검색어가 없거나 너무 짧으면 빈 결과와 전체 개수 0 반환
return JsonResponse({'results': [], 'pagination': {'more': False}, 'total_count': 0})
try:
# ★★★ db_user.get_users_for_autocomplete_paginated 함수 호출 (페이지네이션 지원) ★★★
# 이 함수는 (사용자 목록, 전체 검색된 항목 수)를 반환해야 함
users_list_page, total_results_count = db_user.get_users_for_autocomplete_paginated(term, page, page_size)
if users_list_page is None: # DB 함수에서 오류 시 None 반환 가정
users_list_page = []
total_results_count = 0
except Exception as e:
logger.error(f"사용자 검색 API 오류 (term: {term}, page: {page}): {e}", exc_info=True)
users_list_page = []
total_results_count = 0
results = [{'id': user['user_id'], 'text': user['user_display_name']} for user in users_list_page]
# Select2의 pagination.more 계산
more_pages = (page * page_size) < total_results_count
# total_count는 Select2 자체에서는 직접 사용하지 않지만, 디버깅이나 다른 용도로 유용할 수 있음
return JsonResponse({'results': results, 'pagination': {'more': more_pages}, 'total_count': total_results_count})

View File

@ -0,0 +1,100 @@
# /data/gyber/apps/web/gyber/views/audit.py
import logging
import math
from django.shortcuts import render
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.urls import reverse_lazy
from ..auth_utils import is_admin_user
from datetime import datetime
# 상위 디렉토리의 모듈 임포트
from ..db.audit import (
get_resource_audit_logs, get_user_audit_logs,
get_group_audit_logs, get_category_audit_logs
)
logger = logging.getLogger(__name__)
# --- 공통 로그 처리 함수 (선택적 리팩토링) ---
def _process_log_request(request, log_fetch_function, log_type_title, template_name):
"""로그 요청 처리 및 컨텍스트 생성을 위한 공통 함수"""
search_query = request.GET.get('query', None)
start_date_str = request.GET.get('start_date', None)
end_date_str = request.GET.get('end_date', None)
try: page_number = int(request.GET.get('page', 1)); page_number = max(1, page_number)
except ValueError: page_number = 1
page_size = 20 # 페이지 크기 (필요시 GET 파라미터 사용)
start_date, end_date = None, None
try:
if start_date_str: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError: messages.warning(request, "시작일 형식이 잘못되었습니다 (YYYY-MM-DD).")
try:
if end_date_str: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError: messages.warning(request, "종료일 형식이 잘못되었습니다 (YYYY-MM-DD).")
try:
audit_logs, total_count = log_fetch_function(
search_term=search_query, start_date=start_date, end_date=end_date,
page_num=page_number, page_size=page_size
)
except Exception as e:
logger.error(f"Error fetching {log_type_title} audit logs: {e}", exc_info=True)
messages.error(request, f"{log_type_title} 로그를 불러오는 중 오류가 발생했습니다.")
audit_logs, total_count = [], 0
total_pages = 0; page_numbers = []; has_previous = False; has_next = False; previous_page_number = 1; next_page_number = 1
if total_count >= 0:
try: total_pages = math.ceil(total_count / page_size)
except ZeroDivisionError: total_pages = 1
page_number = min(page_number, total_pages) if total_pages > 0 else 1
page_range_size = 5; half_range = page_range_size // 2
start_page = max(page_number - half_range, 1); end_page = min(page_number + half_range, total_pages)
if end_page - start_page + 1 < page_range_size:
if start_page == 1: end_page = min(start_page + page_range_size - 1, total_pages)
elif end_page == total_pages: start_page = max(end_page - page_range_size + 1, 1)
page_numbers = range(start_page, end_page + 1)
has_previous = page_number > 1; has_next = page_number < total_pages
previous_page_number = page_number - 1; next_page_number = page_number + 1
else: page_numbers = [page_number]
query_params_all = request.GET.copy(); query_params_all.pop('page', None)
query_params_all_str = query_params_all.urlencode()
context = {
'audit_logs': audit_logs, 'log_type_title': log_type_title,
'search_query': search_query, 'start_date': start_date_str, 'end_date': end_date_str,
'total_count': total_count, 'current_page': page_number, 'total_pages': total_pages,
'page_numbers': page_numbers, 'has_previous': has_previous, 'has_next': has_next,
'previous_page_number': previous_page_number, 'next_page_number': next_page_number,
'query_params_all': query_params_all_str, 'page_size': page_size,
'user_is_amdin_group_member': is_admin_user(request.user)
}
return render(request, template_name, context)
# --- 각 로그 뷰 함수 (공통 함수 사용) ---
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_log_list_view(request):
"""자산 관련 감사 로그 목록"""
return _process_log_request(request, get_resource_audit_logs, '자산', 'gyber/resource_log_list.html')
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def user_log_list_view(request):
"""사용자 관리 감사 로그 목록"""
return _process_log_request(request, get_user_audit_logs, '사용자', 'gyber/user_log_list.html')
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def group_log_list_view(request):
"""그룹(부서) 관리 감사 로그 목록"""
return _process_log_request(request, get_group_audit_logs, '그룹(부서)', 'gyber/group_log_list.html')
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def category_log_list_view(request):
"""자산 카테고리 관리 감사 로그 목록"""
return _process_log_request(request, get_category_audit_logs, '카테고리', 'gyber/category_log_list.html')

View File

@ -0,0 +1,51 @@
# /data/gyber/apps/web/gyber/views/auth.py
import logging
from django.contrib.auth import logout
from django.shortcuts import redirect
from django.conf import settings
from urllib.parse import urlencode
from django.urls import reverse # reverse 사용 예시 포함
logger = logging.getLogger(__name__)
def custom_oidc_logout(request):
"""
Django 세션을 로그아웃하고 OIDC Provider의 로그아웃 엔드포인트로 리디렉션합니다.
"""
logger.info(f"Initiating OIDC logout for user: {request.user}")
# Django 세션 로그아웃 먼저 수행
logout(request)
# settings.py 에 OIDC 로그아웃 관련 설정이 있는지 확인
if hasattr(settings, 'OIDC_OP_LOGOUT_ENDPOINT') and settings.OIDC_OP_LOGOUT_ENDPOINT:
logout_url = settings.OIDC_OP_LOGOUT_ENDPOINT
params = {}
# post_logout_redirect_uri 가 설정되어 있으면 파라미터에 추가
if hasattr(settings, 'OIDC_RP_POST_LOGOUT_REDIRECT_URI') and settings.OIDC_RP_POST_LOGOUT_REDIRECT_URI:
params['post_logout_redirect_uri'] = settings.OIDC_RP_POST_LOGOUT_REDIRECT_URI
# IdP에 따라 client_id 등 추가 파라미터가 필요할 수 있음 (Azure AD는 보통 필요 없음)
# if hasattr(settings, 'OIDC_RP_CLIENT_ID'):
# params['client_id'] = settings.OIDC_RP_CLIENT_ID
# URL 파라미터 인코딩
query_string = urlencode(params)
redirect_url = f"{logout_url}?{query_string}" if query_string else logout_url
logger.info(f"Redirecting to OIDC logout endpoint: {redirect_url}")
return redirect(redirect_url)
else:
# OIDC 로그아웃 엔드포인트 설정이 없으면, settings.LOGOUT_REDIRECT_URL 로 이동
logger.warning("OIDC_OP_LOGOUT_ENDPOINT not configured. Redirecting to LOGOUT_REDIRECT_URL.")
logout_redirect_url = settings.LOGOUT_REDIRECT_URL
# LOGOUT_REDIRECT_URL 이 URL 이름일 경우 reverse 사용
try:
# 만약 URL 이름이라면 resolve 시도
logout_redirect_url = reverse(logout_redirect_url)
except Exception:
# URL 이름이 아니거나 resolve 실패 시 그대로 사용
pass
return redirect(logout_redirect_url)

View File

@ -0,0 +1,130 @@
# /data/gyber/apps/web/gyber/views/category.py
import logging
from django.shortcuts import render, redirect
from django.http import Http404
from django.urls import reverse, reverse_lazy
from ..auth_utils import is_admin_user, is_viewer_user
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
# 상위 디렉토리의 모듈 임포트
from ..db.category import (
get_all_categories, get_category_by_id, add_new_category,
update_category_name, delete_category
)
from ..forms import CategoryForm # CategoryForm 은 category_name 만 가짐
logger = logging.getLogger(__name__)
@login_required
@user_passes_test(is_viewer_user, login_url=reverse_lazy('gyber:dashboard'))
def category_list(request):
"""자산 카테고리 목록"""
logger.info(f"User {request.user.username} accessed category list page.")
categories = []
try:
categories = get_all_categories() or []
# 카테고리별 자산 수는 필요 시 프로시저 수정 또는 여기서 추가 조회
except Exception as e:
logger.error(f"Error loading category list: {e}", exc_info=True)
messages.error(request, "카테고리 목록을 불러오는 중 오류가 발생했습니다.")
context = {
'category_list': categories,
'section': 'categories'
}
return render(request, 'gyber/category_list.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def category_add(request):
"""새 자산 카테고리 추가"""
logger.info(f"User {request.user.username} trying to add a new category.")
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message, new_category_id = add_new_category(
admin_user_id=request.user.id, actor_description=None,
category_name=cleaned_data['category_name']
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:category_list'))
else:
messages.error(request, f"카테고리 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding category: {e}", exc_info=True)
messages.error(request, f"카테고리 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
form = CategoryForm()
context = { 'form': form, 'is_edit_mode': False }
return render(request, 'gyber/category_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def category_edit(request, category_id):
"""카테고리 이름 수정"""
logger.info(f"User {request.user.username} trying to edit category ID: {category_id}.")
try: category_data = get_category_by_id(category_id)
except Exception as e: logger.error(f"Error fetching category {category_id} for edit: {e}"); category_data = None
if category_data is None: raise Http404("수정할 카테고리를 찾을 수 없습니다.")
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message = update_category_name(
admin_user_id=request.user.id, actor_description=None,
category_id=category_id, new_category_name=cleaned_data['category_name']
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:category_list'))
else:
messages.error(request, f"카테고리 이름 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating category {category_id}: {e}", exc_info=True)
messages.error(request, f"카테고리 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
initial_data = { 'category_name': category_data.get('category_name') }
form = CategoryForm(initial=initial_data)
context = {
'form': form, 'category_id': category_id, 'category_data': category_data,
'is_edit_mode': True
}
return render(request, 'gyber/category_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def category_delete(request, category_id):
"""카테고리 삭제 처리 (POST 전용)"""
logger.info(f"User {request.user.username} trying to delete category ID: {category_id}.")
if request.method == 'POST':
try: category_data = get_category_by_id(category_id)
except Exception as e: logger.error(f"Error fetching category {category_id} before delete: {e}"); category_data = None
category_name = category_data.get('category_name', f'ID {category_id}') if category_data else f'ID {category_id}'
if category_data is None: messages.error(request, "삭제할 카테고리를 찾을 수 없습니다."); return redirect(reverse('gyber:category_list'))
try:
success, message = delete_category(
admin_user_id=request.user.id, actor_description=None, category_id=category_id
)
if success: messages.success(request, message)
else: messages.error(request, f"카테고리 '{category_name}' 삭제 실패: {message}")
return redirect(reverse('gyber:category_list'))
except Exception as e:
logger.error(f"Exception during deleting category {category_id}: {e}", exc_info=True)
messages.error(request, f"카테고리 '{category_name}' 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:category_list'))
else:
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:category_list'))

View File

@ -0,0 +1,66 @@
# /data/gyber/apps/web/gyber/views/dashboard.py
import logging
import json
from django.shortcuts import render
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.urls import reverse_lazy
from ..auth_utils import is_admin_user, dashboard_only_user, is_viewer_user
# 상위 디렉토리의 db 모듈 임포트
from ..db.dashboard import get_dashboard_summary, get_assets_count_by_category
# ★ 수정: 자산 로그 함수 임포트 확인
from ..db.audit import get_resource_audit_logs
logger = logging.getLogger(__name__)
@login_required
@user_passes_test(dashboard_only_user, login_url=reverse_lazy('gyber:dashboard'))
def dashboard_view(request):
logger.info(f"User {request.user.username} accessed dashboard.")
summary_data = {}
category_counts = []
recent_logs = [] # 초기화
category_labels_json = '[]'
category_data_json = '[]'
try:
summary_data = get_dashboard_summary() or {}
except Exception as e:
logger.error(f"Error getting dashboard summary: {e}", exc_info=True)
messages.error(request, "대시보드 요약 정보를 가져오는 중 오류 발생.")
try:
category_counts = get_assets_count_by_category() or []
category_labels_json = json.dumps([item.get('category_name', 'N/A') for item in category_counts])
category_data_json = json.dumps([item.get('asset_count', 0) for item in category_counts])
except Exception as e:
logger.error(f"Error getting category counts: {e}", exc_info=True)
messages.error(request, "카테고리별 자산 수를 가져오는 중 오류 발생.")
try:
# ★ 수정: get_resource_audit_logs 호출 방식 변경
# limit 대신 page_num 과 page_size 를 사용
# 검색/필터 조건은 없으므로 None 전달
audit_logs_page, total_log_count = get_resource_audit_logs(
search_term=None,
start_date=None,
end_date=None,
page_num=1, # 첫 페이지
page_size=5 # 5개만 가져오기
)
recent_logs = audit_logs_page # 결과 목록만 사용
except Exception as e:
# ★ 수정: 에러 메시지 및 로깅 개선
logger.error(f"Error getting recent resource logs: {e}", exc_info=True)
messages.error(request, "최근 활동 로그를 가져오는 중 오류 발생.")
recent_logs = [] # 오류 시 빈 리스트
context = {
'summary': summary_data,
'category_counts': category_counts,
'category_labels_json': category_labels_json,
'category_data_json': category_data_json,
'recent_logs': recent_logs, # 이제 빈 리스트 또는 5개의 로그 포함
'section': 'dashboard'
}
return render(request, 'gyber/dashboard.html', context)

View File

@ -0,0 +1,84 @@
# gyber/views/export_views.py
import csv
import logging
from django.contrib import messages
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required, user_passes_test
from django.urls import reverse_lazy
from ..auth_utils import is_admin_user
from ..db.resource import get_all_resources_for_export, get_resources_by_search_for_export # 페이징 없이 모든 데이터를 가져오는 함수 필요
logger = logging.getLogger('gyber.views.export_views') # 로거 이름 구체화
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def export_resources_csv(request):
search_query = request.GET.get('query', None)
category_filter = request.GET.get('category', None)
group_filter = request.GET.get('group', None)
user_filter = request.GET.get('user_id', None)
sort_by = request.GET.get('sort', 'id') # 정렬 조건은 유지
sort_dir = request.GET.get('dir', 'desc')
try: current_category = int(category_filter) if category_filter and category_filter.isdigit() else None
except ValueError: current_category = None
try: current_group = int(group_filter) if group_filter and group_filter.isdigit() else None
except ValueError: current_group = None
try: current_user_id = int(user_filter) if user_filter and user_filter.isdigit() else None
except ValueError: current_user_id = None
all_resources = []
try:
if search_query:
# ★★★ 페이징 없는 검색 함수 호출 ★★★
all_resources = get_resources_by_search_for_export(
search_term=search_query,
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group, user_id=current_user_id
)
else:
# ★★★ 페이징 없는 전체 조회 함수 호출 ★★★
all_resources = get_all_resources_for_export(
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group, user_id=current_user_id
)
if all_resources is None: # DB 함수에서 오류 시 None 반환 가정
all_resources = []
messages.error(request, "데이터를 가져오는 중 오류가 발생했습니다.")
# 오류 발생 시 빈 CSV를 반환하거나, 리디렉션 등을 고려할 수 있음
# 여기서는 빈 CSV 반환으로 진행
except Exception as e:
logger.error(f"Error fetching all resources for CSV export: {e}", exc_info=True)
messages.error(request, "데이터를 가져오는 중 예외가 발생했습니다.")
all_resources = [] # 예외 발생 시 빈 리스트
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="gyber_resources.csv"'
writer = csv.writer(response)
header = ['자산 ID', '제품명', '카테고리', '관리 코드', '제조사', '시리얼 번호', '사용자', '부서', '구매일', '등록일', '수정일', '잠금상태', '비고']
writer.writerow(header)
if all_resources: # 데이터가 있을 때만 행 작성
for resource in all_resources:
writer.writerow([
resource.get('resource_id', ''),
resource.get('resource_name', ''),
resource.get('category_name', ''),
resource.get('resource_code', ''),
resource.get('manufacturer', ''),
resource.get('serial_num', ''),
resource.get('user_display_name', ''),
resource.get('group_name', ''),
resource.get('purchase_date', ''),
resource.get('register_date', ''),
resource.get('update_date', ''),
'잠김' if resource.get('is_locked') else '해제',
resource.get('comments', '')
])
else: # 데이터가 없을 경우 헤더만 있는 빈 CSV가 아닌, 메시지 포함한 내용으로 대체 가능
# writer.writerow(["데이터가 없습니다."]) # 또는 이 부분 없이 헤더만 있는 파일로
pass
return response

View File

@ -0,0 +1,163 @@
# /data/gyber/apps/web/gyber/views/group.py
import logging
from django.shortcuts import render, redirect
from django.http import Http404
from django.urls import reverse
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.urls import reverse_lazy
from ..auth_utils import is_admin_user, is_viewer_user
# 상위 디렉토리의 모듈 임포트
from ..db.group import (
get_all_groups, get_group_by_id, add_new_group,
update_group, delete_group
)
# GroupForm 은 manager 선택 위해 user 목록 필요
from ..db.user import get_all_users
from ..forms import GroupForm # ★ GroupForm 필드 업데이트 필요 (manager_user)
logger = logging.getLogger(__name__)
@login_required
@user_passes_test(is_viewer_user, login_url=reverse_lazy('gyber:dashboard'))
def group_list(request):
"""그룹(부서) 목록"""
logger.info(f"User {request.user.username} accessed group list page.")
groups = []
try:
groups = get_all_groups() or []
# 그룹별 사용자 수, 매니저 정보 등은 필요 시 get_all_groups 프로시저 수정 또는 여기서 추가 조회
# 예: 매니저 정보 추가 (N+1 주의)
# users_dict = {u['user_id']: u['user_display_name'] for u in get_all_users()}
# for g in groups:
# g['manager_name'] = users_dict.get(g.get('manager_user_id'))
except Exception as e:
logger.error(f"Error loading group list: {e}", exc_info=True)
messages.error(request, "그룹(부서) 목록을 불러오는 중 오류가 발생했습니다.")
context = {
'group_list': groups, # ★ Template: group.manager_user_id 등 확인
'section': 'groups'
}
return render(request, 'gyber/group_list.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def group_add(request):
"""새 그룹(부서) 추가"""
logger.info(f"User {request.user.username} trying to add a new group.")
# --- Choices 로드 ---
user_choices = [('', '---------')]
try:
users = get_all_users() or []
user_choices.extend([(user['user_id'], user['user_display_name']) for user in users])
except Exception as e: logger.error(f"Error loading users for GroupForm: {e}"); messages.error(request, "관리자 목록 로드 오류")
if request.method == 'POST':
# ★ 변경: 폼 생성 시 choices 전달
form = GroupForm(request.POST, user_choices=user_choices)
# ★ 제거: __init__ 에서 로드하는 방식 대신 뷰에서 전달
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message, new_group_id = add_new_group(
admin_user_id=request.user.id, actor_description=None,
group_name=cleaned_data['group_name'],
manager_user_id=cleaned_data.get('manager_user') or None
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:group_list'))
else:
messages.error(request, f"그룹(부서) 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding group: {e}", exc_info=True)
messages.error(request, f"그룹(부서) 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
# ★ 변경: 폼 생성 시 choices 전달
form = GroupForm(user_choices=user_choices)
# ★ 제거: __init__ 에서 로드하는 방식 대신 뷰에서 전달
context = { 'form': form, 'is_edit_mode': False }
return render(request, 'gyber/group_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def group_edit(request, group_id):
"""그룹(부서) 정보 수정"""
logger.info(f"User {request.user.username} trying to edit group ID: {group_id}.")
try: group_data = get_group_by_id(group_id)
except Exception as e: logger.error(f"Error fetching group {group_id} for edit: {e}"); group_data = None
if group_data is None: raise Http404("수정할 그룹(부서)을 찾을 수 없습니다.")
# --- Choices 로드 ---
user_choices = [('', '---------')]
try:
users = get_all_users() or []
user_choices.extend([(user['user_id'], user['user_display_name']) for user in users])
except Exception as e: logger.error(f"Error loading users for GroupForm (edit): {e}"); messages.error(request, "관리자 목록 로드 오류")
if request.method == 'POST':
# ★ 변경: 폼 생성 시 choices 전달
form = GroupForm(request.POST, user_choices=user_choices)
# ★ 제거: __init__ 에서 로드하는 방식 대신 뷰에서 전달
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message = update_group(
admin_user_id=request.user.id, actor_description=None,
group_id=group_id, group_name=cleaned_data['group_name'],
manager_user_id=cleaned_data.get('manager_user') or None
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:group_list'))
else:
messages.error(request, f"그룹(부서) 정보 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating group {group_id}: {e}", exc_info=True)
messages.error(request, f"그룹(부서) 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
initial_data = {
'group_name': group_data.get('group_name'),
'manager_user': group_data.get('manager_user_id')
}
# ★ 변경: 폼 생성 시 initial 데이터와 choices 전달
form = GroupForm(initial=initial_data, user_choices=user_choices)
# ★ 제거: __init__ 에서 로드하는 방식 대신 뷰에서 전달
context = { 'form': form, 'group_id': group_id, 'is_edit_mode': True }
return render(request, 'gyber/group_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def group_delete(request, group_id):
"""그룹(부서) 삭제 처리 (POST 전용)"""
logger.info(f"User {request.user.username} trying to delete group ID: {group_id}.")
if request.method == 'POST':
try: group_data = get_group_by_id(group_id)
except Exception as e: logger.error(f"Error fetching group {group_id} before delete: {e}"); group_data = None
group_name = group_data.get('group_name', f'ID {group_id}') if group_data else f'ID {group_id}'
if group_data is None: messages.error(request, "삭제할 그룹(부서)을 찾을 수 없습니다."); return redirect(reverse('gyber:group_list'))
try:
success, message = delete_group(
admin_user_id=request.user.id, actor_description=None, group_id=group_id
)
if success: messages.success(request, message)
else: messages.error(request, f"그룹 '{group_name}' 삭제 실패: {message}")
return redirect(reverse('gyber:group_list'))
except Exception as e:
logger.error(f"Exception during deleting group {group_id}: {e}", exc_info=True)
messages.error(request, f"그룹 '{group_name}' 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:group_list'))
else:
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:group_list'))

View File

@ -0,0 +1,344 @@
# /data/gyber/apps/web/gyber/views/resource.py
import logging
import math
from django.shortcuts import render, redirect, get_object_or_404
from django.http import Http404
from django.urls import reverse, reverse_lazy
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from ..auth_utils import is_admin_user, is_viewer_user
# DB 모듈 임포트 (일관성을 위해 별칭 사용 권장, 여기서는 기존 방식 유지)
from ..db.resource import (
get_all_resources, get_resource_by_id, add_new_resource, # add_new_resource는 add_resource로 변경 고려
update_resource, delete_resource, get_resources_by_search
)
from ..db.category import get_all_categories
from ..db.group import get_all_groups
from ..db.user import get_all_users, get_user_by_id
from ..forms import ResourceForm
logger = logging.getLogger('gyber.views.resource') # 로거 이름 구체화
# resource_list, resource_detail, resource_add, resource_delete 함수는 이전과 동일하게 유지
# ... (이전 resource_list, resource_detail, resource_add, resource_delete 함수 코드) ...
@login_required
@user_passes_test(is_viewer_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_list(request):
"""자산 목록 (검색, 페이징, 정렬, 필터링)"""
search_query = request.GET.get('query', None)
try: page_number = int(request.GET.get('page', 1)); page_number = max(1, page_number)
except ValueError: page_number = 1
try:
page_size = int(request.GET.get('page_size', 20))
valid_page_sizes = [10, 20, 50, 100]
if page_size not in valid_page_sizes: page_size = 20
except ValueError: page_size = 20
sort_by = request.GET.get('sort', 'id') # DB 스키마 기반 컬럼명 사용
sort_dir = request.GET.get('dir', 'desc')
category_filter = request.GET.get('category', None)
group_filter = request.GET.get('group', None)
user_filter = request.GET.get('user_id', None)
allowed_sort_columns = ['id', 'name', 'category', 'code', 'user', 'group', 'serial', 'purchased', 'registered', 'updated', 'is_locked']
if sort_by not in allowed_sort_columns: sort_by = 'id'
if sort_dir not in ['asc', 'desc']: sort_dir = 'desc'
try: current_category = int(category_filter) if category_filter and category_filter.isdigit() else None # isdigit() 추가
except ValueError: current_category = None
try: current_group = int(group_filter) if group_filter and group_filter.isdigit() else None # isdigit() 추가
except ValueError: current_group = None
try: current_user_id = int(user_filter) if user_filter and user_filter.isdigit() else None # isdigit() 추가
except ValueError: current_user_id = None
resource_list_page, total_count = [], 0
try:
if search_query:
logger.debug(f"Searching resources with query='{search_query}', page={page_number}, size={page_size}, sort='{sort_by}', dir='{sort_dir}', category={current_category}, group={current_group}, user={current_user_id}")
resource_list_page, total_count = get_resources_by_search(
search_term=search_query, page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group, user_id=current_user_id
)
else:
logger.debug(f"Fetching all resources, page={page_number}, size={page_size}, sort='{sort_by}', dir='{sort_dir}', category={current_category}, group={current_group}, user={current_user_id}")
resource_list_page, total_count = get_all_resources(
page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir,
category_id=current_category, group_id=current_group, user_id=current_user_id
)
except Exception as e:
logger.error(f"Error fetching resource list: {e}", exc_info=True)
messages.error(request, "자산 목록을 불러오는 중 오류가 발생했습니다.")
try: total_pages = math.ceil(total_count / page_size)
except ZeroDivisionError: total_pages = 1
page_number = min(page_number, total_pages) if total_pages > 0 else 1
page_range_size = 5; half_range = page_range_size // 2
start_page = max(page_number - half_range, 1)
end_page = min(start_page + page_range_size - 1, total_pages)
if end_page - start_page + 1 < page_range_size and total_pages >= page_range_size:
if start_page == 1: end_page = min(page_range_size, total_pages)
elif end_page == total_pages: start_page = max(total_pages - page_range_size + 1, 1)
page_numbers = range(start_page, end_page + 1)
query_params_all = request.GET.copy(); query_params_all.pop('page', None)
query_params_all_str = query_params_all.urlencode()
query_params_no_filter = request.GET.copy(); query_params_no_filter.pop('page', None); query_params_no_filter.pop('category', None); query_params_no_filter.pop('group', None); query_params_no_filter.pop('user_id', None)
query_params_no_filter_str = query_params_no_filter.urlencode()
query_params_no_search = request.GET.copy(); query_params_no_search.pop('page', None); query_params_no_search.pop('query', None)
query_params_no_search_str = query_params_no_search.urlencode()
category_list, group_list = [], []
try: category_list = get_all_categories() or []
except Exception as e: logger.error(f"Error loading category list: {e}"); messages.error(request, "카테고리 필터 로드 오류")
try: group_list = get_all_groups() or []
except Exception as e: logger.error(f"Error loading group list: {e}"); messages.error(request, "그룹 필터 로드 오류")
filtered_user_info = None
if current_user_id:
try: filtered_user_info = get_user_by_id(current_user_id)
except Exception as e: logger.error(f"Error loading user info for filter (user_id: {current_user_id}): {e}")
if not filtered_user_info: logger.warning(f"User filter applied for non-existent user_id: {current_user_id}")
context = {
'resource_list': resource_list_page,
'total_count': total_count, 'page_size': page_size, 'valid_page_sizes': valid_page_sizes,
'current_page': page_number, 'total_pages': total_pages, 'page_numbers': page_numbers,
'has_previous': page_number > 1, 'has_next': page_number < total_pages,
'previous_page_number': page_number - 1, 'next_page_number': page_number + 1,
'search_query': search_query, 'sort_by': sort_by, 'sort_dir': sort_dir,
'query_params_all': query_params_all_str, 'query_params_no_filter': query_params_no_filter_str,
'query_params_no_search': query_params_no_search_str,
'category_list': category_list, 'group_list': group_list,
'current_category': current_category, 'current_group': current_group,
'current_user_id': current_user_id, 'filtered_user_info': filtered_user_info,
'section': 'resource_list'
}
return render(request, 'gyber/resource_list.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_detail(request, resource_id):
"""자산 상세 정보"""
try:
resource = get_resource_by_id(resource_id)
if resource is None:
raise Http404("해당 ID의 자산을 찾을 수 없습니다.")
except Exception as e:
logger.error(f"자산 상세(ID: {resource_id}) 정보 로드 오류: {e}", exc_info=True)
messages.error(request, "자산 상세 정보를 불러오는 중 오류가 발생했습니다.")
return redirect(reverse('gyber:resource_list'))
context = {
'resource': resource,
}
return render(request, 'gyber/resource_detail.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_add(request):
"""새 자산 추가"""
category_choices_data = [] # 기본값
try:
categories = get_all_categories() or []
category_choices_data = [(cat['category_id'], cat['category_name']) for cat in categories]
except Exception as e:
logger.error(f"자산 추가: 카테고리 로드 오류: {e}")
messages.error(request, "카테고리 정보 로드 실패")
# 사용자 검색 방식이므로 user_choices_data는 폼 생성 시 필요 없음
if request.method == 'POST':
form = ResourceForm(request.POST, category_choices=category_choices_data) # user_choices 제거
if form.is_valid():
cleaned_data = form.cleaned_data
try:
is_locked_value = cleaned_data.get('is_locked', False)
user_id_value = cleaned_data.get('user') # 폼에서 user_id를 직접 받음
# add_new_resource 함수가 (success, message, new_id) 튜플을 반환한다고 가정
success, message, new_resource_id = add_new_resource(
admin_user_id=request.user.id,
actor_description=f"WebApp User: {request.user.username}",
category_id=cleaned_data['category'],
resource_code=cleaned_data.get('resource_code'),
manufacturer=cleaned_data.get('manufacturer'),
resource_name=cleaned_data['resource_name'],
serial_num=cleaned_data.get('serial_num'),
spec_value=cleaned_data.get('spec_value'),
spec_unit=cleaned_data.get('spec_unit') or None,
user_id=user_id_value, # ★★★ 전달하는 user_id 값 ★★★
comments=cleaned_data.get('comments'),
purchase_date=cleaned_data.get('purchase_date'),
is_locked=is_locked_value
)
if success:
messages.success(request, message or f"자산 '{cleaned_data['resource_name']}' 추가 성공 (ID: {new_resource_id}).")
return redirect(reverse('gyber:resource_list'))
else:
messages.error(request, f"자산 추가 실패: {message}")
except Exception as e:
logger.error(f"자산 추가 처리 중 예외: {e}", exc_info=True)
messages.error(request, f"자산 추가 중 오류가 발생했습니다: {e}")
else:
logger.warning(f"자산 추가 폼 유효성 검사 실패: {form.errors.as_json()}")
messages.warning(request, f"입력 내용을 확인해주세요. {form.errors.as_ul()}")
else: # GET
form = ResourceForm(category_choices=category_choices_data) # user_choices 제거
context = {
'form': form,
'form_title': '새 자산 등록',
'is_edit_mode': False
# 'initial_selected_user_id' 와 'initial_selected_user_name' 은 추가 모드에서는 불필요
}
return render(request, 'gyber/resource_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_edit(request, resource_id):
"""자산 정보 수정"""
try:
resource_data = get_resource_by_id(resource_id) # is_locked 및 user_display_name 포함 가정
if resource_data is None:
logger.warning(f"수정할 자산 ID {resource_id}를 찾을 수 없습니다.")
raise Http404("수정할 자산을 찾을 수 없습니다.")
logger.debug(f"DB에서 가져온 resource_data (ID: {resource_id}): {resource_data}")
except Exception as e:
logger.error(f"자산(ID: {resource_id}) 정보 로드 실패 (수정용): {e}", exc_info=True)
messages.error(request, "자산 정보를 불러오는 중 오류가 발생했습니다.")
return redirect(reverse('gyber:resource_list'))
category_choices_data = []
try:
categories = get_all_categories() or []
category_choices_data = [(cat['category_id'], cat['category_name']) for cat in categories]
except Exception as e: logger.error(f"자산 수정: 카테고리 로드 오류: {e}"); messages.error(request, "카테고리 정보 로드 실패")
# 사용자 검색 방식이므로 user_choices_data는 여기서 로드하지 않음
# ★★★ GET 또는 POST 실패 시 템플릿에 전달할 초기 사용자 정보 변수 ★★★
initial_selected_user_id_for_template = None
initial_selected_user_name_for_template = ''
if request.method == 'POST':
form = ResourceForm(request.POST, category_choices=category_choices_data) # user_choices 제거
if form.is_valid():
cleaned_data = form.cleaned_data
logger.debug(f"수정 폼 제출 (ID: {resource_id}), cleaned_data: {cleaned_data}")
try:
is_locked_value = cleaned_data.get('is_locked', False)
user_id_value = cleaned_data.get('user') # 폼에서 user_id (정수)를 직접 받음
success, message = update_resource(
admin_user_id=request.user.id,
actor_description=f"WebApp User: {request.user.username}",
resource_id=resource_id,
category_id=cleaned_data['category'],
resource_code=cleaned_data.get('resource_code'),
manufacturer=cleaned_data.get('manufacturer'),
resource_name=cleaned_data['resource_name'],
serial_num=cleaned_data.get('serial_num'),
spec_value=cleaned_data.get('spec_value'),
spec_unit=cleaned_data.get('spec_unit') or None,
user_id=user_id_value, # 전달하는 user_id 값
comments=cleaned_data.get('comments'),
purchase_date=cleaned_data.get('purchase_date'),
is_locked=is_locked_value
)
if success:
messages.success(request, message or f"자산 (ID: {resource_id}) 수정 성공.")
return redirect(reverse('gyber:resource_detail', args=[resource_id]))
else:
messages.error(request, f"자산 수정 실패: {message}")
except Exception as e:
logger.error(f"자산(ID: {resource_id}) 수정 처리 중 예외: {e}", exc_info=True)
messages.error(request, f"자산 수정 중 오류가 발생했습니다: {e}")
else: # 폼 유효성 검사 실패 시
logger.warning(f"자산 수정 폼 유효성 검사 실패 (ID: {resource_id}): {form.errors.as_json()}")
messages.warning(request, f"입력 내용을 확인해주세요. {form.errors.as_ul()}")
# 실패 시에도 입력된 user ID로 사용자 이름 찾아 템플릿에 전달 (Select2 초기값 유지를 위해)
user_id_from_invalid_form = request.POST.get('user') # 폼 필드 이름 'user'
if user_id_from_invalid_form and user_id_from_invalid_form.isdigit():
try:
user_info_for_template = get_user_by_id(int(user_id_from_invalid_form))
if user_info_for_template:
initial_selected_user_id_for_template = int(user_id_from_invalid_form)
initial_selected_user_name_for_template = user_info_for_template.get('user_display_name', '')
except Exception as e:
logger.error(f"잘못된 POST 폼에서 사용자 정보 조회 오류 (ID: {user_id_from_invalid_form}): {e}")
else: # GET 요청 (폼을 처음 보여줄 때)
raw_db_is_locked = resource_data.get('is_locked')
initial_is_locked = False
if raw_db_is_locked is not None:
initial_is_locked = bool(raw_db_is_locked)
initial_user_id = resource_data.get('user_id')
# get_resource_by_id가 user_display_name을 반환해야 함
initial_user_display_name = resource_data.get('user_display_name', '')
initial_selected_user_id_for_template = initial_user_id
initial_selected_user_name_for_template = initial_user_display_name
initial_data = {
'category': str(resource_data.get('category_id', '')),
'resource_code': resource_data.get('resource_code', ''),
'manufacturer': resource_data.get('manufacturer', ''),
'resource_name': resource_data.get('resource_name', ''),
'serial_num': resource_data.get('serial_num', ''),
'spec_value': resource_data.get('spec_value'),
'spec_unit': str(resource_data.get('spec_unit', '')),
'user': initial_user_id, # 폼의 hidden input 'user' 필드에 user_id 설정
'comments': resource_data.get('comments', ''),
'purchase_date': resource_data.get('purchase_date'),
'is_locked': initial_is_locked
}
if initial_data.get('purchase_date') and hasattr(initial_data['purchase_date'], 'strftime'):
initial_data['purchase_date'] = initial_data['purchase_date'].strftime('%Y-%m-%d')
form = ResourceForm(initial=initial_data, category_choices=category_choices_data)
context = {
'form': form,
'resource': resource_data, # 템플릿에서 기존 다른 정보 표시용
'resource_id': resource_id,
'form_title': '자산 정보 수정',
'is_edit_mode': True,
# ★★★ 템플릿에 전달할 초기 선택 사용자 정보 ★★★
'initial_selected_user_id': initial_selected_user_id_for_template,
'initial_selected_user_name': initial_selected_user_name_for_template,
}
return render(request, 'gyber/resource_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def resource_delete(request, resource_id):
"""자산 삭제 처리 (POST 전용)"""
logger.info(f"User '{request.user.username}' trying to delete resource ID: {resource_id}.") # 로깅 개선
if request.method == 'POST':
resource_name_for_log = f'ID {resource_id}'
try:
resource_data_for_log = get_resource_by_id(resource_id)
if resource_data_for_log:
resource_name_for_log = resource_data_for_log.get('resource_name', resource_name_for_log)
except Exception:
logger.warning(f"삭제 전 자산 정보 로드 실패 (ID: {resource_id})")
try:
success, message = delete_resource(
admin_user_id=request.user.id,
actor_description=f"WebApp User: {request.user.username}", # actor_description 추가
resource_id=resource_id
)
if success: messages.success(request, message or f"자산 '{resource_name_for_log}' 삭제 성공.")
else: messages.error(request, f"'{resource_name_for_log}' 자산 삭제 실패: {message}")
except Exception as e:
logger.error(f"자산(ID: {resource_id}) 삭제 처리 중 예외: {e}", exc_info=True)
messages.error(request, f"'{resource_name_for_log}' 자산 삭제 중 오류가 발생했습니다: {e}")
return redirect(reverse('gyber:resource_list'))
else:
messages.error(request, "잘못된 접근 방식입니다.")
return redirect(reverse('gyber:resource_list'))

View File

@ -0,0 +1,213 @@
# /data/gyber/apps/web/gyber/views/user.py
import logging
import math
from django.shortcuts import render, redirect
from django.http import Http404
from django.urls import reverse, reverse_lazy
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from ..auth_utils import is_admin_user, is_viewer_user
# 상위 디렉토리의 모듈 임포트
from ..db.user import (
get_user_list, get_user_by_id, add_new_user,
update_user, delete_user, get_all_users
)
from ..db.group import get_all_groups
from ..forms import UserForm # ★ UserForm 필드 업데이트 필요 (display_name, account_name)
logger = logging.getLogger(__name__)
@login_required
@user_passes_test(is_viewer_user, login_url=reverse_lazy('gyber:dashboard'))
def user_list_view(request): # 함수명 변경 user_status_view -> user_list_view
"""사용자 목록 (검색, 페이징, 정렬, 필터링)"""
logger.info(f"User {request.user.username} accessed user list page.")
search_query = request.GET.get('query', None)
try: page_number = int(request.GET.get('page', 1)); page_number = max(1, page_number)
except ValueError: page_number = 1
try:
page_size = int(request.GET.get('page_size', 20))
valid_page_sizes = [10, 20, 50, 100]
if page_size not in valid_page_sizes: page_size = 20
except ValueError: page_size = 20
sort_by = request.GET.get('sort', 'name') # 정렬 컬럼명 확인 (name, account, group, assets)
sort_dir = request.GET.get('dir', 'asc')
group_filter = request.GET.get('group', None)
allowed_sort_columns = ['name', 'account', 'group', 'assets'] # 'email' -> 'account'
if sort_by not in allowed_sort_columns: sort_by = 'name'
if sort_dir not in ['asc', 'desc']: sort_dir = 'asc'
try: current_group = int(group_filter) if group_filter else None
except ValueError: current_group = None
user_list_page, total_count = [], 0
try:
# ★ 함수 이름 변경: get_user_status_list -> get_user_list
user_list_page, total_count = get_user_list(
search_term=search_query, page_num=page_number, page_size=page_size,
sort_column=sort_by, sort_direction=sort_dir, group_id=current_group
)
except Exception as e:
logger.error(f"Error fetching user list: {e}", exc_info=True)
messages.error(request, "사용자 목록을 불러오는 중 오류가 발생했습니다.")
try: total_pages = math.ceil(total_count / page_size)
except ZeroDivisionError: total_pages = 1
page_number = min(page_number, total_pages) if total_pages > 0 else 1
page_range_size = 5; half_range = page_range_size // 2
start_page = max(page_number - half_range, 1); end_page = min(page_number + half_range, total_pages)
if end_page - start_page + 1 < page_range_size:
if start_page == 1: end_page = min(start_page + page_range_size - 1, total_pages)
elif end_page == total_pages: start_page = max(end_page - page_range_size + 1, 1)
page_numbers = range(start_page, end_page + 1)
query_params_all = request.GET.copy(); query_params_all.pop('page', None)
query_params_all_str = query_params_all.urlencode()
query_params_no_filter = request.GET.copy(); query_params_no_filter.pop('page', None); query_params_no_filter.pop('group', None)
query_params_no_filter_str = query_params_no_filter.urlencode()
query_params_no_search = request.GET.copy(); query_params_no_search.pop('page', None); query_params_no_search.pop('query', None)
query_params_no_search_str = query_params_no_search.urlencode()
group_list = []
try: group_list = get_all_groups() or []
except Exception as e: logger.error(f"Error loading group list for user list page: {e}"); messages.error(request, "그룹 필터 로드 오류")
context = {
'user_list': user_list_page, # ★ Template: user.display_name, user.account_name 등 확인
'total_count': total_count, 'page_size': page_size, 'valid_page_sizes': valid_page_sizes,
'current_page': page_number, 'total_pages': total_pages, 'page_numbers': page_numbers,
'has_previous': page_number > 1, 'has_next': page_number < total_pages,
'previous_page_number': page_number - 1, 'next_page_number': page_number + 1,
'search_query': search_query, 'sort_by': sort_by, 'sort_dir': sort_dir,
'group_list': group_list, 'current_group': current_group,
'query_params_all': query_params_all_str, 'query_params_no_filter': query_params_no_filter_str,
'query_params_no_search': query_params_no_search_str,
'section': 'user_list' # section 이름 변경
}
# ★ Template 파일 이름 변경 필요: user_status_list.html -> user_list.html
return render(request, 'gyber/user_list.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def user_add(request):
"""새 사용자 추가"""
logger.info(f"User {request.user.username} trying to add a new user.")
# --- Choices 로드 ---
group_choices = [('', '---------')]
try:
groups = get_all_groups() or []
group_choices.extend([(g['group_id'], g['group_name']) for g in groups])
except Exception as e: logger.error(f"Error loading groups for UserForm: {e}"); messages.error(request, "그룹 로드 오류")
if request.method == 'POST':
# ★ 변경: 폼 생성 시 choices 전달
form = UserForm(request.POST, group_choices=group_choices)
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message, new_user_id = add_new_user(
admin_user_id=request.user.id, actor_description=None,
display_name=cleaned_data.get('display_name'),
account_name=cleaned_data['account_name'],
group_id=cleaned_data.get('group') or None
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:user_list'))
else:
messages.error(request, f"사용자 추가 실패: {message}")
except Exception as e:
logger.error(f"Exception during adding user: {e}", exc_info=True)
messages.error(request, f"사용자 추가 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
# ★ 변경: 폼 생성 시 choices 전달
form = UserForm(group_choices=group_choices)
context = { 'form': form, 'is_edit_mode': False }
return render(request, 'gyber/user_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def user_edit(request, user_id):
"""사용자 정보 수정"""
logger.info(f"User {request.user.username} trying to edit user ID: {user_id}.")
try: user_data = get_user_by_id(user_id)
except Exception as e: logger.error(f"Error fetching user {user_id} for edit: {e}"); user_data = None
if user_data is None: raise Http404("수정할 사용자를 찾을 수 없습니다.")
# --- Choices 로드 ---
group_choices = [('', '---------')]
try:
groups = get_all_groups() or []
group_choices.extend([(g['group_id'], g['group_name']) for g in groups])
except Exception as e: logger.error(f"Error loading groups for UserForm (edit): {e}"); messages.error(request, "그룹 로드 오류")
if request.method == 'POST':
# ★ 변경: 폼 생성 시 choices 전달
form = UserForm(request.POST, group_choices=group_choices)
if form.is_valid():
cleaned_data = form.cleaned_data
try:
success, message = update_user(
admin_user_id=request.user.id, actor_description=None, user_id=user_id,
display_name=cleaned_data.get('display_name'),
account_name=cleaned_data['account_name'],
group_id=cleaned_data.get('group') or None
)
if success:
messages.success(request, message)
return redirect(reverse('gyber:user_list'))
else:
messages.error(request, f"사용자 정보 수정 실패: {message}")
except Exception as e:
logger.error(f"Exception during updating user {user_id}: {e}", exc_info=True)
messages.error(request, f"사용자 수정 중 예외가 발생했습니다: {e}")
else:
messages.warning(request, "입력 내용을 확인해주세요.")
else: # GET
initial_data = {
'display_name': user_data.get('display_name'),
'account_name': user_data.get('account_name'),
'group': user_data.get('group_id')
}
# ★ 변경: 폼 생성 시 initial 데이터와 choices 전달
form = UserForm(initial=initial_data, group_choices=group_choices)
context = { 'form': form, 'user_id': user_id, 'is_edit_mode': True }
return render(request, 'gyber/user_form.html', context)
@login_required
@user_passes_test(is_admin_user, login_url=reverse_lazy('gyber:dashboard'))
def user_delete(request, user_id):
"""사용자 삭제 처리 (POST 전용)"""
logger.info(f"User {request.user.username} trying to delete user ID: {user_id}.")
if request.method == 'POST':
try: user_data = get_user_by_id(user_id)
except Exception as e: logger.error(f"Error fetching user {user_id} before delete: {e}"); user_data = None
# ★ 표시 이름 로직 변경 (display_name, account_name)
user_display = user_data.get('display_name') or user_data.get('account_name') or f'ID {user_id}' if user_data else f'ID {user_id}'
if user_data is None: messages.error(request, "삭제할 사용자를 찾을 수 없습니다."); return redirect(reverse('gyber:user_list'))
try:
success, message = delete_user(
admin_user_id=request.user.id, actor_description=None, user_id=user_id
)
if success: messages.success(request, message)
else: messages.error(request, f"사용자 '{user_display}' 삭제 실패: {message}")
return redirect(reverse('gyber:user_list'))
except Exception as e:
logger.error(f"Exception during deleting user {user_id}: {e}", exc_info=True)
messages.error(request, f"사용자 '{user_display}' 삭제 중 예외가 발생했습니다: {e}")
return redirect(reverse('gyber:user_list'))
else:
messages.error(request, "잘못된 접근 방식입니다. 삭제는 POST 방식으로만 가능합니다.")
return redirect(reverse('gyber:user_list'))

22
apps/web/manage.py Normal file
View File

@ -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', 'config.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()

16
apps/web/requirements.txt Normal file
View File

@ -0,0 +1,16 @@
asgiref==3.8.1
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
cryptography==44.0.2
Django==5.2
django-widget-tweaks==1.5.0
idna==3.10
josepy==2.0.0
mozilla-django-oidc==4.0.1
mysqlclient==2.2.7
pycparser==2.22
requests==2.32.3
sqlparse==0.5.3
urllib3==2.4.0
gunicorn==23.0.0

View File

@ -0,0 +1,156 @@
/* static/css/custom_styles.css */
/* 리소스 할당시 유저 검색에 다크테마 적용이 안되는 것 방지용 */
/* Select2 컨테이너 기본 스타일 조정 (Bootstrap 5 form-control 과 유사하게) */
.select2-container--bootstrap-5 .select2-selection--single {
height: calc(1.5em + 0.75rem + 2px); /* var(--bs-body-font-size) * var(--bs-form-select-line-height) + var(--bs-form-select-padding-y) * 2 + var(--bs-form-select-border-width) * 2 와 유사 */
padding: var(--bs-form-select-padding-y) var(--bs-form-select-padding-x);
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color); /* Bootstrap 변수 사용 */
background-color: var(--bs-body-bg); /* Bootstrap 변수 사용 */
border: 1px solid var(--bs-border-color); /* Bootstrap 변수 사용 */
border-radius: var(--bs-border-radius);
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
/* 선택된 항목 텍스트 정렬 및 색상 */
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__rendered {
color: var(--bs-body-color);
line-height: 1.5; /* 기본값 또는 조정 */
padding-left: 0;
padding-right: 0;
}
/* 화살표 아이콘 정렬 */
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__arrow {
height: calc(1.5em + 0.75rem); /* 내부 아이템 높이에 맞춤 */
top: 50%;
transform: translateY(-50%);
}
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__arrow b {
border-color: var(--bs-body-color) transparent transparent transparent; /* Bootstrap 변수 사용 고려 */
}
.select2-container--bootstrap-5.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent var(--bs-body-color) transparent; /* Bootstrap 변수 사용 고려 */
}
/* 드롭다운 패널 스타일 */
.select2-container--bootstrap-5 .select2-dropdown {
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color-translucent); /* Bootstrap 변수 사용 */
border-radius: var(--bs-border-radius);
box-shadow: 0 0.5rem 1rem rgba(var(--bs-body-color-rgb), .15); /* Bootstrap 드롭다운 그림자와 유사하게 */
}
/* 드롭다운 내 검색창 스타일 */
.select2-container--bootstrap-5 .select2-search--dropdown .select2-search__field {
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg); /* Bootstrap 변수 사용 */
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm); /* 약간 작은 radius */
padding: var(--bs-form-select-padding-y) var(--bs-form-select-padding-x);
}
.select2-container--bootstrap-5 .select2-search--dropdown .select2-search__field:focus {
border-color: var(--bs-primary); /* 부트스트랩 primary 색상 */
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
}
/* 드롭다운 결과 항목 스타일 */
.select2-container--bootstrap-5 .select2-results__option {
padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
color: var(--bs-body-color);
transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
}
/* 드롭다운 결과 항목 호버/포커스 스타일 */
.select2-container--bootstrap-5 .select2-results__option--highlighted {
color: var(--bs-primary-text-emphasis); /* Bootstrap 변수 */
background-color: var(--bs-primary-bg-subtle); /* Bootstrap 변수 */
}
.select2-container--bootstrap-5 .select2-results__option[aria-selected=true] {
color: var(--bs-dropdown-link-active-color);
background-color: var(--bs-dropdown-link-active-bg);
}
/* "더 많은 결과 로딩 중..." 메시지 스타일 */
.select2-container--bootstrap-5 .select2-results__option--loading {
color: var(--bs-secondary-color);
}
/* "결과 없음" 메시지 스타일 */
.select2-container--bootstrap-5 .select2-results__message {
color: var(--bs-secondary-color);
padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
}
/* 선택 해제(X) 버튼 스타일 (allowClear: true 사용 시) */
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear {
/* 필요시 스타일 조정 */
color: var(--bs-secondary-color);
font-size: 1.2em; /* 아이콘 크기 조정 */
right: 2.5rem; /* 화살표 왼쪽에 위치하도록 조정 */
}
.select2-container--bootstrap-5 .select2-selection--single .select2-selection__clear:hover {
color: var(--bs-danger);
}
/* static/css/custom_styles.css 또는 컴파일된 tables.css */
/* 왜 테이블에 다크 테마가 부분적으로만 적용되는지 미스테리 */
/* --- 다크 모드 스타일 --- */
html[data-bs-theme="dark"] {
/* 일반 테이블 헤더 (thead에 table-light 클래스가 없을 경우) */
.table > thead > tr > th {
color: var(--bs-body-color); /* 다크 모드 기본 텍스트 색상 */
background-color: var(--bs-tertiary-bg); /* 다크 모드 테이블 배경과 유사하게 */
border-color: var(--bs-border-color-translucent);
}
/* ★★★ thead에 table-light 클래스가 적용된 경우의 스타일 덮어쓰기 ★★★ */
.table thead.table-light th { /* a 태그가 아닌 th 자체에 배경과 글자색 적용 */
color: var(--bs-body-color); /* 예: Bootstrap 다크 모드의 기본 텍스트 색상 */
background-color: #343a40; /* 예: Bootstrap의 $gray-800 과 유사한 어두운 색 */
/* 또는 var(--bs-emphasis-color) 에 맞춰 var(--bs-secondary-bg) 등 */
border-color: #495057; /* 예: Bootstrap의 $gray-700 과 유사한 테두리 색 */
text-decoration: none
}
/* thead.table-light 내부의 a 태그 색상 */
.table thead.table-light th a {
color: var(--bs-body-color); /* 부모 th의 색상을 상속받도록 (가장 간단) */
text-decoration: none
}
.table thead.table-light th a:hover {
color: var(--bs-body-color); /* 다크 모드 링크 호버 색상 */
}
/* thead.table-light 내부의 Font Awesome 아이콘 색상 */
.table thead.table-light th a .fas {
color: inherit; /* 링크 색상 상속 */
}
/* --- (선택 사항) 테이블 전체에 대한 다크 모드 기본 스타일 --- */
.table {
--bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-secondary-bg);
--bs-table-border-color: var(--bs-border-color-translucent);
--bs-table-striped-color: var(--bs-body-color);
--bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.03);
--bs-table-active-color: var(--bs-body-color);
--bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.06);
--bs-table-hover-color: var(--bs-body-color);
--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.045);
}
}

View File

@ -0,0 +1,279 @@
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;
}
.errors .select2-selection {
border: 1px solid var(--error-fg);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,343 @@
/* 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;
}
@media (forced-colors: active) {
#changelist-filter {
border: 1px solid;
}
}
#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-extra-actions {
font-size: 0.8125rem;
margin-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);
}
@media (forced-colors: active) {
#changelist tbody tr.selected {
background-color: SelectedItem;
}
#changelist tbody tr:has(.action-select:checked) {
background-color: SelectedItem;
}
}
#changelist .actions {
padding: 10px;
background: var(--body-bg);
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);
}

View File

@ -0,0 +1,130 @@
@media (prefers-color-scheme: dark) {
:root {
--primary: #264b5d;
--primary-fg: #f7f7f7;
--body-fg: #eeeeee;
--body-bg: #121212;
--body-quiet-color: #d0d0d0;
--body-medium-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;
color-scheme: dark;
}
}
html[data-theme="dark"] {
--primary: #264b5d;
--primary-fg: #f7f7f7;
--body-fg: #eeeeee;
--body-bg: #121212;
--body-quiet-color: #d0d0d0;
--body-medium-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;
color-scheme: dark;
}
/* 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: 1.5rem;
width: 1.5rem;
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;
}

View File

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

View File

@ -0,0 +1,498 @@
@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;
}
/* 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;
}
/* FIELDSETS */
fieldset .fieldset-heading,
fieldset .inline-heading,
:not(.inline-related) .collapse summary {
border: 1px solid var(--header-bg);
margin: 0;
padding: 8px;
font-weight: 400;
font-size: 0.8125rem;
background: var(--header-bg);
color: var(--header-link-color);
}
/* ALIGNED FIELDSETS */
.aligned label {
display: block;
padding: 4px 10px 0 0;
min-width: 160px;
width: 160px;
word-wrap: break-word;
}
.aligned label:not(.vCheckboxLabel):after {
content: '';
display: inline-block;
vertical-align: middle;
}
.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 select option:checked {
background-color: var(--selected-row);
}
form .aligned ul li {
list-style: none;
}
form .aligned table p {
margin-left: 0;
padding-left: 0;
}
.aligned .vCheckboxLabel {
padding: 1px 0 0 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.help,
form .wide ul.errorlist,
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;
}
/* COLLAPSIBLE FIELDSETS */
.collapse summary .fieldset-heading,
.collapse summary .inline-heading {
background: transparent;
border: none;
color: currentColor;
display: inline;
margin: 0;
padding: 0;
}
/* 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 h4,
.inline-related:not(.tabular) .collapse summary {
margin: 0;
color: var(--body-medium-color);
padding: 5px;
font-size: 0.8125rem;
background: var(--darkened-bg);
border: 1px solid var(--hairline-color);
border-left-color: var(--darkened-bg);
border-right-color: var(--darkened-bg);
}
.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-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 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 div.add-row a,
.inline-group .tabular tr.add-row td a {
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;
}

View File

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

View File

@ -0,0 +1,150 @@
.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);
}
@media (forced-colors: active) {
#nav-sidebar .current-model {
background-color: SelectedItem;
}
}
.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%;
}

View File

@ -0,0 +1,908 @@
/* 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;
}
#site-name {
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: 1rem;
}
/*
Minifiers remove the default (text) "type" attribute from "input" HTML
tags. Add input:not([type]) to make the CSS stylesheet work the same.
*/
.form-row input:not([type]),
.form-row input[type=text],
.form-row input[type=password],
.form-row input[type=email],
.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: 1rem;
}
.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;
}
/* Selector */
.selector {
display: flex;
width: 100%;
}
.selector .selector-filter {
display: flex;
align-items: center;
}
.selector .selector-filter input {
width: 100%;
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-chooseall, .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 {
padding: 0 2px;
transform: none;
}
.stacked .selector-chooser li {
padding: 3px;
}
.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 #site-name {
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 {
padding: 15px;
}
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;
}
.object-tools:has(a.addlink) {
margin-top: 0px;
}
/* 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;
}
.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;
flex: 1 0 auto;
}
.related-widget-wrapper > a {
order: 2;
}
.related-widget-wrapper .radiolist ~ a {
align-self: flex-end;
}
.related-widget-wrapper > select ~ a {
align-self: center;
}
/* Selector */
.selector {
flex-direction: column;
gap: 10px 0;
}
.selector-available, .selector-chosen {
flex: 1 1 auto;
}
.selector select {
max-height: 96px;
}
.selector ul.selector-chooser {
display: flex;
width: 60px;
height: 30px;
padding: 0 2px;
transform: none;
}
.selector ul.selector-chooser li {
float: left;
}
.selector-remove {
background-position: 0 0;
}
:enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -24px;
}
.selector-add {
background-position: 0 -48px;
}
:enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -72px;
}
/* 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;
}
}

View File

@ -0,0 +1,89 @@
/* 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 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"] .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;
}
[dir="rtl"] .selector-remove {
background-position: 0 0;
}
[dir="rtl"] :enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -24px;
}
[dir="rtl"] .selector-add {
background-position: 0 -48px;
}
[dir="rtl"] :enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -72px;
}
}

View File

@ -0,0 +1,293 @@
/* 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, .hidelink {
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-left: 0;
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 ul.errorlist,
form .wide div.help {
padding-left: 0;
padding-right: 50px;
}
.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 -15px no-repeat;
}
.calendarnav-next {
top: 0;
right: auto;
left: 10px;
background: url(../img/calendar-icons.svg) 0 0 no-repeat;
}
.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 -96px no-repeat;
background-size: 24px auto;
}
:enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -120px;
}
.selector-remove {
background: url(../img/selector-icons.svg) 0 -144px no-repeat;
background-size: 24px auto;
}
:enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -168px;
}
.selector-chooseall {
background: url(../img/selector-icons.svg) right -128px no-repeat;
}
:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover {
background-position: 100% -144px;
}
.selector-clearall {
background: url(../img/selector-icons.svg) 0 -160px no-repeat;
}
:enabled.selector-clearall:focus, :enabled.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;
}
.inline-group .tabular td.original p {
right: 0;
}
.selector .selector-chooser {
margin: 0;
}

View File

@ -0,0 +1,19 @@
/* Hide warnings fields if usable password is selected */
form:has(#id_usable_password input[value="true"]:checked) .messagelist {
display: none;
}
/* Hide password fields if unusable password is selected */
form:has(#id_usable_password input[value="false"]:checked) .field-password1,
form:has(#id_usable_password input[value="false"]:checked) .field-password2 {
display: none;
}
/* Select appropriate submit button */
form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password {
display: none;
}
form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password {
display: none;
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,613 @@
/* SELECTOR (FILTER INTERFACE) */
.selector {
display: flex;
flex: 1;
gap: 0 10px;
}
.selector select {
height: 17.2em;
flex: 1 0 auto;
overflow: scroll;
width: 100%;
}
.selector-available, .selector-chosen {
display: flex;
flex-direction: column;
flex: 1 1;
}
.selector-available-title, .selector-chosen-title {
border: 1px solid var(--border-color);
border-radius: 4px 4px 0 0;
}
.selector .helptext {
font-size: 0.6875rem;
}
.selector-chosen .list-footer-display {
border: 1px solid var(--border-color);
border-top: none;
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-title {
background: var(--secondary);
color: var(--header-link-color);
padding: 8px;
}
.aligned .selector-chosen-title label {
color: var(--header-link-color);
width: 100%;
}
.selector-available-title {
background: var(--darkened-bg);
color: var(--body-quiet-color);
padding: 8px;
}
.aligned .selector-available-title label {
width: 100%;
}
.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;
display: flex;
gap: 8px;
}
.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-filter input {
flex-grow: 1;
}
.selector ul.selector-chooser {
align-self: center;
width: 30px;
background-color: var(--selected-bg);
border-radius: 10px;
margin: 0;
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: 24px;
height: 24px;
display: block;
text-indent: -3000px;
overflow: hidden;
cursor: default;
opacity: 0.55;
border: none;
}
:enabled.selector-add, :enabled.selector-remove {
opacity: 1;
}
:enabled.selector-add:hover, :enabled.selector-remove:hover {
cursor: pointer;
}
.selector-add {
background: url(../img/selector-icons.svg) 0 -144px no-repeat;
background-size: 24px auto;
}
:enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -168px;
}
.selector-remove {
background: url(../img/selector-icons.svg) 0 -96px no-repeat;
background-size: 24px auto;
}
:enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -120px;
}
.selector-chooseall, .selector-clearall {
display: inline-block;
height: 16px;
text-align: left;
margin: 0 auto;
overflow: hidden;
font-weight: bold;
line-height: 16px;
color: var(--body-quiet-color);
text-decoration: none;
opacity: 0.55;
border: none;
}
:enabled.selector-chooseall:focus, :enabled.selector-clearall:focus,
:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover {
color: var(--link-fg);
}
:enabled.selector-chooseall, :enabled.selector-clearall {
opacity: 1;
}
:enabled.selector-chooseall:hover, :enabled.selector-clearall:hover {
cursor: pointer;
}
.selector-chooseall {
padding: 0 18px 0 0;
background: url(../img/selector-icons.svg) right -160px no-repeat;
cursor: default;
}
:enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover {
background-position: 100% -176px;
}
.selector-clearall {
padding: 0 0 0 18px;
background: url(../img/selector-icons.svg) 0 -128px no-repeat;
cursor: default;
}
:enabled.selector-clearall:focus, :enabled.selector-clearall:hover {
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 {
display: flex;
height: 30px;
width: 64px;
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 -48px no-repeat;
background-size: 24px auto;
cursor: default;
}
.stacked :enabled.selector-add {
background-position: 0 -48px;
cursor: pointer;
}
.stacked :enabled.selector-add:focus, .stacked :enabled.selector-add:hover {
background-position: 0 -72px;
cursor: pointer;
}
.stacked .selector-remove {
background: url(../img/selector-icons.svg) 0 0 no-repeat;
background-size: 24px auto;
cursor: default;
}
.stacked :enabled.selector-remove {
background-position: 0 0px;
cursor: pointer;
}
.stacked :enabled.selector-remove:focus, .stacked :enabled.selector-remove:hover {
background-position: 0 -24px;
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: 24px;
width: 24px;
overflow: hidden;
}
.datetimeshortcuts .clock-icon {
background: url(../img/icon-clock.svg) 0 0 no-repeat;
background-size: 24px auto;
}
.datetimeshortcuts a:focus .clock-icon,
.datetimeshortcuts a:hover .clock-icon {
background-position: 0 -24px;
}
.datetimeshortcuts .date-icon {
background: url(../img/icon-calendar.svg) 0 0 no-repeat;
background-size: 24px auto;
top: -1px;
}
.datetimeshortcuts a:focus .date-icon,
.datetimeshortcuts a:hover .date-icon {
background-position: 0 -24px;
}
.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(--secondary);
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;
}
.calendarnav-next {
right: 10px;
background: url(../img/calendar-icons.svg) 0 -15px no-repeat;
}
.calendar-cancel {
margin: 0;
padding: 4px 0;
font-size: 0.75rem;
background: var(--close-button-bg);
border-top: 1px solid var(--border-color);
color: var(--button-fg);
}
.calendar-cancel:focus, .calendar-cancel:hover {
background: var(--close-button-hover-bg);
}
.calendar-cancel a {
color: var(--button-fg);
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: 1.5rem;
height: 1.5rem;
border: 0px none;
margin-bottom: .25rem;
}
.inline-deletelink:focus, .inline-deletelink:hover {
cursor: pointer;
}
/* RELATED WIDGET WRAPPER */
.related-widget-wrapper {
display: flex;
gap: 0 10px;
flex-grow: 1;
flex-wrap: wrap;
margin-bottom: 5px;
}
.related-widget-wrapper-link {
opacity: .6;
filter: grayscale(1);
}
.related-widget-wrapper-link:link {
opacity: 1;
filter: grayscale(0);
}
/* GIS MAPS */
.dj_map {
width: 600px;
height: 400px;
}

View File

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

View File

@ -0,0 +1,7 @@
All icons are taken from Font Awesome (https://fontawesome.com/) 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).

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="15"
height="30"
viewBox="0 0 1792 3584"
version="1.1"
id="svg5"
sodipodi:docname="calendar-icons.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="13.3"
inkscape:cx="15.526316"
inkscape:cy="20.977444"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<defs
id="defs2">
<g
id="previous">
<path
d="m 1037,1395 102,-102 q 19,-19 19,-45 0,-26 -19,-45 L 832,896 1139,589 q 19,-19 19,-45 0,-26 -19,-45 L 1037,397 q -19,-19 -45,-19 -26,0 -45,19 L 493,851 q -19,19 -19,45 0,26 19,45 l 454,454 q 19,19 45,19 26,0 45,-19 z m 627,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path1" />
</g>
<g
id="next">
<path
d="m 845,1395 454,-454 q 19,-19 19,-45 0,-26 -19,-45 L 845,397 q -19,-19 -45,-19 -26,0 -45,19 L 653,499 q -19,19 -19,45 0,26 19,45 l 307,307 -307,307 q -19,19 -19,45 0,26 19,45 l 102,102 q 19,19 45,19 26,0 45,-19 z m 819,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path2" />
</g>
</defs>
<use
xlink:href="#next"
x="0"
y="5376"
fill="#000000"
id="use5"
transform="translate(0,-3584)" />
<use
xlink:href="#previous"
x="0"
y="0"
fill="#333333"
id="use2"
style="fill:#000000;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#EBECE6" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9C9C9" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg width="24" height="22" viewBox="0 0 847 779" xmlns="http://www.w3.org/2000/svg"><g><path fill="#F1C02A" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120z"/><path fill="#9E9E93" d="M120 1h607c66 0 120 54 120 120v536c0 66-54 120-120 120h-607c-66 0-120-54-120-120v-536c0-66 54-120 120-120zm607 25h-607c-26 0-50 11-67 28-17 18-28 41-28 67v536c0 27 11 50 28 68 17 17 41 27 67 27h607c26 0 49-10 67-27 17-18 28-41 28-68v-536c0-26-11-49-28-67-18-17-41-28-67-28z"/><path stroke="#A9A8A4" stroke-width="20" d="M706 295l-68 281"/><path stroke="#E47474" stroke-width="20" d="M316 648l390-353M141 435l175 213"/><path stroke="#C9A741" stroke-width="20" d="M319 151l-178 284M706 295l-387-144"/><g fill="#040405"><path d="M319 111c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40zM141 395c22 0 40 18 40 40s-18 40-40 40c-23 0-41-18-41-40s18-40 41-40zM316 608c22 0 40 18 40 40 0 23-18 41-40 41s-40-18-40-41c0-22 18-40 40-40zM706 254c22 0 40 18 40 41 0 22-18 40-40 40s-40-18-40-40c0-23 18-41 40-41zM638 536c22 0 40 18 40 40s-18 40-40 40-40-18-40-40 18-40 40-40z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#5fa225" d="M1600 796v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M1024 1375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 23.5v190q0 14 9.5 23.5t22.5 9.5h192q13 0 22.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11 0-24 11-10 7-10 21l17 457q0 10 10 16.5t24 6.5h185q14 0 23.5-6.5t10.5-16.5zm-14-934l768 1408q35 63-2 126-17 29-46.5 46t-63.5 17h-1536q-34 0-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31 47-49t65-18 65 18 47 49z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#b48c08" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="m555 1335 78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5T592 832q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173T20 1029Q0 998 0 960t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5T1291 358q16 10 16 27zm37 447q0 139-79 253.5T1056 1250l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267T896 1536l74-132q212-18 392.5-137T1664 960q-115-179-282-294l63-112q95 64 182.5 153T1772 891q20 34 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#dd4646" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#666666" d="M1024 1376v-192q0-14-9-23t-23-9h-192q-14 0-23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23-9t9-23zm256-672q0-88-55.5-163t-138.5-116-170-41q-243 0-371 213-15 24 8 42l132 100q7 6 19 6 16 0 25-12 53-68 86-92 34-24 86-24 48 0 85.5 26t37.5 59q0 38-20 61t-68 45q-63 28-115.5 86.5t-52.5 125.5v36q0 14 9 23t23 9h192q14 0 23-9t9-23q0-19 21.5-49.5t54.5-49.5q32-18 49-28.5t46-35 44.5-48 28-60.5 12.5-81zm384 192q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#2b70bf" d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#70bf2b" d="M1412 734q0-28-18-46l-91-90q-19-19-45-19t-45 19l-408 407-226-226q-19-19-45-19t-45 19l-91 90q-18 18-18 46 0 27 18 45l362 362q19 19 45 19 27 0 46-19l543-543q18-18 18-45zm252 162q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#999999" d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@ -0,0 +1,3 @@
<svg width="15" height="15" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#555555" d="M1216 832q0-185-131.5-316.5t-316.5-131.5-316.5 131.5-131.5 316.5 131.5 316.5 316.5 131.5 316.5-131.5 131.5-316.5zm512 832q0 52-38 90t-90 38q-54 0-90-38l-343-342q-179 124-399 124-143 0-273.5-55.5t-225-150-150-225-55.5-273.5 55.5-273.5 150-225 225-150 273.5-55.5 273.5 55.5 225 150 150 225 55.5 273.5q0 220-124 399l343 343q37 37 37 90z"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@ -0,0 +1,34 @@
<svg width="16" height="192" viewBox="0 0 1792 21504" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="up">
<path d="M1412 895q0-27-18-45l-362-362-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45v-502l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="down">
<path d="M1412 897q0-27-18-45l-91-91q-18-18-45-18t-45 18l-189 189v-502q0-26-19-45t-45-19h-128q-26 0-45 19t-19 45v502l-189-189q-19-19-45-19t-45 19l-91 91q-18 18-18 45t18 45l362 362 91 91q18 18 45 18t45-18l91-91 362-362q18-18 18-45zm252-1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="left">
<path d="M1408 960v-128q0-26-19-45t-45-19h-502l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18l-362 362-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45l-189-189h502q26 0 45-19t19-45zm256-64q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="right">
<path d="M1413 896q0-27-18-45l-91-91-362-362q-18-18-45-18t-45 18l-91 91q-18 18-18 45t18 45l189 189h-502q-26 0-45 19t-19 45v128q0 26 19 45t45 19h502l-189 189q-19 19-19 45t19 45l91 91q18 18 45 18t45-18l362-362 91-91q18-18 18-45zm251 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="clearall">
<path transform="translate(336, 336) scale(0.75)" d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="chooseall">
<path transform="translate(336, 336) scale(0.75)" d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#up" x="0" y="0" fill="#666666" />
<use xlink:href="#up" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#down" x="0" y="3584" fill="#666666" />
<use xlink:href="#down" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#left" x="0" y="7168" fill="#666666" />
<use xlink:href="#left" x="0" y="8960" fill="#447e9b" />
<use xlink:href="#right" x="0" y="10752" fill="#666666" />
<use xlink:href="#right" x="0" y="12544" fill="#447e9b" />
<use xlink:href="#clearall" x="0" y="14336" fill="#666666" />
<use xlink:href="#clearall" x="0" y="16128" fill="#447e9b" />
<use xlink:href="#chooseall" x="0" y="17920" fill="#666666" />
<use xlink:href="#chooseall" x="0" y="19712" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,19 @@
<svg width="14" height="84" viewBox="0 0 1792 10752" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="sort">
<path d="M1408 1088q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45zm0-384q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="ascending">
<path d="M1408 1216q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"/>
</g>
<g id="descending">
<path d="M1408 704q0 26-19 45l-448 448q-19 19-45 19t-45-19l-448-448q-19-19-19-45t19-45 45-19h896q26 0 45 19t19 45z"/>
</g>
</defs>
<use xlink:href="#sort" x="0" y="0" fill="#999999" />
<use xlink:href="#sort" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#ascending" x="0" y="3584" fill="#999999" />
<use xlink:href="#ascending" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#descending" x="0" y="7168" fill="#999999" />
<use xlink:href="#descending" x="0" y="8960" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M1363 877l-742 742q-19 19-45 19t-45-19l-166-166q-19-19-19-45t19-45l531-531-531-531q-19-19-19-45t19-45l166-166q19-19 45-19t45 19l742 742q19 19 19 45t-19 45z"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

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

View File

@ -0,0 +1,307 @@
/*global SelectBox, gettext, ngettext, 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';
from_box.setAttribute('aria-labelledby', field_id + '_from_title');
for (const p of from_box.parentNode.getElementsByTagName('p')) {
if (p.classList.contains("info")) {
// Remove <p class="info">, 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);
}
}
// <div class="selector"> or <div class="selector stacked">
const selector_div = quickElement('div', from_box.parentNode);
// Make sure the selector div is at the beginning so that the
// add link would be displayed to the right of the widget.
from_box.parentNode.prepend(selector_div);
selector_div.className = is_stacked ? 'selector stacked' : 'selector';
// <div class="selector-available">
const selector_available = quickElement('div', selector_div);
selector_available.className = 'selector-available';
const selector_available_title = quickElement('div', selector_available);
selector_available_title.id = field_id + '_from_title';
selector_available_title.className = 'selector-available-title';
quickElement('label', selector_available_title, interpolate(gettext('Available %s') + ' ', [field_name]), 'for', field_id + '_from');
quickElement(
'p',
selector_available_title,
interpolate(gettext('Choose %s by selecting them and then select the "Choose" arrow button.'), [field_name]),
'class', 'helptext'
);
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',
'aria-label', 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(
'button',
selector_available,
interpolate(gettext('Choose all %s'), [field_name]),
'id', field_id + '_add_all',
'class', 'selector-chooseall'
);
// <ul class="selector-chooser">
const selector_chooser = quickElement('ul', selector_div);
selector_chooser.className = 'selector-chooser';
const add_button = quickElement(
'button',
quickElement('li', selector_chooser),
interpolate(gettext('Choose selected %s'), [field_name]),
'id', field_id + '_add',
'class', 'selector-add'
);
const remove_button = quickElement(
'button',
quickElement('li', selector_chooser),
interpolate(gettext('Remove selected %s'), [field_name]),
'id', field_id + '_remove',
'class', 'selector-remove'
);
// <div class="selector-chosen">
const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen');
selector_chosen.className = 'selector-chosen';
const selector_chosen_title = quickElement('div', selector_chosen);
selector_chosen_title.className = 'selector-chosen-title';
selector_chosen_title.id = field_id + '_to_title';
quickElement('label', selector_chosen_title, interpolate(gettext('Chosen %s') + ' ', [field_name]), 'for', field_id + '_to');
quickElement(
'p',
selector_chosen_title,
interpolate(gettext('Remove %s by selecting them and then select the "Remove" arrow button.'), [field_name]),
'class', 'helptext'
);
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',
'aria-label', 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';
quickElement(
'select',
selector_chosen,
'',
'id', field_id + '_to',
'multiple', '',
'size', from_box.size,
'name', from_box.name,
'aria-labelledby', field_id + '_to_title',
'class', '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, ' ' + gettext('(click to clear)'), 'class', 'list-footer-display__clear');
const clear_all = quickElement(
'button',
selector_chosen,
interpolate(gettext('Remove all %s'), [field_name]),
'id', field_id + '_remove_all',
'class', '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.hasAttribute('disabled')) {
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_button.addEventListener('click', function(e) {
move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to');
});
remove_button.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');
// Disabled if no items are selected.
document.getElementById(field_id + '_add').disabled = !SelectFilter.any_selected(from);
document.getElementById(field_id + '_remove').disabled = !SelectFilter.any_selected(to);
// Disabled if the corresponding box is empty.
document.getElementById(field_id + '_add_all').disabled = !from.querySelector('option');
document.getElementById(field_id + '_remove_all').disabled = !to.querySelector('option');
},
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));
});
});
}

View File

@ -0,0 +1,204 @@
/*global gettext, interpolate, ngettext, Actions*/
'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 havent saved your changes to individual fields yet. Please click OK to save. Youll need to re-run the action.")
: gettext("You have selected an action, and you havent made any changes on individual fields. Youre probably looking for the Go button rather than the Save button.");
if (!confirm(text)) {
event.preventDefault();
}
}
});
}
// Sync counter when navigating to the page, such as through the back
// button.
window.addEventListener('pageshow', (event) => updateCounter(actionCheckboxes, options));
};
// 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);
}
});
}

View File

@ -0,0 +1,408 @@
/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/
// Inserts shortcut buttons after all of the following:
// <input type="text" class="vDateField">
// <input type="text" class="vTimeField">
'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 <div> that gets toggled
calendarDivName2: 'calendarin', // name of <div> that contains calendar
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
clockDivName: 'clockbox', // name of clock <div> 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:
// <div id="clockbox1" class="clockbox module">
// <h2>Choose a time</h2>
// <ul class="timelist">
// <li><a href="#">Now</a></li>
// <li><a href="#">Midnight</a></li>
// <li><a href="#">6 a.m.</a></li>
// <li><a href="#">Noon</a></li>
// <li><a href="#">6 p.m.</a></li>
// </ul>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
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 <input>.
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:
//
// <div id="calendarbox3" class="calendarbox module">
// <h2>
// <a href="#" class="link-previous">&lsaquo;</a>
// <a href="#" class="link-next">&rsaquo;</a> February 2003
// </h2>
// <div class="calendar" id="calendarin3">
// <!-- (cal) -->
// </div>
// <div class="calendar-shortcuts">
// <a href="#">Yesterday</a> | <a href="#">Today</a> | <a href="#">Tomorrow</a>
// </div>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
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;
}

View File

@ -0,0 +1,252 @@
/*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 {
elem.value = chosenId;
}
$(elem).trigger('change');
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));
elm.removeAttr('aria-disabled');
});
} else {
siblings.removeAttr('href');
siblings.attr('aria-disabled', true);
}
}
function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId, skipIds = []) {
// 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 '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
// Select elements with a specific model reference and context of "available-source".
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`);
selectsRelated.forEach(function(select) {
if (currentSelect === select || skipIds && skipIds.includes(select.id)) {
return;
}
let option = select.querySelector(`option[value="${objId}"]`);
if (!option) {
option = new Option(newRepr, newId);
select.options.add(option);
// Update SelectBox cache for related fields.
if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) {
SelectBox.add_to_cache(select.id, option);
SelectBox.redisplay(select.id);
}
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 toElem = document.getElementById(toId);
const o = new Option(newRepr, newId);
SelectBox.add_to_cache(toId, o);
SelectBox.redisplay(toId);
if (toElem && toElem.nodeName.toUpperCase() === 'SELECT') {
const skipIds = [name + "_from"];
updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds);
}
}
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;
window.relatedWindows = relatedWindows;
// 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);
}
});
});
}

View File

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

View File

@ -0,0 +1,239 @@
/*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: [
gettext('Sunday'),
gettext('Monday'),
gettext('Tuesday'),
gettext('Wednesday'),
gettext('Thursday'),
gettext('Friday'),
gettext('Saturday')
],
daysOfWeekAbbrev: [
pgettext('abbrev. day Sunday', 'Sun'),
pgettext('abbrev. day Monday', 'Mon'),
pgettext('abbrev. day Tuesday', 'Tue'),
pgettext('abbrev. day Wednesday', 'Wed'),
pgettext('abbrev. day Thursday', 'Thur'),
pgettext('abbrev. day Friday', 'Fri'),
pgettext('abbrev. day Saturday', 'Sat')
],
daysOfWeekInitial: [
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.daysOfWeekInitial[(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;
}

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