convert to gitea
This commit is contained in:
101
.gitignore
vendored
Normal file
101
.gitignore
vendored
Normal 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
157
README.md
Normal 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에서 주기적으로 (예: 로그인 시 또는 예약된 작업으로) 실행되도록 설정합니다.
|
||||
11
apps/rust_db_enc_creator/.env
Normal file
11
apps/rust_db_enc_creator/.env
Normal 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
356
apps/rust_db_enc_creator/Cargo.lock
generated
Normal 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",
|
||||
]
|
||||
25
apps/rust_db_enc_creator/Cargo.toml
Normal file
25
apps/rust_db_enc_creator/Cargo.toml
Normal 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" # 최신 버전 확인
|
||||
76
apps/rust_db_enc_creator/src/main.rs
Normal file
76
apps/rust_db_enc_creator/src/main.rs
Normal 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
12
apps/rust_gyber/.env
Normal 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
2696
apps/rust_gyber/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/rust_gyber/Cargo.toml
Normal file
36
apps/rust_gyber/Cargo.toml
Normal 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"] }
|
||||
4
apps/rust_gyber/config/config.json
Normal file
4
apps/rust_gyber/config/config.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"json_file_path": "/pcinfo",
|
||||
"db_config_path": "config/db.enc"
|
||||
}
|
||||
1
apps/rust_gyber/config/db.enc
Normal file
1
apps/rust_gyber/config/db.enc
Normal 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
|
||||
48
apps/rust_gyber/config/log4rs.yaml
Normal file
48
apps/rust_gyber/config/log4rs.yaml
Normal 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면 이 설정만 적용
|
||||
51
apps/rust_gyber/src/config_reader.rs
Normal file
51
apps/rust_gyber/src/config_reader.rs
Normal 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)
|
||||
}
|
||||
143
apps/rust_gyber/src/db/compare.rs
Normal file
143
apps/rust_gyber/src/db/compare.rs
Normal 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)
|
||||
}
|
||||
37
apps/rust_gyber/src/db/connection.rs
Normal file
37
apps/rust_gyber/src/db/connection.rs
Normal 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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/rust_gyber/src/db/mod.rs
Normal file
9
apps/rust_gyber/src/db/mod.rs
Normal 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;
|
||||
208
apps/rust_gyber/src/db/sync.rs
Normal file
208
apps/rust_gyber/src/db/sync.rs
Normal 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(())
|
||||
}
|
||||
106
apps/rust_gyber/src/file/decrypt.rs
Normal file
106
apps/rust_gyber/src/file/decrypt.rs
Normal 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)
|
||||
}
|
||||
192
apps/rust_gyber/src/file/json_reader.rs
Normal file
192
apps/rust_gyber/src/file/json_reader.rs
Normal 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)
|
||||
}
|
||||
5
apps/rust_gyber/src/file/mod.rs
Normal file
5
apps/rust_gyber/src/file/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
// src/file/mod.rs
|
||||
pub mod json_reader;
|
||||
pub mod decrypt;
|
||||
|
||||
// main.rs에서 필요한 것들 위주로 내보내기
|
||||
27
apps/rust_gyber/src/logger/logger.rs
Normal file
27
apps/rust_gyber/src/logger/logger.rs
Normal 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(())
|
||||
}
|
||||
7
apps/rust_gyber/src/logger/mod.rs
Normal file
7
apps/rust_gyber/src/logger/mod.rs
Normal 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
129
apps/rust_gyber/src/main.rs
Normal 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(())
|
||||
}
|
||||
0
apps/web/config/__init__.py
Normal file
0
apps/web/config/__init__.py
Normal file
16
apps/web/config/asgi.py
Normal file
16
apps/web/config/asgi.py
Normal 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
298
apps/web/config/settings.py
Normal 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
35
apps/web/config/urls.py
Normal 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
16
apps/web/config/wsgi.py
Normal 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()
|
||||
0
apps/web/gyber/__init__.py
Normal file
0
apps/web/gyber/__init__.py
Normal file
179
apps/web/gyber/admin.py
Normal file
179
apps/web/gyber/admin.py
Normal 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
6
apps/web/gyber/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GyberConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gyber'
|
||||
24
apps/web/gyber/auth_utils.py
Normal file
24
apps/web/gyber/auth_utils.py
Normal 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()
|
||||
69
apps/web/gyber/context_processors.py
Normal file
69
apps/web/gyber/context_processors.py
Normal 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), # 필요시 추가
|
||||
}
|
||||
17
apps/web/gyber/db/__init__.py
Normal file
17
apps/web/gyber/db/__init__.py
Normal 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 # ... 등등
|
||||
# ...
|
||||
63
apps/web/gyber/db/audit.py
Normal file
63
apps/web/gyber/db/audit.py
Normal 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
90
apps/web/gyber/db/base.py
Normal 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 [] # 데이터 반환을 기대하는 모드에서는 오류 시 빈 리스트 반환
|
||||
135
apps/web/gyber/db/category.py
Normal file
135
apps/web/gyber/db/category.py
Normal 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}"
|
||||
15
apps/web/gyber/db/dashboard.py
Normal file
15
apps/web/gyber/db/dashboard.py
Normal 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
133
apps/web/gyber/db/group.py
Normal 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}"
|
||||
244
apps/web/gyber/db/resource.py
Normal file
244
apps/web/gyber/db/resource.py
Normal 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
205
apps/web/gyber/db/user.py
Normal 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
189
apps/web/gyber/forms.py
Normal 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__ 수정 없음
|
||||
75
apps/web/gyber/migrations/0001_initial.py
Normal file
75
apps/web/gyber/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/web/gyber/migrations/__init__.py
Normal file
0
apps/web/gyber/migrations/__init__.py
Normal file
278
apps/web/gyber/models.py
Normal file
278
apps/web/gyber/models.py
Normal 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
117
apps/web/gyber/oidc.py
Normal 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
56
apps/web/gyber/signals.py
Normal 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 # 시그널 임포트
|
||||
49
apps/web/gyber/templates/gyber/category_form.html
Normal file
49
apps/web/gyber/templates/gyber/category_form.html
Normal 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 %}
|
||||
67
apps/web/gyber/templates/gyber/category_list.html
Normal file
67
apps/web/gyber/templates/gyber/category_list.html
Normal 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 %}
|
||||
53
apps/web/gyber/templates/gyber/category_log_list.html
Normal file
53
apps/web/gyber/templates/gyber/category_log_list.html
Normal 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 사용 #}
|
||||
209
apps/web/gyber/templates/gyber/dashboard.html
Normal file
209
apps/web/gyber/templates/gyber/dashboard.html
Normal 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 %}
|
||||
48
apps/web/gyber/templates/gyber/group_form.html
Normal file
48
apps/web/gyber/templates/gyber/group_form.html
Normal 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 %}
|
||||
75
apps/web/gyber/templates/gyber/group_list.html
Normal file
75
apps/web/gyber/templates/gyber/group_list.html
Normal 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 %}
|
||||
55
apps/web/gyber/templates/gyber/group_log_list.html
Normal file
55
apps/web/gyber/templates/gyber/group_log_list.html
Normal 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 사용 #}
|
||||
148
apps/web/gyber/templates/gyber/log_base.html
Normal file
148
apps/web/gyber/templates/gyber/log_base.html
Normal 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 %}
|
||||
114
apps/web/gyber/templates/gyber/resource_detail.html
Normal file
114
apps/web/gyber/templates/gyber/resource_detail.html
Normal 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 %}
|
||||
215
apps/web/gyber/templates/gyber/resource_form.html
Normal file
215
apps/web/gyber/templates/gyber/resource_form.html
Normal 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 %}
|
||||
88
apps/web/gyber/templates/gyber/resource_list.html
Normal file
88
apps/web/gyber/templates/gyber/resource_list.html
Normal 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 %}
|
||||
55
apps/web/gyber/templates/gyber/resource_log_list.html
Normal file
55
apps/web/gyber/templates/gyber/resource_log_list.html
Normal 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 블록 재정의 안 함) #}
|
||||
66
apps/web/gyber/templates/gyber/user_form.html
Normal file
66
apps/web/gyber/templates/gyber/user_form.html
Normal 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 %}
|
||||
64
apps/web/gyber/templates/gyber/user_list.html
Normal file
64
apps/web/gyber/templates/gyber/user_list.html
Normal 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 %}
|
||||
58
apps/web/gyber/templates/gyber/user_log_list.html
Normal file
58
apps/web/gyber/templates/gyber/user_log_list.html
Normal 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
56
apps/web/gyber/urls.py
Normal 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
901
apps/web/gyber/views.py_old
Normal 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'))
|
||||
11
apps/web/gyber/views/__init__.py
Normal file
11
apps/web/gyber/views/__init__.py
Normal 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에서 명시적으로 임포트합니다.
|
||||
45
apps/web/gyber/views/api.py
Normal file
45
apps/web/gyber/views/api.py
Normal 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})
|
||||
100
apps/web/gyber/views/audit.py
Normal file
100
apps/web/gyber/views/audit.py
Normal 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')
|
||||
51
apps/web/gyber/views/auth.py
Normal file
51
apps/web/gyber/views/auth.py
Normal 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)
|
||||
130
apps/web/gyber/views/category.py
Normal file
130
apps/web/gyber/views/category.py
Normal 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'))
|
||||
66
apps/web/gyber/views/dashboard.py
Normal file
66
apps/web/gyber/views/dashboard.py
Normal 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)
|
||||
84
apps/web/gyber/views/export_views.py
Normal file
84
apps/web/gyber/views/export_views.py
Normal 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
|
||||
163
apps/web/gyber/views/group.py
Normal file
163
apps/web/gyber/views/group.py
Normal 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'))
|
||||
344
apps/web/gyber/views/resource.py
Normal file
344
apps/web/gyber/views/resource.py
Normal 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'))
|
||||
213
apps/web/gyber/views/user.py
Normal file
213
apps/web/gyber/views/user.py
Normal 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
22
apps/web/manage.py
Normal 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
16
apps/web/requirements.txt
Normal 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
|
||||
156
apps/web/static/css/custom_styles.css
Normal file
156
apps/web/static/css/custom_styles.css
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
279
apps/web/staticfiles/admin/css/autocomplete.css
Normal file
279
apps/web/staticfiles/admin/css/autocomplete.css
Normal 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);
|
||||
}
|
||||
1183
apps/web/staticfiles/admin/css/base.css
Normal file
1183
apps/web/staticfiles/admin/css/base.css
Normal file
File diff suppressed because it is too large
Load Diff
343
apps/web/staticfiles/admin/css/changelists.css
Normal file
343
apps/web/staticfiles/admin/css/changelists.css
Normal 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);
|
||||
}
|
||||
130
apps/web/staticfiles/admin/css/dark_mode.css
Normal file
130
apps/web/staticfiles/admin/css/dark_mode.css
Normal 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;
|
||||
}
|
||||
29
apps/web/staticfiles/admin/css/dashboard.css
Normal file
29
apps/web/staticfiles/admin/css/dashboard.css
Normal 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;
|
||||
}
|
||||
498
apps/web/staticfiles/admin/css/forms.css
Normal file
498
apps/web/staticfiles/admin/css/forms.css
Normal 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;
|
||||
}
|
||||
61
apps/web/staticfiles/admin/css/login.css
Normal file
61
apps/web/staticfiles/admin/css/login.css
Normal 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;
|
||||
}
|
||||
150
apps/web/staticfiles/admin/css/nav_sidebar.css
Normal file
150
apps/web/staticfiles/admin/css/nav_sidebar.css
Normal 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%;
|
||||
}
|
||||
908
apps/web/staticfiles/admin/css/responsive.css
Normal file
908
apps/web/staticfiles/admin/css/responsive.css
Normal 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;
|
||||
}
|
||||
}
|
||||
89
apps/web/staticfiles/admin/css/responsive_rtl.css
Normal file
89
apps/web/staticfiles/admin/css/responsive_rtl.css
Normal 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;
|
||||
}
|
||||
}
|
||||
293
apps/web/staticfiles/admin/css/rtl.css
Normal file
293
apps/web/staticfiles/admin/css/rtl.css
Normal 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;
|
||||
}
|
||||
19
apps/web/staticfiles/admin/css/unusable_password_field.css
Normal file
19
apps/web/staticfiles/admin/css/unusable_password_field.css
Normal 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;
|
||||
}
|
||||
21
apps/web/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md
vendored
Normal file
21
apps/web/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md
vendored
Normal 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.
|
||||
481
apps/web/staticfiles/admin/css/vendor/select2/select2.css
vendored
Normal file
481
apps/web/staticfiles/admin/css/vendor/select2/select2.css
vendored
Normal 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; }
|
||||
1
apps/web/staticfiles/admin/css/vendor/select2/select2.min.css
vendored
Normal file
1
apps/web/staticfiles/admin/css/vendor/select2/select2.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
613
apps/web/staticfiles/admin/css/widgets.css
Normal file
613
apps/web/staticfiles/admin/css/widgets.css
Normal 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;
|
||||
}
|
||||
20
apps/web/staticfiles/admin/img/LICENSE
Normal file
20
apps/web/staticfiles/admin/img/LICENSE
Normal 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.
|
||||
7
apps/web/staticfiles/admin/img/README.txt
Normal file
7
apps/web/staticfiles/admin/img/README.txt
Normal 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).
|
||||
63
apps/web/staticfiles/admin/img/calendar-icons.svg
Normal file
63
apps/web/staticfiles/admin/img/calendar-icons.svg
Normal 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 |
1
apps/web/staticfiles/admin/img/gis/move_vertex_off.svg
Normal file
1
apps/web/staticfiles/admin/img/gis/move_vertex_off.svg
Normal 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 |
1
apps/web/staticfiles/admin/img/gis/move_vertex_on.svg
Normal file
1
apps/web/staticfiles/admin/img/gis/move_vertex_on.svg
Normal 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 |
3
apps/web/staticfiles/admin/img/icon-addlink.svg
Normal file
3
apps/web/staticfiles/admin/img/icon-addlink.svg
Normal 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 |
3
apps/web/staticfiles/admin/img/icon-alert.svg
Normal file
3
apps/web/staticfiles/admin/img/icon-alert.svg
Normal 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
Reference in New Issue
Block a user