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

101
.gitignore vendored Normal file
View File

@ -0,0 +1,101 @@
# .gitignore for gyber monorepo
# Byte-compiled / optimized / DLL files
**/__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are created by pyinstaller, if you are using it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py # 로컬 환경별 설정 파일 (필요시)
db.sqlite3 # SQLite 데이터베이스 파일 (사용하지 않더라도)
db.sqlite3-journal
apps/web/staticfiles/ # collectstatic 결과 (선택사항, CI/CD에서 생성한다면 제외)
media/ # 사용자가 업로드하는 미디어 파일
# Python Virtual Environment
# --- 중요: 실제 가상 환경 폴더 이름으로 변경하세요! ---
apps/web/vgyber/
# Rust specific
apps/rust_gyber/target/
apps/rust_db_enc_creator/target/
apps/rust_gyber/logs/ # 또는 *.log (위 Django *.log 와 중복될 수 있음)
# Secrets and configuration - **절대 Git에 올리면 안 되는 파일들**
# 실제 비밀번호, API 키 등이 포함된 파일은 여기에 명시하거나, 애초에 저장소 밖에 두세요.
#apps/rust_gyber/config/db.enc # 암호화된 DB 설정 파일 (키 관리가 안전하다면 제외 가능)
# .env # 환경 변수 파일 (사용한다면)
# secrets.*
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor directories and files
.idea/
.vscode/
*.suo
*.user
*.userosscache
*.sln.docstates
*.swp
*.swo
*~

157
README.md Normal file
View File

@ -0,0 +1,157 @@
# Gyber - 통합 자산 관리 시스템
**Gyber**는 사내 IT 자산을 효율적으로 관리하기 위한 통합 시스템입니다. Windows 클라이언트 PC의 하드웨어 정보를 PowerShell 스크립트로 자동 수집하고, Rust로 작성된 동기화 에이전트가 이를 중앙 데이터베이스에 반영합니다. 관리자는 Django로 제작된 웹 인터페이스를 통해 자산을 조회, 추가, 수정, 삭제하고, 사용자별 할당 현황 및 감사 로그를 확인할 수 있습니다.
## ✨ 주요 기능
* **💻 하드웨어 정보 자동 수집:** PowerShell 스크립트를 통해 CPU, 메모리, 디스크, VGA 등 상세 하드웨어 정보를 수집합니다.
* **⚙️ Rust 기반 데이터 동기화:** Rust로 작성된 강력하고 안정적인 에이전트가 수집된 정보를 중앙 DB와 동기화합니다.
* **🌐 웹 기반 관리 인터페이스:** Django 프레임워크를 사용하여 직관적인 웹 UI를 제공합니다.
* 자산, 사용자, 그룹(부서), 카테고리의 CRUD(생성, 읽기, 수정, 삭제) 기능
* 강력한 검색, 필터링 및 정렬 기능
* 대시보드를 통한 자산 현황 요약
* 변경 이력 추적을 위한 상세 감사 로그
* **🔒 동기화 잠금 기능:** 웹에서 수동으로 입력하거나 중요한 자산 정보를 자동 동기화로부터 보호하는 잠금 기능
* **🔐 Azure AD 기반 SSO:** OpenID Connect(OIDC)를 통한 안전하고 편리한 사용자 인증
* **🎨 다크/라이트 모드 지원:** 사용자 편의를 위한 동적 테마 전환
* **📊 데이터 내보내기:** 현재 조회 중인 목록을 CSV 파일로 내보내기
## 🛠️ 기술 스택
* **백엔드 (웹):** Python, Django
* **백엔드 (동기화 에이전트):** Rust
* **프론트엔드:** HTML, CSS, JavaScript, Bootstrap 5, Select2, Chart.js
* **데이터베이스:** MariaDB / MySQL
* **인프라:** Nginx, Gunicorn, systemd
* **인증:** Azure Active Directory (OIDC)
* **클라이언트 정보 수집:** PowerShell
## 🚀 시작하기
이 프로젝트는 크게 **웹 애플리케이션 (Django)**과 **데이터 동기화 에이전트 (Rust)**, 그리고 **DB 자격 증명 암호화 유틸리티 (Rust)**로 구성됩니다.
### 사전 준비 사항
* Ubuntu Server 22.04 LTS (또는 유사한 Linux 배포판)
* MariaDB 또는 MySQL 데이터베이스 서버
* Rust 개발 환경 (rustup 권장)
* Python 3.12+ 개발 환경
* Nginx
* Azure Active Directory 테넌트 및 앱 등록 정보 (클라이언트 ID, 시크릿, 테넌트 ID)
* (클라이언트 PC) PowerShell 5.1 이상
### 1. 데이터베이스 설정
1. MariaDB/MySQL에 `gyber` 데이터베이스와 `gyber` 사용자를 생성하고 권한을 부여합니다.
2. 제공된 DB 스키마 파일 (`schema.sql` 등)을 사용하여 테이블과 저장 프로시저를 생성합니다.
3. **시간대 정보 로드 (필수):** DB 서버에서 다음 명령을 실행하여 시간대 정보를 로드합니다.
```bash
sudo mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql
```
### 2. Rust DB 자격 증명 암호화 유틸리티 (`rust_db_enc_creator`) 실행
이 유틸리티는 DB 접속 정보를 암호화하여 `db.enc` 파일을 생성합니다.
1. **`.env` 파일 생성:**
`apps/rust_db_enc_creator/` 디렉토리에 `.env` 파일을 만들고 다음 내용을 작성합니다.
```env
# apps/rust_db_enc_creator/.env
ENCRYPTION_KEY="your-strong-32-byte-encryption-key-here" # 32바이트(256비트) 키
```
2. **유틸리티 실행:**
```bash
cd apps/rust_db_enc_creator
cargo run
```
3. 실행이 완료되면 `apps/rust_db_enc_creator/` 디렉토리에 `db.enc` 파일이 생성됩니다. 이 파일을 **`apps/rust_gyber/config/` 디렉토리로 이동 또는 복사**합니다.
### 3. Rust 데이터 동기화 에이전트 (`rust_gyber`) 설정 및 실행
1. **환경 변수 설정:**
`apps/rust_gyber/` 디렉토리에 `.env` 파일을 만들고 암호화 유틸리티에서 사용한 것과 동일한 `ENCRYPTION_KEY`를 설정합니다.
```env
# apps/rust_gyber/.env
ENCRYPTION_KEY="your-strong-32-byte-encryption-key-here"
```
2. **설정 파일 확인:**
`apps/rust_gyber/config/config.json` 파일의 `json_file_path`가 PowerShell 스크립트가 정보를 저장하는 공유 폴더 경로와 일치하는지 확인합니다.
3. **컴파일 및 실행:**
```bash
cd apps/rust_gyber
cargo build --release # 프로덕션용으로 빌드
./target/release/gyber # 실행
```
* 실제 운영 시에는 이 Rust 애플리케이션도 `systemd` 서비스로 등록하여 백그라운드에서 주기적으로 실행되도록 구성하는 것이 좋습니다.
### 4. Django 웹 애플리케이션 (`web`) 설정 및 배포
이 프로젝트는 Ubuntu 22.04 서버에서 Nginx와 Gunicorn을 사용하여 프로덕션 환경에 배포하는 것을 기준으로 합니다.
1. **프로젝트 클론 및 Python 가상환경 설정:**
```bash
cd /data/gyber/apps # 예시 경로
git clone <your-repository-url> web
cd web
python3 -m venv vgyber
source vgyber/bin/activate
pip install -r requirements.txt
```
2. **환경 변수 파일 생성:**
`systemd` 서비스에서 사용할 환경 변수 파일을 생성합니다 (예: `/etc/gyber/gyber_prod.env`).
```bash
sudo mkdir -p /etc/gyber
sudo nano /etc/gyber/gyber_prod.env
```
파일 내용:
```env
DJANGO_SECRET_KEY="your_django_production_secret_key"
DB_PASSWORD="your_database_password"
OIDC_RP_CLIENT_ID="your_azure_ad_client_id"
OIDC_RP_CLIENT_SECRET="your_azure_ad_client_secret"
AZURE_TENANT_ID="your_azure_ad_tenant_id"
# 기타 필요한 환경 변수 (DB_HOST, DB_USER, OIDC_RP_POST_LOGOUT_REDIRECT_URI 등)
```
파일 권한을 설정합니다: `sudo chmod 600 /etc/gyber/gyber_prod.env`
3. **Django `settings.py` 확인:**
`apps/web/config/settings.py` 파일이 프로덕션 환경에 맞게 설정되었는지 확인합니다 (`DEBUG = False`, 환경 변수 참조 등).
4. **`collectstatic` 실행:**
```bash
# 가상 환경 활성화 상태에서
cd /data/gyber/apps/web
python manage.py collectstatic --noinput
```
5. **Gunicorn `systemd` 서비스 설정:**
* `/etc/systemd/system/gunicorn_gyber.socket` 및 `/etc/systemd/system/gunicorn_gyber.service` 파일을 생성하고 제공된 파일 내용을 복사합니다.
* 서비스 파일의 `User`, `Group`, `WorkingDirectory`, `ExecStart`, `EnvironmentFile` 경로가 실제 환경과 일치하는지 확인합니다.
* 서비스 시작 및 활성화:
```bash
sudo systemctl daemon-reload
sudo systemctl start gunicorn_gyber.socket
sudo systemctl enable gunicorn_gyber.socket
sudo systemctl start gunicorn_gyber.service
sudo systemctl enable gunicorn_gyber.service
```
6. **Nginx 설정:**
* SSL/TLS 인증서 파일들을 안전한 위치(예: `/etc/nginx/ssl/gyber.oneunivrs.com/`)에 준비합니다.
* `/etc/nginx/sites-available/gyber_project` 설정 파일을 생성하고, `server_name`, `listen` 포트(`8438`), `ssl_certificate` 경로, `alias` 경로, `proxy_pass` 소켓 경로 등을 실제 환경에 맞게 수정합니다.
* 설정 활성화 및 Nginx 재시작:
```bash
sudo ln -s /etc/nginx/sites-available/gyber_project /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
7. **방화벽 설정:**
```bash
sudo ufw allow 8438/tcp # HTTPS 포트
sudo ufw allow 80/tcp # HTTP->HTTPS 리디렉션 사용 시
```
### 5. 클라이언트 PC 설정 (PowerShell 스크립트)
1. `scripts/powershell/Collect-HWInfo.ps1` 스크립트를 클라이언트 PC에 배포합니다.
2. 스크립트 내의 `$saveDir` 변수를 실제 데이터가 저장될 네트워크 공유 폴더 경로로 수정합니다.
3. 이 스크립트가 각 PC에서 주기적으로 (예: 로그인 시 또는 예약된 작업으로) 실행되도록 설정합니다.

View File

@ -0,0 +1,11 @@
# db_enc_creator/.env
# 암호화 키 (Base64 인코딩된 16바이트 키)
# 메인 애플리케이션의 .env 파일과 동일한 키를 사용해야 합니다.
DB_ENCRYPTION_KEY="YWN0aW9uITEyM3NxdWFyZQ=="
# 암호화할 DB 사용자 이름
DB_USER_TO_ENCRYPT="gyber"
# 암호화할 DB 비밀번호
DB_PASSWORD_TO_ENCRYPT="Qj#pLas*BX0L" # 실제 비밀번호로 변경

356
apps/rust_db_enc_creator/Cargo.lock generated Normal file
View File

@ -0,0 +1,356 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "db_enc_creator"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"base64",
"dotenvy",
"rand",
"serde",
"serde_json",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "zerocopy"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@ -0,0 +1,25 @@
[package]
name = "db_enc_creator"
version = "0.1.0"
edition = "2021"
description = "Utility to create encrypted database credential file (db.enc) using AES-GCM"
[dependencies]
# Serde: "derive" 기능만 명시
serde = { version = "1.0.204", features = ["derive"] } # 최신 버전 확인 및 derive만 명시
serde_json = "1.0.120" # 최신 버전 확인
# AES-GCM: AEAD 암호화
aes-gcm = { version = "0.10.3", features = ["alloc"] } # alloc 기능 필요
# Rand: Nonce 생성
rand = { version = "0.8.5", features = ["std_rng"] } # OsRng 사용 위해 std_rng
# Base64: 키 인코딩/디코딩
base64 = "0.22.1" # 최신 버전 확인
# Dotenvy: .env 파일 로드
dotenvy = "0.15.7" # 최신 버전 확인
# Anyhow: 에러 처리
anyhow = "1.0.86" # 최신 버전 확인

View File

@ -0,0 +1,76 @@
use aes_gcm::aead::{Aead, OsRng}; // Aead 트레잇, OsRng 임포트
use aes_gcm::{Aes128Gcm, Key, KeyInit, Nonce}; // AES-GCM 관련 타입 임포트
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _};
use dotenvy::dotenv;
use rand::RngCore; // fill_bytes 메소드 사용 위해 필요
use serde::Serialize; // derive 매크로가 Serialize 트레잇을 사용
use std::{env, fs};
// 암호화할 데이터 구조체
#[derive(Serialize, Debug)] // Serialize 트레잇 사용 명시
struct DbCredentialsToEncrypt {
username: String,
password: String,
}
// 환경 변수에서 암호화 키 로드 (AES-128-GCM: 16바이트)
fn load_encryption_key() -> Result<Key<Aes128Gcm>> {
let key_b64 = env::var("DB_ENCRYPTION_KEY")
.context("환경 변수 'DB_ENCRYPTION_KEY'를 찾을 수 없습니다.")?;
let key_bytes = base64_engine.decode(key_b64.trim())
.context("DB_ENCRYPTION_KEY Base64 디코딩 실패")?;
if key_bytes.len() == 16 {
// KeyInit 트레잇의 from_slice 사용 후 역참조하여 Key 소유권 반환
Ok(*Key::<Aes128Gcm>::from_slice(&key_bytes))
} else {
anyhow::bail!(
"DB_ENCRYPTION_KEY 키 길이가 16바이트가 아닙니다 (현재 {}바이트).", key_bytes.len()
)
}
}
// 환경 변수에서 암호화할 사용자 정보 로드
fn load_credentials_to_encrypt() -> Result<DbCredentialsToEncrypt> {
let username = env::var("DB_USER_TO_ENCRYPT")
.context("환경 변수 'DB_USER_TO_ENCRYPT'를 찾을 수 없습니다.")?;
let password = env::var("DB_PASSWORD_TO_ENCRYPT")
.context("환경 변수 'DB_PASSWORD_TO_ENCRYPT'를 찾을 수 없습니다.")?;
Ok(DbCredentialsToEncrypt { username, password })
}
fn main() -> Result<()> {
dotenv().ok(); // .env 파일 로드 시도
println!("DB 인증 정보 암호화(AES-GCM) 파일 생성 시작...");
let key = load_encryption_key().context("암호화 키 로드 실패")?;
println!("암호화 키 로드 성공.");
let db_credentials = load_credentials_to_encrypt()
.context("암호화할 DB 사용자 정보 로드 실패")?;
println!("암호화할 사용자 정보 로드 성공: username={}", db_credentials.username);
let plaintext = serde_json::to_vec(&db_credentials) // Serialize 트레잇 사용
.context("DB 인증 정보를 JSON으로 직렬화 실패")?;
let cipher = Aes128Gcm::new(&key); // KeyInit 트레잇 사용
let mut nonce_bytes = [0u8; 12]; // AES-GCM 표준 Nonce 크기
OsRng.fill_bytes(&mut nonce_bytes); // RngCore 트레잇 사용
let nonce = Nonce::from_slice(&nonce_bytes);
println!("암호화에 사용할 Nonce 생성 완료.");
let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()) // Aead 트레잇 사용
.map_err(|e| anyhow::anyhow!("AES-GCM 암호화 실패: {}", e))?;
println!("데이터 암호화 완료.");
let data_to_save = [nonce.as_slice(), ciphertext.as_slice()].concat();
let output_filename = "db.enc";
fs::write(output_filename, &data_to_save)
.with_context(|| format!("'{}' 파일 쓰기 실패", output_filename))?;
println!("\n성공: 암호화된 DB 인증 정보가 '{}' 파일에 저장되었습니다.", output_filename);
Ok(())
}

12
apps/rust_gyber/.env Normal file
View File

@ -0,0 +1,12 @@
# .env
# 암호화 키 (Base64 인코딩된 16바이트 키)
DB_ENCRYPTION_KEY="YWN0aW9uITEyM3NxdWFyZQ=="
# 데이터베이스 접속 정보
DB_HOST="localhost"
DB_NAME="gyber"
DB_PORT="3306"
# 로그 레벨 (선택 사항)
RUST_LOG="info"

2696
apps/rust_gyber/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
[package]
name = "gyber"
version = "0.1.0"
edition = "2021"
description = "Collects hardware information and updates MariaDB"
[dependencies]
# --- Serde 수정: "derive"와 "deserialize" 기능 모두 명시 ---
serde = { version = "1.0.204", features = ["derive"] } # 최신 버전 확인 및 기능 수정
serde_json = "1.0.120"
# MySQL/MariaDB Async Driver
mysql_async = "0.34.1"
# Tokio: 비동기 런타임
tokio = { version = "1.39.1", features = ["full"] }
# Logging (log4rs 사용)
log = "0.4.22"
log4rs = "1.3.0"
serde_yaml = "0.9" # log4rs YAML 설정용
# Anyhow: 에러 처리
anyhow = "1.0.86"
# Chrono: 날짜/시간 처리
chrono = "0.4.38"
# Dotenvy: .env 파일 로드
dotenvy = "0.15.7"
# Base64: 키 인코딩/디코딩
base64 = "0.22.1"
# AES-GCM: 복호화
aes-gcm = { version = "0.10.3", features = ["alloc"] }

View File

@ -0,0 +1,4 @@
{
"json_file_path": "/pcinfo",
"db_config_path": "config/db.enc"
}

View File

@ -0,0 +1 @@
<EFBFBD><EFBFBD><EFBFBD>?a&<26><>@<03>̯<EFBFBD><CCAF><1B>#<23>1,9<>K<EFBFBD>j=<3D>2Oͧ?Ű昩<C5B0>H<EFBFBD><48><EFBFBD><EFBFBD><EFBFBD><EFBFBD>%<25><>KQ(<28><><EFBFBD>5<><1D>ʰl<CAB0><6C>䶫583

View File

@ -0,0 +1,48 @@
# config/log4rs.yaml
appenders:
console:
kind: console
target: stdout
encoder:
# 파일명({f})과 라인번호({L}) 추가
pattern: "{d(%Y-%m-%d %H:%M:%S)} [{h({l:<5})}] {({t})} [{f}:{L}] - {m}{n}"
info_file:
kind: file
path: "logs/info.log"
append: true
encoder:
# 파일명({f})과 라인번호({L}) 추가
pattern: "{d(%Y-%m-%d %H:%M:%S)} [{l:<5}] {({t})} [{f}:{L}] - {m}{n}"
filters:
- kind: threshold
level: info
error_file:
kind: file
path: "logs/error.log"
append: true
encoder:
# 파일명({f})과 라인번호({L}) 추가 (기존에도 있었음)
pattern: "{d(%Y-%m-%d %H:%M:%S)} [{l:<5}] {({t})} [{f}:{L}] - {m}{n}"
filters:
- kind: threshold
level: error
root:
level: info # 또는 debug 등 필요 레벨
appenders:
- console
- info_file
- error_file
# 특정 모듈에 다른 로깅 레벨 적용 가능 (선택 사항)
# loggers:
# gyber::db: # 예시: gyber::db 모듈은 DEBUG 레벨까지 출력
# level: debug
# appenders:
# - console
# - info_file
# - error_file
# additivity: false # true면 root 설정도 상속받음, false면 이 설정만 적용

View File

@ -0,0 +1,51 @@
// src/config_reader.rs
use anyhow::{Context, Result};
use serde::Deserialize; // JSON 역직렬화를 위해 필요
use std::fs; // 파일 시스템 접근 (파일 읽기)
use std::path::Path; // 파일 경로 관련 작업
// config.json 파일 구조에 맞는 설정 구조체 정의
// Debug 트레잇은 println! 등에서 구조체를 보기 좋게 출력하기 위해 추가
#[derive(Deserialize, Debug)]
pub struct AppConfig {
// JSON 파일의 "json_file_path" 키와 필드매핑
// serde(rename = "...") 어노테이션은 JSON 키 이름과 Rust 필드 이름이 다를 때 사용!!
#[serde(rename = "json_file_path")]
pub json_files_path: String, // 처리할 JSON 파일들이 있는 디렉토리 경로
// JSON 파일의 "db_config_path" 키와 이 필드를 매핑
// config.json 파일에 이 키가 존재해야 함!!
#[serde(rename = "db_config_path")]
pub db_config_path: String, // 암호화된 DB 설정 파일 경로
}
// 설정 파일을 읽어 AppConfig 구조체로 반환하는 함수
pub fn read_app_config(config_path: &str) -> Result<AppConfig> {
// 입력받은 설정 파일 경로 문자열을 Path 객체로 변환
let path = Path::new(config_path);
// 설정 파일이 실제로 존재하는지 확인
if !path.exists() {
// 파일이 없으면 anyhow::bail! 매크로를 사용하여 에러를 생성하고 즉시 반환
anyhow::bail!("설정 파일을 찾을 수 없습니다: {}", config_path);
}
// 파일 내용을 문자열로 읽기
// fs::read_to_string은 Result를 반환하므로 '?' 연산자로 에러 처리
// .with_context()는 에러 발생 시 추가적인 문맥 정보를 제공 (anyhow 기능)
let config_content = fs::read_to_string(path)
.with_context(|| format!("설정 파일 읽기 실패: {}", config_path))?;
// 읽어온 JSON 문자열을 AppConfig 구조체로 파싱(역직렬화)
// serde_json::from_str은 Result를 반환하므로 '?' 연산자로 에러 처리
// .with_context()로 파싱 실패 시 에러 문맥 추가
let app_config: AppConfig = serde_json::from_str(&config_content)
.with_context(|| format!("설정 파일 JSON 파싱 실패: {}", config_path))?;
// 성공적으로 읽고 파싱한 경우, 디버그 레벨 로그로 설정값 출력
log::debug!("설정 파일 로드 완료: {:?}", app_config);
// 파싱된 AppConfig 구조체를 Ok() 로 감싸서 반환
Ok(app_config)
}

View File

@ -0,0 +1,143 @@
use super::connection::DbPool;
use crate::file::json_reader::ProcessedHwInfo; // 처리된 JSON 데이터 구조체
use anyhow::{Context, Result};
use log::{debug, error, info, warn};
use mysql_async::prelude::*;
use mysql_async::{params, FromRowError, Row};
use std::collections::{HashMap, HashSet};
// DB에서 가져온 자원 정보를 담는 구조체
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DbResource {
pub resource_id: i64, // 자원 고유 ID
pub resource_name: String, // 자원 이름 (모델명 등)
pub serial_num: String, // <-- DB에 저장된 '합성 키'
}
// MySQL Row 에서 DbResource 로 변환하는 구현
impl TryFrom<Row> for DbResource {
type Error = FromRowError;
fn try_from(mut row: Row) -> std::result::Result<Self, Self::Error> {
Ok(DbResource {
resource_id: row.take("resource_id").ok_or_else(|| FromRowError(row.clone()))?,
resource_name: row.take("resource_name").ok_or_else(|| FromRowError(row.clone()))?,
// DB 프로시저가 반환하는 serial_num 컬럼 (합성 키)
serial_num: row.take("serial_num").ok_or_else(|| FromRowError(row.clone()))?,
})
}
}
// 데이터 비교 결과를 담는 구조체
#[derive(Debug, Default)]
pub struct ComparisonResult {
pub adds: Vec<ProcessedHwInfo>, // DB에 추가(또는 할당)해야 할 항목들
pub deletes: Vec<DbResource>, // DB에서 할당 해제해야 할 항목들
}
// 특정 사용자의 DB에 등록된 자원 목록(합성 키 포함)을 조회하는 함수
async fn get_existing_resources(
pool: &DbPool,
account_name: &str, // 사용자 계정 이름 (JSON의 hostname과 매핑)
) -> Result<Vec<DbResource>> {
debug!("'{}' 계정의 기존 자원 조회 (sp_get_resources_by_account)...", account_name);
let mut conn = pool.get_conn().await.context("DB 커넥션 얻기 실패")?;
// 사용자 계정명으로 자원을 조회하는 저장 프로시저 호출
let query = r"CALL sp_get_resources_by_account(:p_account_name)";
let params = params! { "p_account_name" => account_name };
// 프로시저 실행 및 결과 매핑
let results: Vec<std::result::Result<DbResource, FromRowError>> = conn.exec_map(
query, params, |row: Row| DbResource::try_from(row)
).await.with_context(|| format!("sp_get_resources_by_account 프로시저 실패: 계정='{}'", account_name))?;
// 결과 처리 및 유효성 검사
let total_rows = results.len();
let mut db_resources = Vec::with_capacity(total_rows);
let mut error_count = 0;
for result in results {
match result {
Ok(resource) if !resource.serial_num.is_empty() => {
// 유효한 합성 키를 가진 자원만 리스트에 추가
db_resources.push(resource);
}
Ok(resource) => {
// DB에 빈 합성 키가 저장된 경우 경고
warn!("DB에서 유효하지 않은 합성 키 가진 자원 발견 (비교 제외): ID={}, Key='{}'", resource.resource_id, resource.serial_num);
error_count += 1;
}
Err(e) => {
// Row 변환 오류 처리
error!("get_existing_resources: DB Row 변환 에러 (Row 무시됨): {:?}", e);
error_count += 1;
}
}
}
let success_count = db_resources.len();
debug!("'{}' 계정 기존 자원 {}개 조회 완료 (유효 키/변환 성공: {}개, 실패/무효: {}개).", account_name, total_rows, success_count, error_count);
Ok(db_resources)
}
// JSON 데이터와 DB 데이터를 비교하여 추가/삭제 대상을 결정하는 함수
pub async fn compare_data(
pool: &DbPool,
processed_data: &HashMap<String, Vec<ProcessedHwInfo>>, // 호스트명별로 그룹화된 JSON 데이터
) -> Result<HashMap<String, ComparisonResult>> {
info!("JSON 데이터와 DB 데이터 비교 시작 (ADD/DELETE)...");
let mut all_changes: HashMap<String, ComparisonResult> = HashMap::new();
// 각 호스트(사용자)별로 데이터 비교 수행
for (hostname, json_items) in processed_data {
info!("'{}' 호스트 데이터 비교 중...", hostname);
// 해당 호스트(사용자)의 DB 자원 목록 조회
let db_items = match get_existing_resources(pool, hostname).await {
Ok(items) => items,
Err(e) => {
// DB 조회 실패 시 해당 호스트 건너뛰기
error!("'{}' DB 자원 조회 실패: {}. 건너<0xEB><0x9B><0x81>니다.", hostname, e);
continue;
}
};
// --- 비교를 위한 자료구조 생성 (합성 키 기준) ---
// JSON 데이터 Map (Key: 합성 키 serial_key)
let json_map: HashMap<&String, &ProcessedHwInfo> = json_items.iter()
.filter(|item| !item.serial_key.is_empty()) // 빈 키 제외
.map(|item| (&item.serial_key, item))
.collect();
// DB 데이터 Map (Key: 합성 키 serial_num)
let db_map: HashMap<&String, &DbResource> = db_items.iter()
// get_existing_resources 에서 이미 빈 키 필터링됨
.map(|item| (&item.serial_num, item))
.collect();
// 각 데이터 소스의 합성 키 Set 생성
let json_keys: HashSet<&String> = json_map.keys().cloned().collect();
let db_keys: HashSet<&String> = db_map.keys().cloned().collect();
// --- 비교 자료구조 생성 끝 ---
// Adds 찾기: JSON 에는 있으나 DB 에는 없는 합성 키
let adds: Vec<ProcessedHwInfo> = json_keys.difference(&db_keys)
.filter_map(|key| json_map.get(key).map(|&item| item.clone()))
.collect();
// Deletes 찾기: DB 에는 있으나 JSON 에는 없는 합성 키
let deletes: Vec<DbResource> = db_keys.difference(&json_keys)
.filter_map(|key| db_map.get(key).map(|&item| item.clone()))
.collect();
// 변경 사항 결과 저장
if !adds.is_empty() || !deletes.is_empty() {
debug!("'{}': 추가 {}개, 삭제 {}개 발견.", hostname, adds.len(), deletes.len());
all_changes.insert(hostname.clone(), ComparisonResult { adds, deletes });
} else {
debug!("'{}': 변경 사항 없음.", hostname);
}
}
info!("데이터 비교 완료.");
Ok(all_changes)
}

View File

@ -0,0 +1,37 @@
// src/db/connection.rs
use crate::file::decrypt::DbCredentials; // DB 인증 정보 구조체 가져오기
use anyhow::{Context, Result};
use mysql_async::{Opts, OptsBuilder, Pool}; // mysql_async 관련 타입 가져오기
// 편의를 위한 타입 별칭 (Type Alias)
pub type DbPool = Pool;
// DB 인증 정보를 사용하여 데이터베이스 커넥션 풀을 생성하는 비동기 함수
pub async fn connect_db(creds: &DbCredentials) -> Result<DbPool> {
let opts_builder = OptsBuilder::default()
.ip_or_hostname(creds.host.clone()) // ip_addr 대신 ip_or_hostname 사용, 소유권 문제로 clone
.tcp_port(creds.port) // tcp_port 사용
.user(Some(creds.user.clone())) // user 사용, 소유권 문제로 clone
.pass(Some(creds.pass.clone())) // pass 사용, 소유권 문제로 clone
.db_name(Some(creds.db.clone())) // db_name 사용, 소유권 문제로 clone
.prefer_socket(false);
// OptsBuilder에서 Opts 생성
let opts: Opts = opts_builder.into(); // .into()를 사용하여 Opts로 변환
let pool = Pool::new(opts); // Pool::new는 Opts를 인자로 받음
match pool.get_conn().await {
Ok(_conn) => {
log::info!("데이터베이스 연결 테스트 성공: {}", creds.db);
Ok(pool)
}
Err(e) => {
Err(e).context(format!(
"데이터베이스({}) 연결 실패: 호스트={}, 사용자={}",
creds.db, creds.host, creds.user
))
}
}
}

View File

@ -0,0 +1,9 @@
// src/db/mod.rs
pub mod connection;
pub mod compare;
pub mod sync;
// main.rs에서 직접 경로를 사용하므로 pub use 제거
// pub use connection::connect_db;
// pub use compare::{compare_data, ComparisonResult};
// pub use delsert::execute_delsert;

View File

@ -0,0 +1,208 @@
use super::connection::DbPool;
use super::compare::DbResource; // 할당 해제 시 DB 정보 사용
use crate::file::json_reader::ProcessedHwInfo; // 추가/할당 시 JSON 정보 사용
use anyhow::{Context, Result};
use log::{debug, error, info, warn};
use mysql_async::prelude::*;
use mysql_async::{params, Conn, Row, Value};
// ============================================================
// 내부 유틸리티 함수: sp_sync_resource_info_from_scan 프로시저 호출
// ============================================================
async fn call_sync_procedure(
conn: &mut Conn,
admin_user_id: Option<i32>, // p_admin_user_id (Rust에서는 보통 None)
actor_description: &str, // p_actor_description (작업 주체 설명, 예: "RustFileSync-hostname")
user_account_name: &str, // p_user_account_name (사용자 계정명, hostname)
category: &str, // p_category (자산 카테고리 이름)
manufacturer: &str, // p_manufacturer (제조사)
resource_name: &str, // p_resource_name (모델명)
composite_key: &str, // p_serial_num (DB 프로시저의 파라미터명은 p_serial_num 이지만, 값은 '합성 키')
spec_value: &str, // p_spec_value (사양 값, 문자열)
spec_unit: &str, // p_spec_unit (사양 단위, 문자열)
detected_by: &str, // p_detected_by (스캔 정보 출처 등, hostname 사용)
change_type: u8, // p_change_type (1: Add/Assign, 2: Unassign)
) -> Result<String> // 프로시저 결과 메시지 반환
{
let action = match change_type { 1 => "추가/할당", 2 => "할당 해제", _ => "알수없음" };
// 로그: 작업 시작 알림 (합성 키 포함)
debug!("'{}'에 의한 '{}' 자원 동기화 ({}) 시작: Key='{}'",
actor_description, user_account_name, action, composite_key);
// 호출할 저장 프로시저 쿼리
let query = r"CALL sp_sync_resource_info_from_scan(
:p_admin_user_id, :p_actor_description, :p_user_account_name,
:p_category, :p_manufacturer, :p_resource_name, :p_serial_num,
:p_spec_value, :p_spec_unit, :p_detected_by, :p_change_type,
@p_result_message
)"; // 총 11개 IN 파라미터 + 1개 OUT 파라미터
// 프로시저 파라미터 준비
let params = params! {
"p_admin_user_id" => Value::from(admin_user_id),
"p_actor_description" => Value::from(actor_description),
"p_user_account_name" => Value::from(user_account_name),
"p_category" => Value::from(category),
"p_manufacturer" => Value::from(manufacturer),
"p_resource_name" => Value::from(resource_name),
"p_serial_num" => Value::from(composite_key), // <-- 합성 키를 p_serial_num 파라미터로 전달
"p_spec_value" => Value::from(spec_value), // 문자열로 전달
"p_spec_unit" => Value::from(spec_unit), // 문자열로 전달
"p_detected_by" => Value::from(detected_by),
"p_change_type" => Value::from(change_type),
};
// 로그: 프로시저 호출 직전 파라미터 확인
debug!("Calling sp_sync_resource_info_from_scan with params: actor='{}', user='{}', category='{}', key='{}', change_type={}",
actor_description, user_account_name, category, composite_key, change_type);
// 프로시저 실행 (결과를 반환하지 않음)
conn.exec_drop(query, params).await.with_context(|| {
format!(
"sp_sync_resource_info_from_scan 프로시저 실행 실패: Actor='{}', 사용자='{}', 작업='{}', Key='{}'",
actor_description, user_account_name, action, composite_key
)
})?;
// OUT 파라미터(@p_result_message) 값 가져오기
let result_message_query = r"SELECT @p_result_message AS result_message";
let result_row: Option<Row> = conn.query_first(result_message_query).await.with_context(|| {
format!(
"OUT 파라미터(@p_result_message) 읽기 실패: Actor='{}', 사용자='{}', Key='{}'",
actor_description, user_account_name, composite_key
)
})?;
// 결과 메시지 처리
let result_message = match result_row {
Some(row) => {
match row.get_opt::<String, _>("result_message") { // 컬럼 이름은 AS 로 지정한 이름 사용
Some(Ok(msg)) => msg, // 성공적으로 문자열 얻음
Some(Err(e)) => format!("결과 메시지 파싱 실패: {}", e), // 타입 변환 등 실패
None => "결과 메시지가 NULL입니다".to_string(), // 컬럼 값 자체가 NULL
}
}
None => "결과 메시지를 가져올 수 없음".to_string(), // 쿼리 결과 행이 없음
};
// 로그: 작업 완료 및 결과 메시지 기록
debug!("'{}'에 의한 '{}' 자원 동기화 ({}) 완료: Key='{}', 결과='{}'",
actor_description, user_account_name, action, composite_key, result_message);
Ok(result_message)
}
// ============================================================
// 공개 함수 (main.rs 에서 호출)
// ============================================================
// 비교 결과를 바탕으로 DB 동기화(추가/할당, 할당 해제)를 실행하는 함수
pub async fn execute_sync(
pool: &DbPool,
hostname: &str, // 사용자 계정명 (user_account_name) 역할
adds: Vec<ProcessedHwInfo>, // 추가/할당 대상 목록
deletes: Vec<DbResource>, // 할당 해제 대상 목록
) -> Result<()> {
// 변경 사항 없으면 즉시 종료
if adds.is_empty() && deletes.is_empty() {
return Ok(());
}
// 작업 주체 설명 정의 (로그 및 프로시저 파라미터용)
let actor_description = format!("RustFileSync-{}", hostname);
let admin_user_id: Option<i32> = None; // Rust 자동화 작업이므로 관리자 ID는 None
info!( "'{}': DB 동기화 시작 (추가/할당: {}개, 할당 해제: {}개)", &actor_description, adds.len(), deletes.len());
// DB 커넥션 가져오기
let mut conn = pool.get_conn().await.context("DB 커넥션 얻기 실패 (sync)")?;
// --- 추가/할당 작업 루프 ---
for item_to_add in adds {
let log_key = item_to_add.serial_key.clone(); // 로그 및 전달용 합성 키
// 빈 합성 키 건너뛰기 (선택적이지만 권장)
if item_to_add.serial_key.is_empty() {
warn!("추가/할당 건너<0xEB><0x9B><0x81>: 유효하지 않은 합성 키. Host='{}', Category='{}', Model='{}'",
hostname, item_to_add.category, item_to_add.model);
continue;
}
// 동기화 프로시저 호출 (change_type = 1)
match call_sync_procedure(
&mut conn,
admin_user_id,
&actor_description,
hostname, // user_account_name
&item_to_add.category,
&item_to_add.manufacturer,
&item_to_add.model, // resource_name
&item_to_add.serial_key, // composite_key (p_serial_num 파라미터)
&item_to_add.spec_value,
&item_to_add.spec_unit,
hostname, // detected_by
1, // change_type = ADD (추가 또는 할당)
).await {
Ok(msg) => {
// 성공 로그 (결과 메시지 포함)
info!("자원 추가/할당 결과: Key='{}', Msg='{}'", log_key, msg);
// 결과 메시지에 따른 추가 경고 로깅
if msg.contains("실패") || msg.contains("오류") || msg.contains("주의:") || msg.contains("다른 사용자") || msg.contains("이미 등록") {
warn!("추가/할당 처리 중 특이사항: Key='{}', Msg='{}'", log_key, msg);
}
}
Err(e) => {
// 실패 로그
error!("자원 추가/할당 실패: Key='{}', 오류: {}", log_key, e);
// 여기서 에러를 반환할지, 아니면 계속 진행할지 결정 필요
// return Err(e.context(format!("자원 추가/할당 실패: Key='{}'", log_key)));
}
}
}
// --- 할당 해제 작업 루프 ---
for item_to_delete in deletes {
let log_key = item_to_delete.serial_num.clone(); // 로그 및 전달용 합성 키 (DB에서 가져온 값)
// 빈 합성 키 건너뛰기 (DB에서 왔으므로 가능성은 낮음)
if item_to_delete.serial_num.is_empty() {
warn!("할당 해제 건너<0xEB><0x9B><0x81>: 유효하지 않은 합성 키. Host='{}', ID='{}'", hostname, item_to_delete.resource_id);
continue;
}
// 동기화 프로시저 호출 (change_type = 2)
match call_sync_procedure(
&mut conn,
admin_user_id,
&actor_description,
hostname, // user_account_name
"", // category (할당 해제 시 불필요)
"", // manufacturer (할당 해제 시 불필요)
"", // resource_name (할당 해제 시 불필요)
&item_to_delete.serial_num, // composite_key (p_serial_num 파라미터)
"", // spec_value (할당 해제 시 불필요)
"", // spec_unit (할당 해제 시 불필요)
hostname, // detected_by
2, // change_type = DELETE (할당 해제)
).await {
Ok(msg) => {
// 성공 로그
info!("자원 할당 해제 결과: Key='{}', Msg='{}'", log_key, msg);
// 결과 메시지에 따른 추가 경고 로깅
if msg.contains("실패") || msg.contains("오류") || msg.contains("주의:") || msg.contains("대상 없음") {
warn!("할당 해제 처리 중 특이사항: Key='{}', Msg='{}'", log_key, msg);
}
}
Err(e) => {
// 실패 로그
error!("자원 할당 해제 실패: Key='{}', 오류: {}", log_key, e);
// 여기서 에러를 반환할지, 아니면 계속 진행할지 결정 필요
// return Err(e.context(format!("자원 할당 해제 실패: Key='{}'", log_key)));
}
}
}
info!("'{}': DB 동기화 완료.", &actor_description);
Ok(())
}

View File

@ -0,0 +1,106 @@
// src/file/decrypt.rs
// 내부 전용 어플인데 구지 필요할까 싶지만 그냥 스터디 차원에서 별도의 암호화/복호화 기능 구현.
use anyhow::{Context, Result};
use serde::Deserialize; // derive 매크로가 Deserialize 트레잇 사용
use std::{env, fs};
use std::path::Path;
// --- AES-GCM 관련 의존성 ---
use aes_gcm::aead::Aead; // Aead 트레잇은 그대로 사용
use aes_gcm::KeyInit; // KeyInit 트레잇을 직접 임포트
use aes_gcm::{Aes128Gcm, Key, Nonce}; // 나머지 타입들
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _};
// 애플리케이션에서 사용할 DB 접속 정보 구조체
#[derive(Deserialize, Debug, Clone)] // Deserialize 트레잇 사용 명시
pub struct DbCredentials {
pub host: String,
pub user: String,
pub pass: String,
pub db: String,
pub port: u16,
}
// 암호화된 JSON 파일 내용에 맞는 임시 구조체
#[derive(Deserialize, Debug)] // Deserialize 트레잇 사용 명시
struct EncryptedJsonData {
username: String,
password: String,
}
// 환경 변수에서 암호화 키 로드
fn load_encryption_key() -> Result<Key<Aes128Gcm>> {
let key_b64 = env::var("DB_ENCRYPTION_KEY")
.context("환경 변수 'DB_ENCRYPTION_KEY'를 찾을 수 없습니다.")?;
let key_bytes = base64_engine.decode(key_b64.trim())
.context("DB_ENCRYPTION_KEY Base64 디코딩 실패")?;
if key_bytes.len() == 16 {
Ok(*Key::<Aes128Gcm>::from_slice(&key_bytes))
} else {
anyhow::bail!(
"DB_ENCRYPTION_KEY 키 길이가 16바이트가 아닙니다 (현재 {}바이트).", key_bytes.len()
)
}
}
// 환경 변수에서 DB 접속 정보(host, name, port) 로드
fn load_db_connection_info() -> Result<(String, String, u16)> {
let host = env::var("DB_HOST").context("환경 변수 'DB_HOST'를 찾을 수 없습니다.")?;
let db_name = env::var("DB_NAME").context("환경 변수 'DB_NAME'를 찾을 수 없습니다.")?;
let port_str = env::var("DB_PORT").context("환경 변수 'DB_PORT'를 찾을 수 없습니다.")?;
let port = port_str
.parse::<u16>()
.with_context(|| format!("DB_PORT 값 '{}' 파싱 실패", port_str))?;
Ok((host, db_name, port))
}
// 암호화된 DB 설정 파일을 읽고 복호화하는 함수 (AES-GCM)
pub fn decrypt_db_config(db_config_path: &str) -> Result<DbCredentials> {
log::info!("'{}' 파일 복호화 시도 (AES-GCM)...", db_config_path);
let path = Path::new(db_config_path);
if !path.exists() {
anyhow::bail!("DB 설정 파일을 찾을 수 없습니다: {}", db_config_path);
}
let key = load_encryption_key().context("암호화 키 로드 실패")?;
log::debug!("암호화 키 로드 성공.");
let encrypted_data_with_nonce = fs::read(path)
.with_context(|| format!("DB 설정 파일 읽기 실패: {}", db_config_path))?;
let nonce_len = 12; // AES-GCM 표준 Nonce 길이
if encrypted_data_with_nonce.len() <= nonce_len {
anyhow::bail!("암호화된 데이터가 너무 짧습니다.");
}
let (nonce_bytes, ciphertext) = encrypted_data_with_nonce.split_at(nonce_len);
let nonce = Nonce::from_slice(nonce_bytes);
log::debug!("Nonce와 암호문 분리 완료.");
let cipher = Aes128Gcm::new(&key); // NewAead 트레잇 사용
let decrypted_bytes = cipher.decrypt(nonce, ciphertext) // Aead 트레잇 사용
.map_err(|e| anyhow::anyhow!("AES-GCM 복호화 실패: {}", e))?;
log::debug!("AES-GCM 복호화 및 인증 성공.");
// Deserialize 트레잇 사용
let temp_data: EncryptedJsonData = serde_json::from_slice(&decrypted_bytes)
.context("복호화된 데이터 JSON 파싱 실패 (EncryptedJsonData)")?;
log::debug!("복호화된 JSON 데이터 파싱 성공 (Username: {}).", temp_data.username);
let (host, db_name, port) = load_db_connection_info()
.context("DB 연결 정보 환경 변수 로드 실패")?;
log::debug!("DB 연결 정보 로드 성공.");
let db_creds = DbCredentials {
host,
db: db_name,
port,
user: temp_data.username,
pass: temp_data.password,
};
log::info!("DB 설정 파일 복호화 및 전체 인증 정보 구성 완료 (Host: {}, User: {}, DB: {}, Port: {}).",
db_creds.host, db_creds.user, db_creds.db, db_creds.port);
Ok(db_creds)
}

View File

@ -0,0 +1,192 @@
use anyhow::{Context, Result};
use log::{debug, error, info, warn};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
// 문자열 정리 및 기본값("N/A") 설정 함수
fn clean_string(s: Option<&str>) -> String {
match s {
Some(val) => {
let trimmed = val.trim();
if trimmed.is_empty() {
"N/A".to_string() // 비어 있으면 "N/A" 반환
} else {
trimmed.to_string() // 앞뒤 공백 제거
}
}
None => "N/A".to_string(), // Option이 None이면 "N/A" 반환
}
}
// JSON 파일의 원본 데이터 구조체
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct RawHwInfo {
pub hostname: Option<String>,
pub category: Option<String>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub serial: Option<String>, // 실제 시리얼 (합성 키 생성에 사용될 수 있음)
pub spec_value: Option<String>,
pub spec_unit: Option<String>,
pub port_or_slot: Option<String>, // 합성 키 생성에 사용
}
// 처리된 하드웨어 정보 구조체 (실제 사용될 데이터)
#[derive(Debug, Clone)]
pub struct ProcessedHwInfo {
pub hostname: String,
pub category: String,
pub manufacturer: String,
pub model: String,
pub serial_key: String, // <-- 합성 키 (비교 및 DB 전달용)
pub spec_value: String,
pub spec_unit: String,
}
// 지정된 디렉토리에서 패턴에 맞는 JSON 파일 목록 찾기
pub fn find_json_files(dir_path: &str) -> Result<Vec<PathBuf>> {
let mut json_files = Vec::new();
let path = Path::new(dir_path);
// 경로 유효성 검사
if !path.is_dir() {
anyhow::bail!("제공된 JSON 경로가 디렉토리가 아닙니다: {}", dir_path);
}
// 디렉토리 순회하며 파일 찾기
for entry in fs::read_dir(path).with_context(|| format!("디렉토리 읽기 오류: {}", dir_path))? {
let entry = entry.context("디렉토리 항목 읽기 실패")?;
let path = entry.path();
if path.is_file() {
// 파일 이름 패턴 확인 (HWInfo_*.json)
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.starts_with("HWInfo_") && filename.ends_with(".json") {
debug!("발견된 JSON 파일: {:?}", path);
json_files.push(path);
}
}
}
}
Ok(json_files)
}
// 단일 JSON 파일 파싱 및 데이터 처리 함수
fn parse_and_process_single_json(path: &Path) -> Result<Vec<ProcessedHwInfo>> {
// 파일 읽기 및 BOM 처리
let bytes = fs::read(path).with_context(|| format!("JSON 파일 읽기 실패: {:?}", path))?;
let json_content_without_bom = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
std::str::from_utf8(&bytes[3..]).with_context(|| format!("UTF-8 변환 실패(BOM제거): {:?}", path))?
} else {
std::str::from_utf8(&bytes).with_context(|| format!("UTF-8 변환 실패: {:?}", path))?
};
// 빈 파일 처리
if json_content_without_bom.trim().is_empty() {
warn!("JSON 파일 내용이 비어 있습니다: {:?}", path);
return Ok(Vec::new());
}
// JSON 파싱
let raw_data: Vec<RawHwInfo> = serde_json::from_str(json_content_without_bom)
.with_context(|| format!("JSON 파싱 실패: {:?}", path))?;
let mut processed_list = Vec::new();
// 처리할 카테고리 목록 (필요시 DB와 동기화 또는 설정 파일로 분리)
let relevant_categories = ["CPU", "Mainboard", "Memory", "SSD", "HDD", "VGA"];
for raw_item in raw_data {
// 필수 필드(카테고리, 호스트명) 확인
if let (Some(cat_str), Some(host_str)) = (raw_item.category.as_deref(), raw_item.hostname.as_deref()) {
let category = clean_string(Some(cat_str));
let hostname = clean_string(Some(host_str));
// 관련 없는 카테고리 또는 유효하지 않은 호스트명 건너뛰기
if hostname == "N/A" || !relevant_categories.contains(&category.as_str()) {
continue;
}
let original_serial = clean_string(raw_item.serial.as_deref());
let port_or_slot = clean_string(raw_item.port_or_slot.as_deref());
let model = clean_string(raw_item.model.as_deref());
// --- 합성 키 생성 로직 ---
let serial_key: String;
let mut key_parts: Vec<String> = Vec::new();
// 실제 시리얼, 슬롯/포트, 호스트명을 조합하여 고유 키 생성 시도
if original_serial != "N/A" { key_parts.push(original_serial.replace('.', "")); }
if port_or_slot != "N/A" { key_parts.push(port_or_slot.replace('.', "")); }
if hostname != "N/A" { key_parts.push(hostname.replace('.', "")); }
let combined_key = key_parts.iter().map(AsRef::as_ref).collect::<Vec<&str>>().join("_");
// 조합된 키가 비어있거나 호스트명만 있는 경우, 대체 키 생성 시도 (모델명+호스트명)
if combined_key.is_empty() {
let model_cleaned = model.replace('.', "");
let hostname_cleaned_fallback = hostname.replace('.', "");
if model != "N/A" && hostname != "N/A" {
serial_key = format!("{}_{}", model_cleaned, hostname_cleaned_fallback);
debug!("대체 Serial Key 생성 (모델+호스트): {}", serial_key);
} else {
// 대체 키 생성도 불가능하면 해당 항목 건너뛰기
warn!("고유 Key 생성 불가 (시리얼/슬롯/모델 정보 부족), 항목 건너<0xEB><0x9B><0x81>: {:?}", raw_item);
continue;
}
} else {
serial_key = combined_key;
}
// --- 합성 키 생성 로직 끝 ---
// 처리된 데이터 구조체 생성
let processed = ProcessedHwInfo {
hostname: hostname.clone(),
category, // clean_string 이미 적용됨
manufacturer: clean_string(raw_item.manufacturer.as_deref()),
model, // clean_string 이미 적용됨
serial_key, // 생성된 합성 키
spec_value: clean_string(raw_item.spec_value.as_deref()),
spec_unit: clean_string(raw_item.spec_unit.as_deref()),
};
processed_list.push(processed);
} else {
// 필수 필드 누락 시 경고 로그
warn!("필수 필드(Category 또는 Hostname) 누락 항목 건너<0xEB><0x9B><0x81>: {:?}", raw_item);
}
}
Ok(processed_list)
}
// 모든 JSON 파일을 읽고 처리하여 호스트명 기준으로 그룹화하는 함수
pub fn read_and_process_json_files(
json_dir_path: &str,
) -> Result<HashMap<String, Vec<ProcessedHwInfo>>> {
info!("'{}' 디렉토리에서 JSON 파일 검색 및 처리 시작...", json_dir_path);
let json_files = find_json_files(json_dir_path)?;
info!("총 {}개의 JSON 파일 발견.", json_files.len());
let mut all_processed_data: HashMap<String, Vec<ProcessedHwInfo>> = HashMap::new();
// 각 JSON 파일 처리
for json_path in json_files {
debug!("처리 중인 파일: {:?}", json_path);
match parse_and_process_single_json(&json_path) {
Ok(processed_items) => {
if !processed_items.is_empty() {
// 처리된 데이터를 호스트명 기준으로 그룹화
for item in processed_items {
all_processed_data.entry(item.hostname.clone()).or_default().push(item);
}
} else {
// 처리할 데이터가 없는 경우 디버그 로그
debug!("파일에서 처리할 관련 카테고리 데이터가 없습니다: {:?}", json_path);
}
}
Err(e) => {
// 파일 처리 중 오류 발생 시 에러 로그
error!("파일 처리 중 오류 발생 {:?}: {}", json_path, e);
}
}
}
info!("JSON 파일 처리 완료. 총 {}개 호스트 데이터 생성.", all_processed_data.len());
Ok(all_processed_data)
}

View File

@ -0,0 +1,5 @@
// src/file/mod.rs
pub mod json_reader;
pub mod decrypt;
// main.rs에서 필요한 것들 위주로 내보내기

View File

@ -0,0 +1,27 @@
// src/logger/logger.rs
use anyhow::{Context, Result};
// use log::LevelFilter; // 사용하지 않으므로 제거
use log4rs::init_file;
use std::fs;
// log4rs 설정 파일을 읽어 로거를 초기화하는 함수
pub fn setup_logger() -> Result<()> {
let log_dir = "logs";
let config_file = "config/log4rs.yaml";
// 1. 로그 디렉토리 생성 (없으면)
if !std::path::Path::new(log_dir).exists() {
fs::create_dir_all(log_dir)
.with_context(|| format!("로그 디렉토리 '{}' 생성 실패", log_dir))?;
println!("로그 디렉토리 '{}' 생성됨.", log_dir); // 로거 초기화 전
}
// 2. log4rs 설정 파일 로드 및 초기화
init_file(config_file, Default::default())
.with_context(|| format!("log4rs 설정 파일 '{}' 로드 및 초기화 실패", config_file))?;
log::info!("Logger initialized using config file: {}", config_file); // 로거 초기화 후
Ok(())
}

View File

@ -0,0 +1,7 @@
// src/logger/mod.rs
// logger 모듈을 현재 모듈(logger)의 하위 모듈로 선언
pub mod logger;
// logger 모듈의 setup_logger 함수를 외부에서 logger::setup_logger 형태로 사용할 수 있도록 공개 (re-export)
pub use logger::setup_logger;

129
apps/rust_gyber/src/main.rs Normal file
View File

@ -0,0 +1,129 @@
use anyhow::{Context, Result};
use log::{error, info, warn};
use std::collections::HashMap;
// --- 애플리케이션 모듈 선언 ---
mod config_reader;
mod db; // 데이터베이스 관련 모듈 (connection, compare, sync 포함)
mod file; // 파일 처리 관련 모듈 (json_reader, decrypt 포함)
mod logger;
// --- 필요한 구조체 및 함수 임포트 ---
use config_reader::read_app_config;
use db::{
compare::{compare_data, ComparisonResult}, // 데이터 비교 함수 및 결과 구조체
connection::connect_db, // DB 연결 함수
sync::execute_sync, // DB 동기화 실행 함수
};
use file::{
decrypt::decrypt_db_config, // DB 설정 복호화 함수
json_reader::{read_and_process_json_files, ProcessedHwInfo}, // JSON 처리 함수 및 구조체
};
use logger::setup_logger; // 로거 설정 함수
// --- 애플리케이션 메인 진입점 ---
#[tokio::main]
async fn main() -> Result<()> {
// .env 파일 로드 (환경 변수 사용 위함, 예: 복호화 키)
dotenvy::dotenv().ok(); // 파일 없어도 오류 아님
// 1. 로거 초기화
setup_logger().context("로거 설정 실패")?;
info!("자원 관리 동기화 애플리케이션 시작...");
info!("환경 변수 로드 시도 완료."); // 실제 로드 여부는 dotenvy 결과 확인 필요
// 2. 애플리케이션 설정 파일 읽기
let config_path = "config/config.json"; // 설정 파일 경로
info!("설정 파일 읽기 시도: {}", config_path);
let app_config = read_app_config(config_path).context("애플리케이션 설정 읽기 실패")?;
info!("설정 로드 완료: JSON 경로='{}', DB 설정 파일='{}'", app_config.json_files_path, app_config.db_config_path);
// 3. DB 인증 정보 복호화
info!("DB 설정 파일 복호화 시도: {}", app_config.db_config_path);
let db_creds = decrypt_db_config(&app_config.db_config_path).context("DB 인증 정보 복호화 실패")?;
info!("DB 설정 복호화 완료."); // 성공 로그 (민감 정보 노출 주의)
// 4. 데이터베이스 연결 풀 생성
info!("데이터베이스 연결 시도: 호스트={}, DB={}", db_creds.host, db_creds.db);
let db_pool = connect_db(&db_creds).await.context("데이터베이스 연결 풀 생성 실패")?;
info!("데이터베이스 연결 풀 생성 완료.");
// 5. JSON 파일 읽기 및 처리
let processed_data: HashMap<String, Vec<ProcessedHwInfo>> =
match read_and_process_json_files(&app_config.json_files_path) {
Ok(data) => data,
Err(e) => {
// JSON 처리 실패 시 즉시 종료
error!("JSON 파일 처리 실패: {}", e);
return Err(e.context("JSON 파일 처리 중 치명적 오류 발생"));
}
};
// 처리할 데이터가 없는 경우 종료
if processed_data.is_empty() {
warn!("처리할 유효한 JSON 데이터 없음. 종료.");
return Ok(());
}
info!("총 {}개 호스트 데이터 처리 완료.", processed_data.len());
// 6. 데이터 비교 (JSON vs DB)
let comparison_results: HashMap<String, ComparisonResult> =
match compare_data(&db_pool, &processed_data).await {
Ok(results) => results,
Err(e) => {
// 데이터 비교 실패 시 즉시 종료
error!("데이터 비교 실패: {}", e);
return Err(e.context("데이터 비교 중 치명적 오류 발생"));
}
};
// 변경 사항 없는 경우 종료
if comparison_results.is_empty() {
info!("DB와 비교 결과, 변경 사항 없음. 종료.");
return Ok(());
}
info!("총 {}개 호스트 변경 사항 발견.", comparison_results.len());
// 7. DB 동기화 실행 (추가/할당, 할당 해제)
info!("DB 동기화 작업 시작...");
let mut success_count = 0;
let mut fail_count = 0;
let total_hosts_to_sync = comparison_results.len();
// 각 호스트별 변경 사항 DB에 적용
for (hostname, changes) in comparison_results {
info!("'{}' 호스트 DB 동기화 처리 중...", hostname);
match execute_sync(
&db_pool,
&hostname,
changes.adds, // 추가/할당 대상 전달
changes.deletes // 할당 해제 대상 전달
).await {
Ok(_) => {
// 성공 시 카운트 증가
success_count += 1;
info!("'{}' 호스트 DB 동기화 성공.", hostname);
}
Err(e) => {
// 실패 시 카운트 증가 및 에러 로그
fail_count += 1;
error!("'{}' 호스트 DB 동기화 에러: {}", hostname, e);
// 개별 호스트 실패 시 전체 프로세스를 중단할지, 아니면 계속 진행할지 결정
// 여기서는 계속 진행하고 마지막에 요약
}
}
}
// 8. 최종 결과 요약 로깅
info!("--- DB 동기화 작업 요약 ---");
info!("총 대상 호스트: {}", total_hosts_to_sync);
info!("성공 처리 호스트: {}", success_count);
info!("오류 발생 호스트: {}", fail_count);
if fail_count > 0 {
// 실패한 호스트가 있으면 에러 레벨 로그 추가
error!("일부 호스트 동기화 중 오류 발생. 상세 내용은 위 로그 확인 필요.");
}
info!("자원 관리 동기화 애플리케이션 정상 종료.");
Ok(())
}

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

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