Initial commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# Flask Configuration
|
||||||
|
FLASK_APP=run.py
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=1da82c4604ae1d3c3a00f66d2e6aecc8d8f19a93756e7b4a7c114cc6e26eeccd
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=it_ticket
|
||||||
|
DB_USER=it_ticket
|
||||||
|
DB_PASSWORD=IT.t1ck3t.5ys
|
||||||
|
|
||||||
|
# Mail Configuration (SMTP)
|
||||||
|
MAIL_SERVER=mail.ltservicesinc.com
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USERNAME=jqc.noreply@ltservicesinc.com
|
||||||
|
MAIL_PASSWORD=jQc.4utoMail$
|
||||||
|
MAIL_DEFAULT_SENDER=IT Helpdesk <jqc.noreply@ltservicesinc.com>
|
||||||
|
|
||||||
|
# IT Department Email (receives all new ticket notifications)
|
||||||
|
IT_DEPT_EMAIL=da.nguyen8744@gmail.com
|
||||||
|
|
||||||
|
# Application URL (used in email links)
|
||||||
|
APP_BASE_URL=https://tickets.ltservicesinc.com
|
||||||
|
|
||||||
|
# Anthropic API Key (for AI Chatbot)
|
||||||
|
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
UPLOAD_FOLDER=app/static/uploads
|
||||||
|
MAX_CONTENT_LENGTH=16777216
|
||||||
|
|
||||||
|
# Admin default credentials (change after first login)
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
ADMIN_PASSWORD=Admin@123!
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
# TechDesk — IT Ticket System
|
||||||
|
## Complete Deployment & Implementation Guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Project Structure](#1-project-structure)
|
||||||
|
2. [Prerequisites](#2-prerequisites)
|
||||||
|
3. [Server Setup](#3-server-setup)
|
||||||
|
4. [MySQL Database Setup](#4-mysql-database-setup)
|
||||||
|
5. [Application Deployment](#5-application-deployment)
|
||||||
|
6. [Gunicorn Configuration](#6-gunicorn-configuration)
|
||||||
|
7. [Systemd Service](#7-systemd-service)
|
||||||
|
8. [Nginx Configuration](#8-nginx-configuration)
|
||||||
|
9. [SSL Certificate](#9-ssl-certificate)
|
||||||
|
10. [First Login & Admin Setup](#10-first-login--admin-setup)
|
||||||
|
11. [AI Chatbot Configuration](#11-ai-chatbot-configuration)
|
||||||
|
12. [Email Configuration](#12-email-configuration)
|
||||||
|
13. [Code Change Map](#13-code-change-map)
|
||||||
|
14. [Features Overview](#14-features-overview)
|
||||||
|
15. [Maintenance & Operations](#15-maintenance--operations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
it-ticket-system/
|
||||||
|
├── run.py ← Flask entry point
|
||||||
|
├── gunicorn.conf.py ← Gunicorn worker config
|
||||||
|
├── it-ticket-system.service ← Systemd unit file
|
||||||
|
├── nginx.conf ← Nginx site config
|
||||||
|
├── setup_db.sql ← MySQL DB + user creation
|
||||||
|
├── requirements.txt ← Python dependencies
|
||||||
|
├── .env.example ← Environment variable template
|
||||||
|
├── config/
|
||||||
|
│ └── config.py ← Flask config classes (Dev/Prod)
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py ← App factory, extension init, seed admin
|
||||||
|
│ ├── models.py ← All SQLAlchemy models
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── auth.py ← Login, register, logout, profile
|
||||||
|
│ │ ├── tickets.py ← Ticket CRUD, comments, attachments
|
||||||
|
│ │ ├── admin.py ← IT/admin management views
|
||||||
|
│ │ ├── api.py ← JSON API + WebSocket events
|
||||||
|
│ │ └── chatbot.py ← AI assistant endpoint
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── notification_service.py ← Email + in-app + WebSocket notifications
|
||||||
|
│ │ └── log_service.py ← Activity log + ticket history helpers
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── base.html ← Sidebar, topbar, chat widget, JS
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── register.html
|
||||||
|
│ │ └── profile.html
|
||||||
|
│ ├── tickets/
|
||||||
|
│ │ ├── dashboard_employee.html
|
||||||
|
│ │ ├── dashboard_it.html
|
||||||
|
│ │ ├── create.html
|
||||||
|
│ │ ├── list.html
|
||||||
|
│ │ ├── detail.html
|
||||||
|
│ │ ├── notifications.html
|
||||||
|
│ │ ├── knowledge_base.html
|
||||||
|
│ │ └── kb_article.html
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── tickets.html
|
||||||
|
│ ├── users.html
|
||||||
|
│ ├── edit_user.html
|
||||||
|
│ ├── kb_list.html
|
||||||
|
│ ├── kb_edit.html
|
||||||
|
│ └── activity_logs.html
|
||||||
|
└── logs/ ← Created automatically at runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prerequisites
|
||||||
|
|
||||||
|
On your **Ubuntu 22.04** server, ensure the following are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update system
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Python 3.10+, pip, venv
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv python3-dev
|
||||||
|
|
||||||
|
# MySQL Server
|
||||||
|
sudo apt install -y mysql-server libmysqlclient-dev
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
sudo apt install -y nginx
|
||||||
|
|
||||||
|
# Certbot (Let's Encrypt SSL)
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
|
||||||
|
# Build tools (needed for some pip packages)
|
||||||
|
sudo apt install -y build-essential libssl-dev libffi-dev
|
||||||
|
|
||||||
|
# (Optional) Git for deployment
|
||||||
|
sudo apt install -y git
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Setup
|
||||||
|
|
||||||
|
### 3.1 Create Application User & Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a dedicated system user
|
||||||
|
sudo useradd --system --no-create-home --shell /bin/false it-tickets
|
||||||
|
|
||||||
|
# Create application directory
|
||||||
|
sudo mkdir -p /var/www/it-ticket-system
|
||||||
|
sudo chown it-tickets:www-data /var/www/it-ticket-system
|
||||||
|
sudo chmod 755 /var/www/it-ticket-system
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Deploy Application Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A: Clone from your Git repository
|
||||||
|
sudo -u it-tickets git clone https://your-repo-url.git /var/www/it-ticket-system
|
||||||
|
|
||||||
|
# Option B: Upload manually via scp
|
||||||
|
scp -r ./it-ticket-system/* user@your-server:/var/www/it-ticket-system/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Create Python Virtual Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/it-ticket-system
|
||||||
|
|
||||||
|
sudo -u it-tickets python3 -m venv venv
|
||||||
|
sudo -u it-tickets venv/bin/pip install --upgrade pip
|
||||||
|
sudo -u it-tickets venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Create Upload & Log Directories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u it-tickets mkdir -p /var/www/it-ticket-system/app/static/uploads
|
||||||
|
sudo -u it-tickets mkdir -p /var/www/it-ticket-system/logs
|
||||||
|
|
||||||
|
# Ensure www-data (nginx) can read uploads
|
||||||
|
sudo chown -R it-tickets:www-data /var/www/it-ticket-system/app/static/uploads
|
||||||
|
sudo chmod -R 775 /var/www/it-ticket-system/app/static/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MySQL Database Setup
|
||||||
|
|
||||||
|
### 4.1 Secure MySQL Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
# Follow prompts: set root password, remove anonymous users,
|
||||||
|
# disallow remote root login, remove test database.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Create Database and User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the provided SQL setup script
|
||||||
|
sudo mysql -u root -p < /var/www/it-ticket-system/setup_db.sql
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
sudo mysql -u root -p <<'EOF'
|
||||||
|
CREATE DATABASE IF NOT EXISTS it_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER IF NOT EXISTS 'it_tickets_user'@'localhost' IDENTIFIED BY 'YOUR_SECURE_PASSWORD';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, REFERENCES
|
||||||
|
ON it_tickets.* TO 'it_tickets_user'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Verify Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u it_tickets_user -p it_tickets
|
||||||
|
# Should connect without errors. Type \q to exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Application Deployment
|
||||||
|
|
||||||
|
### 5.1 Configure Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the example env file
|
||||||
|
sudo -u it-tickets cp /var/www/it-ticket-system/.env.example /var/www/it-ticket-system/.env
|
||||||
|
|
||||||
|
# Edit with your actual values
|
||||||
|
sudo nano /var/www/it-ticket-system/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical values to change in `.env`:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Generate a strong secret key:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=your-generated-secret-key
|
||||||
|
|
||||||
|
# Database — match what you set in setup_db.sql
|
||||||
|
DB_PASSWORD=YOUR_SECURE_PASSWORD
|
||||||
|
|
||||||
|
# Mail — your SMTP server credentials
|
||||||
|
MAIL_SERVER=smtp.yourdomain.com
|
||||||
|
MAIL_USERNAME=ittickets@yourdomain.com
|
||||||
|
MAIL_PASSWORD=your-email-password
|
||||||
|
IT_DEPT_EMAIL=it-department@yourdomain.com
|
||||||
|
|
||||||
|
# Public URL of your app
|
||||||
|
APP_BASE_URL=https://tickets.yourdomain.com
|
||||||
|
|
||||||
|
# Anthropic API key for AI chatbot
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# Change default admin password
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
ADMIN_PASSWORD=YourStrongAdminPassword!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Initialize the Database
|
||||||
|
|
||||||
|
The application automatically creates all tables and seeds the admin user on first startup. You can also trigger it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/it-ticket-system
|
||||||
|
sudo -u it-tickets venv/bin/python -c "
|
||||||
|
from app import create_app, db
|
||||||
|
app = create_app('production')
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
print('Database tables created successfully.')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Set File Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application files: readable by the app user
|
||||||
|
sudo find /var/www/it-ticket-system -type f -exec chmod 644 {} \;
|
||||||
|
sudo find /var/www/it-ticket-system -type d -exec chmod 755 {} \;
|
||||||
|
sudo chmod 600 /var/www/it-ticket-system/.env
|
||||||
|
sudo chown it-tickets:it-tickets /var/www/it-ticket-system/.env
|
||||||
|
|
||||||
|
# Virtual environment executables
|
||||||
|
sudo chmod +x /var/www/it-ticket-system/venv/bin/gunicorn
|
||||||
|
sudo chmod +x /var/www/it-ticket-system/venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gunicorn Configuration
|
||||||
|
|
||||||
|
The `gunicorn.conf.py` file at the project root controls the WSGI server.
|
||||||
|
|
||||||
|
**Key settings explained:**
|
||||||
|
|
||||||
|
| Setting | Value | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `worker_class` | `eventlet` | Required for Flask-SocketIO WebSocket support |
|
||||||
|
| `workers` | `1` | eventlet mandates a single worker |
|
||||||
|
| `worker_connections` | `1000` | Max concurrent connections per worker |
|
||||||
|
| `timeout` | `120` | Seconds before killing an unresponsive worker |
|
||||||
|
| `bind` | `127.0.0.1:5000` | Internal only — nginx proxies publicly |
|
||||||
|
|
||||||
|
**Test Gunicorn manually before enabling the service:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/it-ticket-system
|
||||||
|
sudo -u it-tickets venv/bin/gunicorn --config gunicorn.conf.py run:app
|
||||||
|
|
||||||
|
# You should see: [INFO] Listening at: http://127.0.0.1:5000
|
||||||
|
# Press Ctrl+C to stop.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Systemd Service
|
||||||
|
|
||||||
|
### 7.1 Install the Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy unit file to systemd
|
||||||
|
sudo cp /var/www/it-ticket-system/it-ticket-system.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# Edit to confirm paths and user match your setup
|
||||||
|
sudo nano /etc/systemd/system/it-ticket-system.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update these lines in the service file if needed:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
User=it-tickets
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/it-ticket-system
|
||||||
|
EnvironmentFile=/var/www/it-ticket-system/.env
|
||||||
|
ExecStart=/var/www/it-ticket-system/venv/bin/gunicorn \
|
||||||
|
--config gunicorn.conf.py \
|
||||||
|
run:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Enable and Start the Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable it-ticket-system
|
||||||
|
sudo systemctl start it-ticket-system
|
||||||
|
|
||||||
|
# Verify it is running
|
||||||
|
sudo systemctl status it-ticket-system
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Useful Service Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View live logs
|
||||||
|
sudo journalctl -u it-ticket-system -f
|
||||||
|
|
||||||
|
# Restart after code changes
|
||||||
|
sudo systemctl restart it-ticket-system
|
||||||
|
|
||||||
|
# Stop the service
|
||||||
|
sudo systemctl stop it-ticket-system
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Nginx Configuration
|
||||||
|
|
||||||
|
### 8.1 Install Site Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy nginx config
|
||||||
|
sudo cp /var/www/it-ticket-system/nginx.conf /etc/nginx/sites-available/it-ticket-system
|
||||||
|
|
||||||
|
# Edit: replace tickets.yourdomain.com with your actual domain
|
||||||
|
sudo nano /etc/nginx/sites-available/it-ticket-system
|
||||||
|
|
||||||
|
# Enable the site
|
||||||
|
sudo ln -s /etc/nginx/sites-available/it-ticket-system /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# Remove default nginx site (optional)
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Test configuration syntax
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# Reload nginx
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Nginx Tuning (Optional)
|
||||||
|
|
||||||
|
Add to `/etc/nginx/nginx.conf` inside the `http {}` block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Increase for large file uploads
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=app:10m rate=30r/m;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SSL Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtain Let's Encrypt certificate
|
||||||
|
sudo certbot --nginx -d tickets.yourdomain.com
|
||||||
|
|
||||||
|
# Certbot will automatically update your nginx config with SSL settings.
|
||||||
|
# Verify auto-renewal:
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
|
||||||
|
# The nginx.conf provided already has the SSL block commented out.
|
||||||
|
# After certbot runs, it will populate the ssl_certificate paths automatically.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. First Login & Admin Setup
|
||||||
|
|
||||||
|
1. Open your browser: `https://tickets.yourdomain.com`
|
||||||
|
2. Log in with the credentials you set in `.env`:
|
||||||
|
- **Email:** `admin@yourdomain.com`
|
||||||
|
- **Password:** `YourStrongAdminPassword!`
|
||||||
|
3. **Change the admin password immediately** via Profile → Change Password.
|
||||||
|
4. Navigate to **Admin → Users** to create IT Staff accounts.
|
||||||
|
5. Set their role to `it_staff` or `admin`.
|
||||||
|
|
||||||
|
### Default Roles
|
||||||
|
|
||||||
|
| Role | Capabilities |
|
||||||
|
|---|---|
|
||||||
|
| `employee` | Create tickets, add comments, view own tickets, use chatbot, read KB |
|
||||||
|
| `it_staff` | All employee permissions + view/update all tickets, assign tickets, internal notes, manage KB |
|
||||||
|
| `admin` | All IT staff permissions + manage users, view full activity logs, deactivate accounts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. AI Chatbot Configuration
|
||||||
|
|
||||||
|
The chatbot uses the **Anthropic Claude API** (`claude-sonnet-4-20250514`).
|
||||||
|
|
||||||
|
1. Obtain an API key from [console.anthropic.com](https://console.anthropic.com)
|
||||||
|
2. Add to `.env`:
|
||||||
|
```ini
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-api03-...
|
||||||
|
```
|
||||||
|
3. Restart the service: `sudo systemctl restart it-ticket-system`
|
||||||
|
|
||||||
|
**Chatbot capabilities:**
|
||||||
|
- Conversationally gathers issue details from employees
|
||||||
|
- Automatically creates tickets with appropriate category and priority
|
||||||
|
- Notifies IT staff immediately upon ticket creation
|
||||||
|
- Answers general IT questions without creating tickets
|
||||||
|
- Detects critical/urgent language and escalates priority accordingly
|
||||||
|
|
||||||
|
If `ANTHROPIC_API_KEY` is not set, the chatbot gracefully informs users to contact IT directly — **the rest of the application continues to function normally.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Email Configuration
|
||||||
|
|
||||||
|
### Gmail / Google Workspace
|
||||||
|
|
||||||
|
```ini
|
||||||
|
MAIL_SERVER=smtp.gmail.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USERNAME=ittickets@yourdomain.com
|
||||||
|
MAIL_PASSWORD=your-app-password # Use App Password, not account password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Microsoft 365 / Outlook
|
||||||
|
|
||||||
|
```ini
|
||||||
|
MAIL_SERVER=smtp.office365.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=True
|
||||||
|
MAIL_USERNAME=ittickets@yourdomain.com
|
||||||
|
MAIL_PASSWORD=your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Postfix (no external relay)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
MAIL_SERVER=localhost
|
||||||
|
MAIL_PORT=25
|
||||||
|
MAIL_USE_TLS=False
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Email notifications are sent for:**
|
||||||
|
- New ticket created → IT Department email + all IT staff (in-app)
|
||||||
|
- Ticket status changed → Ticket creator
|
||||||
|
- Ticket assigned → Assigned IT staff member
|
||||||
|
- New comment added → Ticket creator + assignee
|
||||||
|
|
||||||
|
If email fails (wrong credentials, network issue), errors are **logged silently** — the ticket operation still succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Code Change Map
|
||||||
|
|
||||||
|
This section documents every file and what was added/changed, as a reference for future modifications.
|
||||||
|
|
||||||
|
### New Files (All files are new — this is a greenfield application)
|
||||||
|
|
||||||
|
| File | Purpose | Key Functions/Routes |
|
||||||
|
|---|---|---|
|
||||||
|
| `run.py` | Entry point | Starts Flask + SocketIO |
|
||||||
|
| `config/config.py` | Environment config | `Config`, `DevelopmentConfig`, `ProductionConfig` |
|
||||||
|
| `app/__init__.py` | App factory | `create_app()`, `_seed_admin()` |
|
||||||
|
| `app/models.py` | Database models | `User`, `Ticket`, `Comment`, `Attachment`, `Notification`, `TicketHistory`, `ActivityLog`, `KnowledgeBase` |
|
||||||
|
| `app/routes/auth.py` | Authentication | `GET/POST /auth/login`, `GET/POST /auth/register`, `GET /auth/logout`, `GET/POST /auth/profile` |
|
||||||
|
| `app/routes/tickets.py` | Ticket management | `GET/POST /tickets/new`, `GET /tickets`, `GET/POST /tickets/<id>`, `POST /tickets/<id>/update`, `POST /comments/<id>/delete`, `GET /attachments/<id>`, `GET/POST /notifications`, `GET /kb`, `GET /kb/<id>` |
|
||||||
|
| `app/routes/admin.py` | Admin/IT views | `GET /admin/`, `GET /admin/tickets`, `GET /admin/users`, `GET/POST /admin/users/<id>/edit`, `POST /admin/users/<id>/delete`, KB CRUD, `GET /admin/logs` |
|
||||||
|
| `app/routes/api.py` | JSON API + WebSocket | `GET /api/notifications/unread-count`, `POST /api/notifications/<id>/read`, `POST /api/notifications/mark-all-read`, `GET /api/stats/tickets`, Socket events: `connect`, `disconnect`, `join_ticket`, `leave_ticket` |
|
||||||
|
| `app/routes/chatbot.py` | AI assistant | `POST /chatbot/message` |
|
||||||
|
| `app/services/notification_service.py` | Notification delivery | `create_notification()`, `send_email()`, `notify_new_ticket()`, `notify_status_change()`, `notify_comment_added()`, `notify_assignment()` |
|
||||||
|
| `app/services/log_service.py` | Audit logging | `log_action()`, `log_ticket_history()` |
|
||||||
|
| `gunicorn.conf.py` | WSGI server config | eventlet worker, bind address, log paths |
|
||||||
|
| `it-ticket-system.service` | Systemd unit | Auto-start, restart-on-failure |
|
||||||
|
| `nginx.conf` | Reverse proxy | SSL termination, WebSocket upgrade, static file serving |
|
||||||
|
| `setup_db.sql` | DB initialisation | Creates database, user, grants |
|
||||||
|
|
||||||
|
### Where Logging (`log_action`) Is Called
|
||||||
|
|
||||||
|
| Event | File | Function | Log Action String |
|
||||||
|
|---|---|---|---|
|
||||||
|
| User login | `auth.py` | `login()` | `user_login` |
|
||||||
|
| User registration | `auth.py` | `register()` | `user_register` |
|
||||||
|
| User logout | `auth.py` | `logout()` | `user_logout` |
|
||||||
|
| Profile update | `auth.py` | `profile()` | `user_profile_update` |
|
||||||
|
| Ticket created | `tickets.py` | `create_ticket()` | `ticket_create` |
|
||||||
|
| Ticket updated | `tickets.py` | `update_ticket()` | `ticket_update` |
|
||||||
|
| Comment created | `tickets.py` | `ticket_detail()` | `comment_create` |
|
||||||
|
| Comment deleted | `tickets.py` | `delete_comment()` | `comment_delete` |
|
||||||
|
| Ticket created via chatbot | `chatbot.py` | `chat()` | `ticket_create_chatbot` |
|
||||||
|
| Admin edits user | `admin.py` | `edit_user()` | `admin_user_edit` |
|
||||||
|
| Admin deactivates user | `admin.py` | `delete_user()` | `admin_user_deactivate` |
|
||||||
|
| KB article created | `admin.py` | `kb_new()` | `kb_create` |
|
||||||
|
| KB article edited | `admin.py` | `kb_edit()` | `kb_edit` |
|
||||||
|
| KB article deleted | `admin.py` | `kb_delete()` | `kb_delete` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Features Overview
|
||||||
|
|
||||||
|
### Employee Features
|
||||||
|
- **Dashboard** — open/active/resolved ticket counts, recent tickets, top KB articles
|
||||||
|
- **Submit Ticket** — form with title, category, priority, location, asset tag, file attachments
|
||||||
|
- **AI Chatbot** — floating chat widget (🤖 button, bottom-right); conversationally creates tickets
|
||||||
|
- **Ticket Tracking** — full ticket detail with comment thread, change history, attachments
|
||||||
|
- **Notifications** — real-time bell (WebSocket) + notification centre page
|
||||||
|
- **Knowledge Base** — self-service articles before submitting a ticket
|
||||||
|
- **Profile** — update name/department/phone, toggle email/web notifications, change password
|
||||||
|
|
||||||
|
### IT Staff Features
|
||||||
|
- **IT Dashboard** — queue metrics (open/in-progress/pending/resolved), assigned tickets, recent activity
|
||||||
|
- **All Tickets View** — filterable by status, priority, assignment (mine/unassigned/all)
|
||||||
|
- **Ticket Update Panel** — change status, priority, assignee, due date, resolution notes, internal notes
|
||||||
|
- **Internal Notes** — IT-only comments not visible to the employee
|
||||||
|
- **Ticket History** — field-level change log on every ticket
|
||||||
|
- **Knowledge Base Management** — create, edit, publish/unpublish, delete articles
|
||||||
|
|
||||||
|
### Admin Features
|
||||||
|
- **User Management** — list all users, edit role/department/status, reset passwords, deactivate
|
||||||
|
- **Activity Log** — full audit trail of all create/update/delete actions with IP addresses
|
||||||
|
- **IT Overview** — aggregate stats across all tickets and users
|
||||||
|
|
||||||
|
### Technical Features
|
||||||
|
- **Real-time WebSocket** — notifications pushed instantly via Flask-SocketIO / eventlet
|
||||||
|
- **Email Notifications** — HTML emails for new tickets, status changes, comments, assignments
|
||||||
|
- **File Attachments** — on tickets and comments; UUID-stored filenames prevent collisions
|
||||||
|
- **Ticket Numbering** — sequential daily format: `TKT-20240315-0001`
|
||||||
|
- **Audit Trail** — every significant action persisted to `activity_logs` + `ticket_history`
|
||||||
|
- **AI Ticket Creation** — chatbot flags `ai_generated=True` on auto-created tickets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Maintenance & Operations
|
||||||
|
|
||||||
|
### Application Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application logs (Flask)
|
||||||
|
tail -f /var/www/it-ticket-system/logs/it_tickets.log
|
||||||
|
|
||||||
|
# Gunicorn access log
|
||||||
|
tail -f /var/www/it-ticket-system/logs/gunicorn_access.log
|
||||||
|
|
||||||
|
# Gunicorn error log
|
||||||
|
tail -f /var/www/it-ticket-system/logs/gunicorn_error.log
|
||||||
|
|
||||||
|
# Systemd journal
|
||||||
|
sudo journalctl -u it-ticket-system -f --since "1 hour ago"
|
||||||
|
|
||||||
|
# Nginx logs
|
||||||
|
sudo tail -f /var/log/nginx/it_tickets_access.log
|
||||||
|
sudo tail -f /var/log/nginx/it_tickets_error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploying Code Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/it-ticket-system
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
sudo -u it-tickets git pull origin main
|
||||||
|
|
||||||
|
# Install any new dependencies
|
||||||
|
sudo -u it-tickets venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Apply database migrations (if you add Flask-Migrate)
|
||||||
|
# sudo -u it-tickets venv/bin/flask db upgrade
|
||||||
|
|
||||||
|
# Restart the service
|
||||||
|
sudo systemctl restart it-ticket-system
|
||||||
|
sudo systemctl status it-ticket-system
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full backup
|
||||||
|
mysqldump -u it_tickets_user -p it_tickets > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Automated daily backup via cron (add to root's crontab):
|
||||||
|
# 0 2 * * * mysqldump -u it_tickets_user -pPASSWORD it_tickets | gzip > /backups/it_tickets_$(date +\%Y\%m\%d).sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firewall Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 22/tcp # SSH
|
||||||
|
sudo ufw allow 80/tcp # HTTP (redirects to HTTPS)
|
||||||
|
sudo ufw allow 443/tcp # HTTPS
|
||||||
|
sudo ufw deny 5000/tcp # Block direct Gunicorn access
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting Quick Reference
|
||||||
|
|
||||||
|
| Symptom | Likely Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| 502 Bad Gateway | Gunicorn not running | `sudo systemctl restart it-ticket-system` |
|
||||||
|
| WebSocket not connecting | nginx missing upgrade headers | Verify `/socket.io/` location block in nginx.conf |
|
||||||
|
| Emails not sending | SMTP credentials | Check `.env` MAIL_* values; test with `flask shell` |
|
||||||
|
| Chatbot not responding | Missing API key | Set `ANTHROPIC_API_KEY` in `.env` and restart |
|
||||||
|
| File uploads failing | Wrong permissions on uploads dir | `sudo chown -R it-tickets:www-data app/static/uploads` |
|
||||||
|
| DB connection error | Wrong credentials | Verify `DB_*` values in `.env` match `setup_db.sql` |
|
||||||
|
| Static files 404 | Wrong nginx alias | Check `alias` path in nginx `/static/` block |
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_mail import Mail
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
from config.config import config
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
login_manager= LoginManager()
|
||||||
|
mail = Mail()
|
||||||
|
migrate = Migrate()
|
||||||
|
socketio = SocketIO()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_name=None):
|
||||||
|
if config_name is None:
|
||||||
|
config_name = os.environ.get('FLASK_ENV', 'production')
|
||||||
|
|
||||||
|
app = Flask(__name__, template_folder='templates', static_folder='static')
|
||||||
|
app.config.from_object(config.get(config_name, config['default']))
|
||||||
|
|
||||||
|
# ── ProxyFix: trust one nginx proxy hop for real client IPs ───────────────
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
|
||||||
|
# ── Extensions ────────────────────────────────────────────────────────────
|
||||||
|
db.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
mail.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
socketio.init_app(app, async_mode='eventlet', cors_allowed_origins='*')
|
||||||
|
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
|
login_manager.login_message_category = 'info'
|
||||||
|
|
||||||
|
# ── Upload directory ──────────────────────────────────────────────────────
|
||||||
|
upload_dir = app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
if not app.debug:
|
||||||
|
os.makedirs('logs', exist_ok=True)
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
'logs/it_tickets.log', maxBytes=10_485_760, backupCount=10
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||||
|
))
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
app.logger.info('IT Ticket System startup')
|
||||||
|
|
||||||
|
# ── Blueprints ────────────────────────────────────────────────────────────
|
||||||
|
from app.routes.auth import auth_bp
|
||||||
|
from app.routes.tickets import tickets_bp
|
||||||
|
from app.routes.admin import admin_bp
|
||||||
|
from app.routes.api import api_bp
|
||||||
|
from app.routes.chatbot import chatbot_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(tickets_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(chatbot_bp)
|
||||||
|
|
||||||
|
# ── User loader ───────────────────────────────────────────────────────────
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
# ── Context processors ────────────────────────────────────────────────────
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
from flask_login import current_user
|
||||||
|
unread = 0
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
from app.models import Notification
|
||||||
|
unread = Notification.query.filter_by(
|
||||||
|
user_id=current_user.id, is_read=False
|
||||||
|
).count()
|
||||||
|
return dict(unread_notifications=unread)
|
||||||
|
|
||||||
|
# ── DB initialisation (first run) ─────────────────────────────────────────
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
_seed_admin(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_admin(app):
|
||||||
|
"""Create the default admin account if none exists."""
|
||||||
|
from app.models import User, UserRole
|
||||||
|
if User.query.filter_by(role=UserRole.ADMIN).first():
|
||||||
|
return
|
||||||
|
admin = User(
|
||||||
|
email = app.config['ADMIN_EMAIL'],
|
||||||
|
username = 'admin',
|
||||||
|
full_name = 'System Administrator',
|
||||||
|
role = UserRole.ADMIN,
|
||||||
|
department= 'IT',
|
||||||
|
is_active = True,
|
||||||
|
)
|
||||||
|
admin.set_password(app.config['ADMIN_PASSWORD'])
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
app.logger.info(f'[SEED] Default admin account created: {admin.email}')
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Enumerations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserRole:
|
||||||
|
EMPLOYEE = 'employee'
|
||||||
|
IT_STAFF = 'it_staff'
|
||||||
|
ADMIN = 'admin'
|
||||||
|
|
||||||
|
class TicketStatus:
|
||||||
|
OPEN = 'open'
|
||||||
|
IN_PROGRESS = 'in_progress'
|
||||||
|
PENDING = 'pending'
|
||||||
|
RESOLVED = 'resolved'
|
||||||
|
CLOSED = 'closed'
|
||||||
|
|
||||||
|
class TicketPriority:
|
||||||
|
LOW = 'low'
|
||||||
|
MEDIUM = 'medium'
|
||||||
|
HIGH = 'high'
|
||||||
|
CRITICAL = 'critical'
|
||||||
|
|
||||||
|
class TicketCategory:
|
||||||
|
HARDWARE = 'hardware'
|
||||||
|
SOFTWARE = 'software'
|
||||||
|
NETWORK = 'network'
|
||||||
|
ACCESS = 'access'
|
||||||
|
EMAIL = 'email'
|
||||||
|
PRINTER = 'printer'
|
||||||
|
PHONE = 'phone'
|
||||||
|
SECURITY = 'security'
|
||||||
|
OTHER = 'other'
|
||||||
|
|
||||||
|
class NotificationType:
|
||||||
|
TICKET_CREATED = 'ticket_created'
|
||||||
|
TICKET_UPDATED = 'ticket_updated'
|
||||||
|
TICKET_ASSIGNED = 'ticket_assigned'
|
||||||
|
COMMENT_ADDED = 'comment_added'
|
||||||
|
STATUS_CHANGED = 'status_changed'
|
||||||
|
TICKET_RESOLVED = 'ticket_resolved'
|
||||||
|
TICKET_CLOSED = 'ticket_closed'
|
||||||
|
MENTION = 'mention'
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Models ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
full_name = db.Column(db.String(128), nullable=False)
|
||||||
|
password_hash= db.Column(db.String(256), nullable=False)
|
||||||
|
role = db.Column(db.String(20), default=UserRole.EMPLOYEE, nullable=False)
|
||||||
|
department = db.Column(db.String(100))
|
||||||
|
phone = db.Column(db.String(20))
|
||||||
|
avatar_url = db.Column(db.String(256))
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
email_notif = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
web_notif = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_tickets = db.relationship('Ticket', foreign_keys='Ticket.created_by_id', backref='creator', lazy='dynamic')
|
||||||
|
assigned_tickets = db.relationship('Ticket', foreign_keys='Ticket.assigned_to_id', backref='assignee', lazy='dynamic')
|
||||||
|
comments = db.relationship('Comment', backref='author', lazy='dynamic')
|
||||||
|
notifications = db.relationship('Notification', backref='recipient', lazy='dynamic')
|
||||||
|
activity_logs = db.relationship('ActivityLog', backref='user', lazy='dynamic')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_it_staff(self):
|
||||||
|
return self.role in (UserRole.IT_STAFF, UserRole.ADMIN)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
return self.role == UserRole.ADMIN
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.username}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(db.Model):
|
||||||
|
__tablename__ = 'tickets'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_number = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=False)
|
||||||
|
category = db.Column(db.String(50), default=TicketCategory.OTHER, nullable=False)
|
||||||
|
priority = db.Column(db.String(20), default=TicketPriority.MEDIUM, nullable=False)
|
||||||
|
status = db.Column(db.String(20), default=TicketStatus.OPEN, nullable=False, index=True)
|
||||||
|
location = db.Column(db.String(100))
|
||||||
|
asset_tag = db.Column(db.String(50))
|
||||||
|
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
assigned_to_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
resolved_at = db.Column(db.DateTime)
|
||||||
|
closed_at = db.Column(db.DateTime)
|
||||||
|
due_date = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
ai_generated = db.Column(db.Boolean, default=False)
|
||||||
|
internal_notes = db.Column(db.Text)
|
||||||
|
resolution_notes = db.Column(db.Text)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
comments = db.relationship('Comment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
attachments = db.relationship('Attachment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
notifications= db.relationship('Notification', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
history = db.relationship('TicketHistory', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def generate_ticket_number(self):
|
||||||
|
"""Generate unique ticket number like TKT-20240101-0001"""
|
||||||
|
date_str = datetime.utcnow().strftime('%Y%m%d')
|
||||||
|
last = Ticket.query.filter(
|
||||||
|
Ticket.ticket_number.like(f'TKT-{date_str}-%')
|
||||||
|
).order_by(Ticket.id.desc()).first()
|
||||||
|
if last:
|
||||||
|
seq = int(last.ticket_number.split('-')[-1]) + 1
|
||||||
|
else:
|
||||||
|
seq = 1
|
||||||
|
return f'TKT-{date_str}-{seq:04d}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def priority_color(self):
|
||||||
|
return {
|
||||||
|
TicketPriority.LOW: 'success',
|
||||||
|
TicketPriority.MEDIUM: 'warning',
|
||||||
|
TicketPriority.HIGH: 'danger',
|
||||||
|
TicketPriority.CRITICAL: 'dark',
|
||||||
|
}.get(self.priority, 'secondary')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_color(self):
|
||||||
|
return {
|
||||||
|
TicketStatus.OPEN: 'primary',
|
||||||
|
TicketStatus.IN_PROGRESS: 'info',
|
||||||
|
TicketStatus.PENDING: 'warning',
|
||||||
|
TicketStatus.RESOLVED: 'success',
|
||||||
|
TicketStatus.CLOSED: 'secondary',
|
||||||
|
}.get(self.status, 'secondary')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Ticket {self.ticket_number}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(db.Model):
|
||||||
|
__tablename__ = 'comments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False)
|
||||||
|
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
body = db.Column(db.Text, nullable=False)
|
||||||
|
is_internal = db.Column(db.Boolean, default=False) # IT-only internal note
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
attachments = db.relationship('Attachment', backref='comment', lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Comment {self.id} on Ticket {self.ticket_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(db.Model):
|
||||||
|
__tablename__ = 'attachments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'))
|
||||||
|
comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'))
|
||||||
|
filename = db.Column(db.String(256), nullable=False)
|
||||||
|
stored_name = db.Column(db.String(256), nullable=False)
|
||||||
|
file_size = db.Column(db.Integer)
|
||||||
|
mime_type = db.Column(db.String(100))
|
||||||
|
uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
uploader = db.relationship('User', foreign_keys=[uploaded_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Attachment {self.filename}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(db.Model):
|
||||||
|
__tablename__ = 'notifications'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'))
|
||||||
|
type = db.Column(db.String(50), nullable=False)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
message = db.Column(db.Text)
|
||||||
|
is_read = db.Column(db.Boolean, default=False, index=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||||
|
link = db.Column(db.String(256))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Notification {self.id} for User {self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class TicketHistory(db.Model):
|
||||||
|
__tablename__ = 'ticket_history'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False)
|
||||||
|
changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
field_name = db.Column(db.String(50), nullable=False)
|
||||||
|
old_value = db.Column(db.String(200))
|
||||||
|
new_value = db.Column(db.String(200))
|
||||||
|
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
changer = db.relationship('User', foreign_keys=[changed_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TicketHistory {self.id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLog(db.Model):
|
||||||
|
__tablename__ = 'activity_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
action = db.Column(db.String(100), nullable=False)
|
||||||
|
entity_type = db.Column(db.String(50))
|
||||||
|
entity_id = db.Column(db.Integer)
|
||||||
|
details = db.Column(db.Text)
|
||||||
|
ip_address = db.Column(db.String(45))
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<ActivityLog {self.action} by User {self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase(db.Model):
|
||||||
|
__tablename__ = 'knowledge_base'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
body = db.Column(db.Text, nullable=False)
|
||||||
|
category = db.Column(db.String(50))
|
||||||
|
tags = db.Column(db.String(256))
|
||||||
|
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||||
|
is_published= db.Column(db.Boolean, default=True)
|
||||||
|
view_count = db.Column(db.Integer, default=0)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
author = db.relationship('User', foreign_keys=[author_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<KnowledgeBase {self.title}>'
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
||||||
|
UserRole, TicketStatus)
|
||||||
|
from app.services.log_service import log_action
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or not current_user.is_admin:
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def it_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or not current_user.is_it_staff:
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Admin Dashboard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def index():
|
||||||
|
stats = {
|
||||||
|
'total_tickets' : Ticket.query.count(),
|
||||||
|
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
|
||||||
|
'in_progress' : Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count(),
|
||||||
|
'resolved' : Ticket.query.filter_by(status=TicketStatus.RESOLVED).count(),
|
||||||
|
'closed' : Ticket.query.filter_by(status=TicketStatus.CLOSED).count(),
|
||||||
|
'total_users' : User.query.filter_by(is_active=True).count(),
|
||||||
|
'employees' : User.query.filter_by(role=UserRole.EMPLOYEE, is_active=True).count(),
|
||||||
|
'it_staff' : User.query.filter(
|
||||||
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
|
User.is_active == True).count(),
|
||||||
|
}
|
||||||
|
recent_logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).limit(20).all()
|
||||||
|
return render_template('admin/index.html', stats=stats, recent_logs=recent_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── User Management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/users')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def users():
|
||||||
|
all_users = User.query.order_by(User.created_at.desc()).all()
|
||||||
|
return render_template('admin/users.html', users=all_users)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_user():
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
full_name = request.form.get('full_name', '').strip()
|
||||||
|
department = request.form.get('department', '').strip()
|
||||||
|
phone = request.form.get('phone', '').strip()
|
||||||
|
role = request.form.get('role', UserRole.EMPLOYEE)
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
confirm = request.form.get('confirm_password', '')
|
||||||
|
is_active = bool(request.form.get('is_active'))
|
||||||
|
|
||||||
|
# ── Validation ────────────────────────────────────────────────────────
|
||||||
|
if not email or not username or not full_name or not password:
|
||||||
|
flash('Email, username, full name and password are all required.', 'danger')
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
|
if User.query.filter_by(email=email).first():
|
||||||
|
flash('That email address is already registered.', 'danger')
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
flash('That username is already taken.', 'danger')
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
|
if password != confirm:
|
||||||
|
flash('Passwords do not match.', 'danger')
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
flash('Password must be at least 8 characters.', 'danger')
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form=request.form)
|
||||||
|
|
||||||
|
# ── Create ────────────────────────────────────────────────────────────
|
||||||
|
user = User(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
full_name = full_name,
|
||||||
|
department = department,
|
||||||
|
phone = phone,
|
||||||
|
role = role,
|
||||||
|
is_active = is_active,
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(current_user.id, 'admin_user_create', 'user', user.id,
|
||||||
|
f'email={email} role={role}')
|
||||||
|
logger.info(f'[ADMIN USER CREATE] user_id={user.id} email={email} role={role} by admin_id={current_user.id}')
|
||||||
|
flash(f'User {full_name} ({email}) created successfully.', 'success')
|
||||||
|
return redirect(url_for('admin.users'))
|
||||||
|
|
||||||
|
return render_template('admin/create_user.html', roles=_roles(), form={})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def edit_user(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
old_role = user.role
|
||||||
|
user.full_name = request.form.get('full_name', user.full_name).strip()
|
||||||
|
user.department= request.form.get('department', user.department).strip()
|
||||||
|
user.phone = request.form.get('phone', user.phone or '').strip()
|
||||||
|
user.role = request.form.get('role', user.role)
|
||||||
|
user.is_active = bool(request.form.get('is_active'))
|
||||||
|
new_pw = request.form.get('new_password', '')
|
||||||
|
if new_pw:
|
||||||
|
user.set_password(new_pw)
|
||||||
|
logger.info(f'[ADMIN PASSWORD RESET] target_user_id={user.id} by admin_id={current_user.id}')
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'admin_user_edit', 'user', user.id,
|
||||||
|
f'role_change={old_role}->{user.role} active={user.is_active}')
|
||||||
|
logger.info(f'[ADMIN USER EDIT] user_id={user.id} by admin_id={current_user.id}')
|
||||||
|
flash('User updated.', 'success')
|
||||||
|
return redirect(url_for('admin.users'))
|
||||||
|
return render_template('admin/edit_user.html', user=user, roles=_roles())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
if user.id == current_user.id:
|
||||||
|
flash('You cannot delete your own account.', 'danger')
|
||||||
|
return redirect(url_for('admin.users'))
|
||||||
|
user.is_active = False
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'admin_user_deactivate', 'user', user.id)
|
||||||
|
logger.info(f'[ADMIN USER DEACTIVATE] user_id={user.id} by admin_id={current_user.id}')
|
||||||
|
flash('User deactivated.', 'success')
|
||||||
|
return redirect(url_for('admin.users'))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Management (IT) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/tickets')
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def all_tickets():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
status = request.args.get('status', '')
|
||||||
|
priority = request.args.get('priority', '')
|
||||||
|
assigned = request.args.get('assigned', '')
|
||||||
|
|
||||||
|
q = Ticket.query
|
||||||
|
if status: q = q.filter_by(status=status)
|
||||||
|
if priority: q = q.filter_by(priority=priority)
|
||||||
|
if assigned == 'me': q = q.filter_by(assigned_to_id=current_user.id)
|
||||||
|
elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None)
|
||||||
|
|
||||||
|
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
|
||||||
|
return render_template('admin/tickets.html', tickets=tickets,
|
||||||
|
status=status, priority=priority, assigned=assigned)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Knowledge Base Management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/kb')
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def kb_list():
|
||||||
|
articles = KnowledgeBase.query.order_by(KnowledgeBase.created_at.desc()).all()
|
||||||
|
return render_template('admin/kb_list.html', articles=articles)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/kb/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def kb_new():
|
||||||
|
if request.method == 'POST':
|
||||||
|
article = KnowledgeBase(
|
||||||
|
title = request.form.get('title', '').strip(),
|
||||||
|
body = request.form.get('body', '').strip(),
|
||||||
|
category = request.form.get('category', ''),
|
||||||
|
tags = request.form.get('tags', ''),
|
||||||
|
author_id = current_user.id,
|
||||||
|
is_published= bool(request.form.get('is_published')),
|
||||||
|
)
|
||||||
|
db.session.add(article)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'kb_create', 'knowledge_base', article.id,
|
||||||
|
f'title={article.title}')
|
||||||
|
logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}')
|
||||||
|
flash('Article created.', 'success')
|
||||||
|
return redirect(url_for('admin.kb_list'))
|
||||||
|
return render_template('admin/kb_edit.html', article=None)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/kb/<int:article_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def kb_edit(article_id):
|
||||||
|
article = KnowledgeBase.query.get_or_404(article_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
article.title = request.form.get('title', article.title).strip()
|
||||||
|
article.body = request.form.get('body', article.body).strip()
|
||||||
|
article.category = request.form.get('category', article.category)
|
||||||
|
article.tags = request.form.get('tags', article.tags)
|
||||||
|
article.is_published= bool(request.form.get('is_published'))
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id)
|
||||||
|
logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}')
|
||||||
|
flash('Article updated.', 'success')
|
||||||
|
return redirect(url_for('admin.kb_list'))
|
||||||
|
return render_template('admin/kb_edit.html', article=article)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/kb/<int:article_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def kb_delete(article_id):
|
||||||
|
article = KnowledgeBase.query.get_or_404(article_id)
|
||||||
|
log_action(current_user.id, 'kb_delete', 'knowledge_base', article.id,
|
||||||
|
f'title={article.title}')
|
||||||
|
logger.info(f'[KB DELETE] article_id={article.id} by user_id={current_user.id}')
|
||||||
|
db.session.delete(article)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Article deleted.', 'success')
|
||||||
|
return redirect(url_for('admin.kb_list'))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Activity Log ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/logs')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def activity_logs():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
logs = ActivityLog.query.order_by(ActivityLog.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=50)
|
||||||
|
return render_template('admin/activity_logs.html', logs=logs)
|
||||||
|
|
||||||
|
|
||||||
|
def _roles():
|
||||||
|
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from flask_socketio import emit, join_room, leave_room
|
||||||
|
from app import db, socketio
|
||||||
|
from app.models import Notification, Ticket, TicketStatus, TicketPriority
|
||||||
|
from app.services.log_service import log_action
|
||||||
|
|
||||||
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Notifications API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/notifications/unread-count')
|
||||||
|
@login_required
|
||||||
|
def unread_count():
|
||||||
|
count = Notification.query.filter_by(user_id=current_user.id, is_read=False).count()
|
||||||
|
return jsonify({'count': count})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/notifications/<int:notif_id>/read', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mark_read(notif_id):
|
||||||
|
notif = Notification.query.filter_by(id=notif_id, user_id=current_user.id).first_or_404()
|
||||||
|
notif.is_read = True
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/notifications/mark-all-read', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mark_all_read():
|
||||||
|
Notification.query.filter_by(user_id=current_user.id, is_read=False).update({'is_read': True})
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Stats API (IT) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/stats/tickets')
|
||||||
|
@login_required
|
||||||
|
def ticket_stats():
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
stats = {
|
||||||
|
'open' : Ticket.query.filter_by(status=TicketStatus.OPEN).count(),
|
||||||
|
'in_progress' : Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count(),
|
||||||
|
'pending' : Ticket.query.filter_by(status=TicketStatus.PENDING).count(),
|
||||||
|
'resolved' : Ticket.query.filter_by(status=TicketStatus.RESOLVED).count(),
|
||||||
|
'closed' : Ticket.query.filter_by(status=TicketStatus.CLOSED).count(),
|
||||||
|
}
|
||||||
|
return jsonify(stats)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── WebSocket Events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@socketio.on('connect')
|
||||||
|
def on_connect():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
join_room(f'user_{current_user.id}')
|
||||||
|
logger.info(f'[SOCKET CONNECT] user_id={current_user.id}')
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('disconnect')
|
||||||
|
def on_disconnect():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
leave_room(f'user_{current_user.id}')
|
||||||
|
logger.info(f'[SOCKET DISCONNECT] user_id={current_user.id}')
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('join_ticket')
|
||||||
|
def on_join_ticket(data):
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
ticket_id = data.get('ticket_id')
|
||||||
|
join_room(f'ticket_{ticket_id}')
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('leave_ticket')
|
||||||
|
def on_leave_ticket(data):
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
ticket_id = data.get('ticket_id')
|
||||||
|
leave_room(f'ticket_{ticket_id}')
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.services.log_service import log_action
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('tickets.dashboard'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
remember = bool(request.form.get('remember'))
|
||||||
|
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
if user and user.check_password(password) and user.is_active:
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
log_action(user.id, 'user_login', 'user', user.id, f'email={email}')
|
||||||
|
logger.info(f'[AUTH LOGIN] user_id={user.id} email={email}')
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
return redirect(next_page or url_for('tickets.dashboard'))
|
||||||
|
else:
|
||||||
|
logger.warning(f'[AUTH FAILED] email={email} ip={request.remote_addr}')
|
||||||
|
flash('Invalid credentials or account disabled.', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('tickets.dashboard'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
full_name = request.form.get('full_name', '').strip()
|
||||||
|
department = request.form.get('department', '').strip()
|
||||||
|
phone = request.form.get('phone', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
confirm = request.form.get('confirm_password', '')
|
||||||
|
|
||||||
|
if User.query.filter_by(email=email).first():
|
||||||
|
flash('Email already registered.', 'danger')
|
||||||
|
elif User.query.filter_by(username=username).first():
|
||||||
|
flash('Username already taken.', 'danger')
|
||||||
|
elif password != confirm:
|
||||||
|
flash('Passwords do not match.', 'danger')
|
||||||
|
elif len(password) < 8:
|
||||||
|
flash('Password must be at least 8 characters.', 'danger')
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
email = email,
|
||||||
|
username = username,
|
||||||
|
full_name = full_name,
|
||||||
|
department = department,
|
||||||
|
phone = phone,
|
||||||
|
role = UserRole.EMPLOYEE,
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
|
||||||
|
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
|
||||||
|
flash('Account created! You may now log in.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('auth/register.html')
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
log_action(current_user.id, 'user_logout', 'user', current_user.id)
|
||||||
|
logger.info(f'[AUTH LOGOUT] user_id={current_user.id}')
|
||||||
|
logout_user()
|
||||||
|
flash('You have been logged out.', 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
if request.method == 'POST':
|
||||||
|
full_name = request.form.get('full_name', '').strip()
|
||||||
|
department = request.form.get('department', '').strip()
|
||||||
|
phone = request.form.get('phone', '').strip()
|
||||||
|
email_notif= bool(request.form.get('email_notif'))
|
||||||
|
web_notif = bool(request.form.get('web_notif'))
|
||||||
|
new_pw = request.form.get('new_password', '')
|
||||||
|
confirm_pw = request.form.get('confirm_password', '')
|
||||||
|
|
||||||
|
current_user.full_name = full_name
|
||||||
|
current_user.department = department
|
||||||
|
current_user.phone = phone
|
||||||
|
current_user.email_notif= email_notif
|
||||||
|
current_user.web_notif = web_notif
|
||||||
|
|
||||||
|
if new_pw:
|
||||||
|
if new_pw != confirm_pw:
|
||||||
|
flash('Passwords do not match.', 'danger')
|
||||||
|
return render_template('auth/profile.html')
|
||||||
|
if len(new_pw) < 8:
|
||||||
|
flash('Password must be at least 8 characters.', 'danger')
|
||||||
|
return render_template('auth/profile.html')
|
||||||
|
current_user.set_password(new_pw)
|
||||||
|
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'user_profile_update', 'user', current_user.id)
|
||||||
|
logger.info(f'[AUTH PROFILE UPDATE] user_id={current_user.id}')
|
||||||
|
flash('Profile updated successfully.', 'success')
|
||||||
|
|
||||||
|
return render_template('auth/profile.html')
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app import db
|
||||||
|
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory
|
||||||
|
from app.services.notification_service import notify_new_ticket
|
||||||
|
from app.services.log_service import log_action
|
||||||
|
|
||||||
|
chatbot_bp = Blueprint('chatbot', __name__, url_prefix='/chatbot')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """You are an IT Helpdesk Assistant for an internal IT ticket system.
|
||||||
|
Your job is to:
|
||||||
|
1. Help employees report IT issues conversationally.
|
||||||
|
2. Gather all required information to create a support ticket:
|
||||||
|
- Issue title (short summary)
|
||||||
|
- Detailed description
|
||||||
|
- Category (hardware, software, network, access, email, printer, phone, security, other)
|
||||||
|
- Priority (low, medium, high, critical)
|
||||||
|
- Location (optional)
|
||||||
|
- Asset tag (optional – device serial / asset number)
|
||||||
|
3. When you have enough information, respond with a JSON block like this (and ONLY this, no extra text):
|
||||||
|
{"action": "create_ticket", "title": "...", "description": "...", "category": "...", "priority": "...", "location": "...", "asset_tag": "..."}
|
||||||
|
4. For general IT questions, answer helpfully but briefly.
|
||||||
|
5. If the user seems frustrated or has a critical outage, set priority to "critical".
|
||||||
|
6. Keep your tone professional, friendly, and concise.
|
||||||
|
7. Always ask clarifying questions if you need more detail before creating a ticket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@chatbot_bp.route('/message', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def chat():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
history = data.get('history', []) # [{role, content}, ...]
|
||||||
|
user_msg = data.get('message', '').strip()
|
||||||
|
|
||||||
|
if not user_msg:
|
||||||
|
return jsonify({'error': 'Empty message'}), 400
|
||||||
|
|
||||||
|
api_key = current_app.config.get('ANTHROPIC_API_KEY', '')
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({'reply': "The AI assistant is not configured yet. Please contact your IT administrator.", 'ticket': None})
|
||||||
|
|
||||||
|
messages = history + [{'role': 'user', 'content': user_msg}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
'https://api.anthropic.com/v1/messages',
|
||||||
|
headers={
|
||||||
|
'x-api-key' : api_key,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'content-type' : 'application/json',
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
'model' : 'claude-sonnet-4-20250514',
|
||||||
|
'max_tokens': 1024,
|
||||||
|
'system' : _SYSTEM_PROMPT,
|
||||||
|
'messages' : messages,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
reply_text = resp.json()['content'][0]['text'].strip()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f'[CHATBOT API ERROR] {exc}')
|
||||||
|
return jsonify({'reply': 'Sorry, I encountered an error. Please try again or submit a ticket manually.', 'ticket': None})
|
||||||
|
|
||||||
|
# Check if the AI wants to create a ticket
|
||||||
|
ticket_data = None
|
||||||
|
if '"action": "create_ticket"' in reply_text or "'action': 'create_ticket'" in reply_text:
|
||||||
|
try:
|
||||||
|
start = reply_text.find('{')
|
||||||
|
end = reply_text.rfind('}') + 1
|
||||||
|
parsed = json.loads(reply_text[start:end])
|
||||||
|
if parsed.get('action') == 'create_ticket':
|
||||||
|
ticket = Ticket(
|
||||||
|
title = parsed.get('title', 'Untitled Issue'),
|
||||||
|
description = parsed.get('description', ''),
|
||||||
|
category = parsed.get('category', TicketCategory.OTHER),
|
||||||
|
priority = parsed.get('priority', TicketPriority.MEDIUM),
|
||||||
|
location = parsed.get('location', ''),
|
||||||
|
asset_tag = parsed.get('asset_tag', ''),
|
||||||
|
created_by_id = current_user.id,
|
||||||
|
status = TicketStatus.OPEN,
|
||||||
|
ai_generated = True,
|
||||||
|
)
|
||||||
|
ticket.ticket_number = ticket.generate_ticket_number()
|
||||||
|
db.session.add(ticket)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id,
|
||||||
|
f'ticket_number={ticket.ticket_number} ai_generated=True')
|
||||||
|
logger.info(f'[CHATBOT TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} user_id={current_user.id}')
|
||||||
|
notify_new_ticket(ticket)
|
||||||
|
|
||||||
|
ticket_data = {
|
||||||
|
'id' : ticket.id,
|
||||||
|
'ticket_number' : ticket.ticket_number,
|
||||||
|
'title' : ticket.title,
|
||||||
|
'url' : f'/tickets/{ticket.id}',
|
||||||
|
}
|
||||||
|
reply_text = (
|
||||||
|
f"✅ **Ticket Created!**\n\n"
|
||||||
|
f"I've submitted your ticket **{ticket.ticket_number}**: _{ticket.title}_\n\n"
|
||||||
|
f"Our IT team has been notified and will get back to you shortly. "
|
||||||
|
f"You can track your ticket [here](/tickets/{ticket.id})."
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError) as exc:
|
||||||
|
logger.warning(f'[CHATBOT PARSE ERROR] Could not parse ticket JSON: {exc}')
|
||||||
|
|
||||||
|
return jsonify({'reply': reply_text, 'ticket': ticket_data})
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, send_from_directory, abort)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from app import db
|
||||||
|
from app.models import (Ticket, Comment, Attachment, Notification,
|
||||||
|
TicketStatus, TicketPriority, TicketCategory,
|
||||||
|
User, UserRole, KnowledgeBase)
|
||||||
|
from app.services.notification_service import (
|
||||||
|
notify_new_ticket, notify_status_change,
|
||||||
|
notify_comment_added, notify_assignment,
|
||||||
|
)
|
||||||
|
from app.services.log_service import log_action, log_ticket_history
|
||||||
|
|
||||||
|
tickets_bp = Blueprint('tickets', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
|
||||||
|
|
||||||
|
|
||||||
|
def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
|
file.save(os.path.join(upload_dir, stored_name))
|
||||||
|
att = Attachment(
|
||||||
|
ticket_id = ticket_id,
|
||||||
|
comment_id = comment_id,
|
||||||
|
filename = filename,
|
||||||
|
stored_name= stored_name,
|
||||||
|
file_size = os.path.getsize(os.path.join(upload_dir, stored_name)),
|
||||||
|
mime_type = file.content_type,
|
||||||
|
uploaded_by= uploader_id,
|
||||||
|
)
|
||||||
|
db.session.add(att)
|
||||||
|
return att
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/')
|
||||||
|
@tickets_bp.route('/dashboard')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
if current_user.is_it_staff:
|
||||||
|
open_count = Ticket.query.filter_by(status=TicketStatus.OPEN).count()
|
||||||
|
in_progress_count= Ticket.query.filter_by(status=TicketStatus.IN_PROGRESS).count()
|
||||||
|
pending_count = Ticket.query.filter_by(status=TicketStatus.PENDING).count()
|
||||||
|
resolved_count = Ticket.query.filter_by(status=TicketStatus.RESOLVED).count()
|
||||||
|
my_tickets = Ticket.query.filter_by(assigned_to_id=current_user.id).filter(
|
||||||
|
Ticket.status.notin_([TicketStatus.CLOSED])
|
||||||
|
).order_by(Ticket.created_at.desc()).limit(10).all()
|
||||||
|
recent_tickets = Ticket.query.order_by(Ticket.created_at.desc()).limit(15).all()
|
||||||
|
return render_template('tickets/dashboard_it.html',
|
||||||
|
open_count=open_count, in_progress_count=in_progress_count,
|
||||||
|
pending_count=pending_count, resolved_count=resolved_count,
|
||||||
|
my_tickets=my_tickets, recent_tickets=recent_tickets,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
my_tickets = Ticket.query.filter_by(created_by_id=current_user.id).order_by(
|
||||||
|
Ticket.created_at.desc()).limit(20).all()
|
||||||
|
open_count = sum(1 for t in my_tickets if t.status == TicketStatus.OPEN)
|
||||||
|
active_count = sum(1 for t in my_tickets if t.status == TicketStatus.IN_PROGRESS)
|
||||||
|
resolved_count = sum(1 for t in my_tickets if t.status == TicketStatus.RESOLVED)
|
||||||
|
articles = KnowledgeBase.query.filter_by(is_published=True).order_by(
|
||||||
|
KnowledgeBase.view_count.desc()).limit(5).all()
|
||||||
|
return render_template('tickets/dashboard_employee.html',
|
||||||
|
my_tickets=my_tickets, open_count=open_count,
|
||||||
|
active_count=active_count, resolved_count=resolved_count,
|
||||||
|
articles=articles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Create Ticket ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create_ticket():
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
category = request.form.get('category', TicketCategory.OTHER)
|
||||||
|
priority = request.form.get('priority', TicketPriority.MEDIUM)
|
||||||
|
location = request.form.get('location', '').strip()
|
||||||
|
asset_tag = request.form.get('asset_tag', '').strip()
|
||||||
|
|
||||||
|
if not title or not description:
|
||||||
|
flash('Title and description are required.', 'danger')
|
||||||
|
return render_template('tickets/create.html',
|
||||||
|
categories=_categories(), priorities=_priorities())
|
||||||
|
|
||||||
|
ticket = Ticket(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
category = category,
|
||||||
|
priority = priority,
|
||||||
|
location = location,
|
||||||
|
asset_tag = asset_tag,
|
||||||
|
created_by_id = current_user.id,
|
||||||
|
status = TicketStatus.OPEN,
|
||||||
|
)
|
||||||
|
ticket.ticket_number = ticket.generate_ticket_number()
|
||||||
|
db.session.add(ticket)
|
||||||
|
db.session.flush() # get ticket.id before attachments
|
||||||
|
|
||||||
|
# Handle file uploads
|
||||||
|
for f in request.files.getlist('attachments'):
|
||||||
|
if f and f.filename and allowed_file(f.filename):
|
||||||
|
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
|
||||||
|
f'ticket_number={ticket.ticket_number} priority={priority} category={category}')
|
||||||
|
logger.info(f'[TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} by user_id={current_user.id}')
|
||||||
|
notify_new_ticket(ticket)
|
||||||
|
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
|
||||||
|
|
||||||
|
return render_template('tickets/create.html',
|
||||||
|
categories=_categories(), priorities=_priorities())
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket List ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets')
|
||||||
|
@login_required
|
||||||
|
def ticket_list():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
status = request.args.get('status', '')
|
||||||
|
priority = request.args.get('priority', '')
|
||||||
|
category = request.args.get('category', '')
|
||||||
|
search = request.args.get('q', '')
|
||||||
|
|
||||||
|
query = Ticket.query
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
query = query.filter_by(created_by_id=current_user.id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter_by(status=status)
|
||||||
|
if priority:
|
||||||
|
query = query.filter_by(priority=priority)
|
||||||
|
if category:
|
||||||
|
query = query.filter_by(category=category)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
Ticket.title.ilike(f'%{search}%') |
|
||||||
|
Ticket.ticket_number.ilike(f'%{search}%') |
|
||||||
|
Ticket.description.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
|
||||||
|
tickets = query.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=20)
|
||||||
|
return render_template('tickets/list.html',
|
||||||
|
tickets=tickets, status=status, priority=priority,
|
||||||
|
category=category, search=search,
|
||||||
|
statuses=_statuses(), priorities=_priorities(), categories=_categories(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Detail ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def ticket_detail(ticket_id):
|
||||||
|
ticket = Ticket.query.get_or_404(ticket_id)
|
||||||
|
|
||||||
|
# Employees can only view their own tickets
|
||||||
|
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
body = request.form.get('body', '').strip()
|
||||||
|
is_internal = bool(request.form.get('is_internal')) and current_user.is_it_staff
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
flash('Comment cannot be empty.', 'danger')
|
||||||
|
else:
|
||||||
|
comment = Comment(
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
author_id = current_user.id,
|
||||||
|
body = body,
|
||||||
|
is_internal= is_internal,
|
||||||
|
)
|
||||||
|
db.session.add(comment)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
for f in request.files.getlist('attachments'):
|
||||||
|
if f and f.filename and allowed_file(f.filename):
|
||||||
|
save_attachment(f, ticket_id=ticket.id,
|
||||||
|
comment_id=comment.id, uploader_id=current_user.id)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'comment_create', 'comment', comment.id,
|
||||||
|
f'ticket_id={ticket.id} internal={is_internal}')
|
||||||
|
logger.info(f'[COMMENT CREATE] comment_id={comment.id} ticket_id={ticket.id} by user_id={current_user.id}')
|
||||||
|
notify_comment_added(comment)
|
||||||
|
flash('Comment added.', 'success')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
|
||||||
|
|
||||||
|
comments = Comment.query.filter_by(ticket_id=ticket.id)
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
comments = comments.filter_by(is_internal=False)
|
||||||
|
comments = comments.order_by(Comment.created_at.asc()).all()
|
||||||
|
|
||||||
|
it_staff = User.query.filter(
|
||||||
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
|
User.is_active == True,
|
||||||
|
).all() if current_user.is_it_staff else []
|
||||||
|
|
||||||
|
history = ticket.history.order_by('changed_at').all()
|
||||||
|
|
||||||
|
return render_template('tickets/detail.html',
|
||||||
|
ticket=ticket, comments=comments,
|
||||||
|
it_staff=it_staff, history=history,
|
||||||
|
statuses=_statuses(), priorities=_priorities(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Update Ticket (IT Only) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/<int:ticket_id>/update', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_ticket(ticket_id):
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
ticket = Ticket.query.get_or_404(ticket_id)
|
||||||
|
old_status = ticket.status
|
||||||
|
old_priority = ticket.priority
|
||||||
|
old_assigned = ticket.assigned_to_id
|
||||||
|
|
||||||
|
new_status = request.form.get('status', ticket.status)
|
||||||
|
new_priority = request.form.get('priority', ticket.priority)
|
||||||
|
new_assigned = request.form.get('assigned_to_id', type=int)
|
||||||
|
internal_notes= request.form.get('internal_notes', ticket.internal_notes)
|
||||||
|
resolution = request.form.get('resolution_notes', ticket.resolution_notes)
|
||||||
|
due_date_str = request.form.get('due_date', '')
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
if new_status != old_status:
|
||||||
|
ticket.status = new_status
|
||||||
|
log_ticket_history(ticket, 'status', old_status, new_status, current_user.id)
|
||||||
|
changes.append(f'status: {old_status} → {new_status}')
|
||||||
|
if new_status == TicketStatus.RESOLVED:
|
||||||
|
ticket.resolved_at = datetime.utcnow()
|
||||||
|
elif new_status == TicketStatus.CLOSED:
|
||||||
|
ticket.closed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if new_priority != old_priority:
|
||||||
|
ticket.priority = new_priority
|
||||||
|
log_ticket_history(ticket, 'priority', old_priority, new_priority, current_user.id)
|
||||||
|
changes.append(f'priority: {old_priority} → {new_priority}')
|
||||||
|
|
||||||
|
if new_assigned != old_assigned:
|
||||||
|
ticket.assigned_to_id = new_assigned
|
||||||
|
log_ticket_history(ticket, 'assigned_to', str(old_assigned), str(new_assigned), current_user.id)
|
||||||
|
changes.append(f'assigned_to: {old_assigned} → {new_assigned}')
|
||||||
|
notify_assignment(ticket, current_user)
|
||||||
|
|
||||||
|
ticket.internal_notes = internal_notes
|
||||||
|
ticket.resolution_notes = resolution
|
||||||
|
|
||||||
|
if due_date_str:
|
||||||
|
try:
|
||||||
|
ticket.due_date = datetime.strptime(due_date_str, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_action(current_user.id, 'ticket_update', 'ticket', ticket.id,
|
||||||
|
f'changes=[{"; ".join(changes)}]')
|
||||||
|
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
|
||||||
|
|
||||||
|
if new_status != old_status:
|
||||||
|
notify_status_change(ticket, old_status, current_user)
|
||||||
|
|
||||||
|
flash('Ticket updated successfully.', 'success')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Delete Comment (IT Only) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_comment(comment_id):
|
||||||
|
comment = Comment.query.get_or_404(comment_id)
|
||||||
|
if not current_user.is_it_staff and comment.author_id != current_user.id:
|
||||||
|
abort(403)
|
||||||
|
ticket_id = comment.ticket_id
|
||||||
|
log_action(current_user.id, 'comment_delete', 'comment', comment.id,
|
||||||
|
f'ticket_id={ticket_id}')
|
||||||
|
logger.info(f'[COMMENT DELETE] comment_id={comment.id} ticket_id={ticket_id} by user_id={current_user.id}')
|
||||||
|
db.session.delete(comment)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Comment deleted.', 'success')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Attachment Download ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/attachments/<int:att_id>')
|
||||||
|
@login_required
|
||||||
|
def download_attachment(att_id):
|
||||||
|
att = Attachment.query.get_or_404(att_id)
|
||||||
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
|
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
|
||||||
|
download_name=att.filename)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Notifications ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/notifications')
|
||||||
|
@login_required
|
||||||
|
def notifications():
|
||||||
|
notifs = Notification.query.filter_by(user_id=current_user.id).order_by(
|
||||||
|
Notification.created_at.desc()).paginate(page=request.args.get('page', 1, type=int), per_page=30)
|
||||||
|
return render_template('tickets/notifications.html', notifs=notifs)
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_bp.route('/notifications/mark-read', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mark_notifications_read():
|
||||||
|
Notification.query.filter_by(user_id=current_user.id, is_read=False).update({'is_read': True})
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(request.referrer or url_for('tickets.notifications'))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Knowledge Base ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/kb')
|
||||||
|
@login_required
|
||||||
|
def knowledge_base():
|
||||||
|
articles = KnowledgeBase.query.filter_by(is_published=True).order_by(
|
||||||
|
KnowledgeBase.view_count.desc()).all()
|
||||||
|
return render_template('tickets/knowledge_base.html', articles=articles)
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_bp.route('/kb/<int:article_id>')
|
||||||
|
@login_required
|
||||||
|
def kb_article(article_id):
|
||||||
|
article = KnowledgeBase.query.get_or_404(article_id)
|
||||||
|
article.view_count += 1
|
||||||
|
db.session.commit()
|
||||||
|
return render_template('tickets/kb_article.html', article=article)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _statuses():
|
||||||
|
return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS,
|
||||||
|
TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED]
|
||||||
|
|
||||||
|
def _priorities():
|
||||||
|
return [TicketPriority.LOW, TicketPriority.MEDIUM,
|
||||||
|
TicketPriority.HIGH, TicketPriority.CRITICAL]
|
||||||
|
|
||||||
|
def _categories():
|
||||||
|
return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
|
||||||
|
TicketCategory.NETWORK, TicketCategory.ACCESS,
|
||||||
|
TicketCategory.EMAIL, TicketCategory.PRINTER,
|
||||||
|
TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import logging
|
||||||
|
from flask import request
|
||||||
|
from app import db
|
||||||
|
from app.models import ActivityLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_real_ip():
|
||||||
|
"""
|
||||||
|
Return the genuine client IP address when Flask sits behind an nginx
|
||||||
|
reverse proxy.
|
||||||
|
|
||||||
|
nginx forwards the original client IP in two headers:
|
||||||
|
- X-Forwarded-For: <client>, <proxy1>, <proxy2>, ...
|
||||||
|
- X-Real-IP: <client>
|
||||||
|
|
||||||
|
request.remote_addr is always 127.0.0.1 in a proxied setup, so we
|
||||||
|
must read the headers instead. We take the leftmost (first) value in
|
||||||
|
X-Forwarded-For because that is the original client; subsequent values
|
||||||
|
are intermediate proxies appended by each hop.
|
||||||
|
|
||||||
|
Falls back to X-Real-IP, then remote_addr as a last resort.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
xff = request.headers.get('X-Forwarded-For')
|
||||||
|
if xff:
|
||||||
|
# Strip any port appended by some proxies and take first entry
|
||||||
|
return xff.split(',')[0].strip()
|
||||||
|
xri = request.headers.get('X-Real-IP')
|
||||||
|
if xri:
|
||||||
|
return xri.strip()
|
||||||
|
return request.remote_addr
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
|
||||||
|
"""
|
||||||
|
Persist an activity log entry for create / edit / delete actions.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
user_id : int | None – the acting user (None for system actions)
|
||||||
|
action : str – e.g. 'ticket_create', 'ticket_update', 'comment_delete'
|
||||||
|
entity_type : str | None – 'ticket', 'comment', 'user', etc.
|
||||||
|
entity_id : int | None – primary key of the affected entity
|
||||||
|
details : str | None – free-form JSON or human-readable details
|
||||||
|
"""
|
||||||
|
ip = _get_real_ip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = ActivityLog(
|
||||||
|
user_id = user_id,
|
||||||
|
action = action,
|
||||||
|
entity_type = entity_type,
|
||||||
|
entity_id = entity_id,
|
||||||
|
details = details,
|
||||||
|
ip_address = ip,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(
|
||||||
|
f'[ACTIVITY] action={action} entity={entity_type}:{entity_id} '
|
||||||
|
f'user_id={user_id} ip={ip}'
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'[ACTIVITY LOG ERROR] {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
|
||||||
|
"""Record a granular field-level change on a ticket."""
|
||||||
|
from app.models import TicketHistory
|
||||||
|
try:
|
||||||
|
entry = TicketHistory(
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
changed_by = changed_by_id,
|
||||||
|
field_name = field_name,
|
||||||
|
old_value = str(old_value) if old_value is not None else None,
|
||||||
|
new_value = str(new_value) if new_value is not None else None,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(
|
||||||
|
f'[TICKET HISTORY] ticket_id={ticket.id} field={field_name} '
|
||||||
|
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'[TICKET HISTORY ERROR] {exc}')
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import logging
|
||||||
|
from flask import current_app, render_template_string
|
||||||
|
from flask_mail import Message
|
||||||
|
from app import db, mail, socketio
|
||||||
|
from app.models import Notification, NotificationType, User, UserRole
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Email Templates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_NEW_TICKET_EMAIL = """
|
||||||
|
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||||
|
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
|
||||||
|
<div style="background:#1a1a2e;padding:24px 32px;">
|
||||||
|
<h1 style="color:#e94560;margin:0;font-size:22px;">🎫 New IT Ticket Created</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="color:#555;margin-top:0;">A new support ticket has been submitted.</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||||
|
<tr><td style="padding:8px;color:#888;width:140px;">Ticket #</td><td style="padding:8px;font-weight:bold;">{{ ticket_number }}</td></tr>
|
||||||
|
<tr style="background:#f9f9f9;"><td style="padding:8px;color:#888;">Title</td><td style="padding:8px;">{{ title }}</td></tr>
|
||||||
|
<tr><td style="padding:8px;color:#888;">Category</td><td style="padding:8px;">{{ category }}</td></tr>
|
||||||
|
<tr style="background:#f9f9f9;"><td style="padding:8px;color:#888;">Priority</td><td style="padding:8px;"><span style="background:{{ priority_color }};color:#fff;padding:2px 8px;border-radius:4px;">{{ priority }}</span></td></tr>
|
||||||
|
<tr><td style="padding:8px;color:#888;">Submitted by</td><td style="padding:8px;">{{ submitted_by }}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="background:#f9f9f9;border-left:4px solid #e94560;padding:16px;margin:16px 0;border-radius:0 4px 4px 0;">
|
||||||
|
<p style="margin:0;color:#333;">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View Ticket</a>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;color:#999;font-size:12px;">
|
||||||
|
IT Helpdesk System • This is an automated notification.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_STATUS_UPDATE_EMAIL = """
|
||||||
|
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||||
|
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
|
||||||
|
<div style="background:#1a1a2e;padding:24px 32px;">
|
||||||
|
<h1 style="color:#e94560;margin:0;font-size:22px;">🔄 Ticket Update</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="color:#555;margin-top:0;">Your ticket <strong>{{ ticket_number }}</strong> has been updated.</p>
|
||||||
|
<p><strong>{{ title }}</strong></p>
|
||||||
|
<p style="color:#555;">{{ message }}</p>
|
||||||
|
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View Ticket</a>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;color:#999;font-size:12px;">
|
||||||
|
IT Helpdesk System • This is an automated notification.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_badge_color(priority):
|
||||||
|
return {'low': '#28a745', 'medium': '#ffc107', 'high': '#dc3545', 'critical': '#343a40'}.get(priority, '#6c757d')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── In-App Notification ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
|
||||||
|
"""Persist an in-app notification and push via WebSocket."""
|
||||||
|
try:
|
||||||
|
notif = Notification(
|
||||||
|
user_id = user_id,
|
||||||
|
ticket_id= ticket_id,
|
||||||
|
type = notif_type,
|
||||||
|
title = title,
|
||||||
|
message = message,
|
||||||
|
link = link,
|
||||||
|
)
|
||||||
|
db.session.add(notif)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
|
||||||
|
|
||||||
|
# Real-time push
|
||||||
|
socketio.emit('new_notification', {
|
||||||
|
'id' : notif.id,
|
||||||
|
'type' : notif_type,
|
||||||
|
'title' : title,
|
||||||
|
'message' : message,
|
||||||
|
'link' : link,
|
||||||
|
'created_at': notif.created_at.isoformat(),
|
||||||
|
}, room=f'user_{user_id}')
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Email Notification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_email(subject, recipients, html_body):
|
||||||
|
"""Send an email; silently log errors so it never blocks the main flow."""
|
||||||
|
try:
|
||||||
|
msg = Message(subject=subject, recipients=recipients, html=html_body)
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Event Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def notify_new_ticket(ticket):
|
||||||
|
"""Notify IT staff (email + in-app) and confirm receipt to the creator."""
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
# Email IT staff
|
||||||
|
html = render_template_string(_NEW_TICKET_EMAIL,
|
||||||
|
ticket_number = ticket.ticket_number,
|
||||||
|
title = ticket.title,
|
||||||
|
description = ticket.description[:500],
|
||||||
|
category = ticket.category.replace('_', ' ').title(),
|
||||||
|
priority = ticket.priority.upper(),
|
||||||
|
priority_color= _priority_badge_color(ticket.priority),
|
||||||
|
submitted_by = ticket.creator.full_name,
|
||||||
|
ticket_url = ticket_url,
|
||||||
|
)
|
||||||
|
it_email = current_app.config.get('IT_DEPT_EMAIL')
|
||||||
|
send_email(f'[New Ticket] {ticket.ticket_number} – {ticket.title}', [it_email], html)
|
||||||
|
|
||||||
|
# In-app: all IT staff
|
||||||
|
it_users = User.query.filter(
|
||||||
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
|
User.is_active == True,
|
||||||
|
).all()
|
||||||
|
for staff in it_users:
|
||||||
|
create_notification(
|
||||||
|
user_id = staff.id,
|
||||||
|
notif_type= NotificationType.TICKET_CREATED,
|
||||||
|
title = f'New Ticket: {ticket.ticket_number}',
|
||||||
|
message = f'{ticket.creator.full_name} submitted: {ticket.title}',
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = f'/tickets/{ticket.id}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Confirm to creator
|
||||||
|
create_notification(
|
||||||
|
user_id = ticket.created_by_id,
|
||||||
|
notif_type= NotificationType.TICKET_CREATED,
|
||||||
|
title = f'Ticket {ticket.ticket_number} Created',
|
||||||
|
message = 'Your ticket has been received. Our IT team will review it shortly.',
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = f'/tickets/{ticket.id}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_status_change(ticket, old_status, changed_by):
|
||||||
|
"""Notify ticket creator of status change."""
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
msg_text = f'Status changed from {old_status.replace("_"," ").title()} to {ticket.status.replace("_"," ").title()} by {changed_by.full_name}.'
|
||||||
|
|
||||||
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
|
ticket_number = ticket.ticket_number,
|
||||||
|
title = ticket.title,
|
||||||
|
message = msg_text,
|
||||||
|
ticket_url = ticket_url,
|
||||||
|
)
|
||||||
|
if ticket.creator.email_notif:
|
||||||
|
send_email(
|
||||||
|
f'[Ticket Update] {ticket.ticket_number} – Status Changed',
|
||||||
|
[ticket.creator.email],
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
create_notification(
|
||||||
|
user_id = ticket.created_by_id,
|
||||||
|
notif_type= NotificationType.STATUS_CHANGED,
|
||||||
|
title = f'Ticket {ticket.ticket_number} Updated',
|
||||||
|
message = msg_text,
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = f'/tickets/{ticket.id}',
|
||||||
|
)
|
||||||
|
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
|
||||||
|
|
||||||
|
|
||||||
|
def notify_comment_added(comment):
|
||||||
|
"""Notify relevant parties when a comment is added."""
|
||||||
|
ticket = comment.ticket
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
notified = set()
|
||||||
|
|
||||||
|
def _notify(user_id, is_internal=False):
|
||||||
|
if user_id in notified:
|
||||||
|
return
|
||||||
|
notified.add(user_id)
|
||||||
|
create_notification(
|
||||||
|
user_id = user_id,
|
||||||
|
notif_type= NotificationType.COMMENT_ADDED,
|
||||||
|
title = f'New Comment on {ticket.ticket_number}',
|
||||||
|
message = f'{comment.author.full_name} commented: {comment.body[:100]}',
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = f'/tickets/{ticket.id}#comment-{comment.id}',
|
||||||
|
)
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if user and user.email_notif and not is_internal:
|
||||||
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
|
ticket_number = ticket.ticket_number,
|
||||||
|
title = ticket.title,
|
||||||
|
message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
|
||||||
|
ticket_url = ticket_url,
|
||||||
|
)
|
||||||
|
send_email(
|
||||||
|
f'[Ticket Comment] {ticket.ticket_number}',
|
||||||
|
[user.email],
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify creator (skip if internal-only comment)
|
||||||
|
if not comment.is_internal:
|
||||||
|
_notify(ticket.created_by_id)
|
||||||
|
|
||||||
|
# Notify assignee
|
||||||
|
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
|
||||||
|
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
|
||||||
|
|
||||||
|
logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}')
|
||||||
|
|
||||||
|
|
||||||
|
def notify_assignment(ticket, assigned_by):
|
||||||
|
"""Notify newly assigned IT staff member."""
|
||||||
|
if not ticket.assigned_to_id:
|
||||||
|
return
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
create_notification(
|
||||||
|
user_id = ticket.assigned_to_id,
|
||||||
|
notif_type= NotificationType.TICKET_ASSIGNED,
|
||||||
|
title = f'Ticket Assigned: {ticket.ticket_number}',
|
||||||
|
message = f'You have been assigned ticket "{ticket.title}" by {assigned_by.full_name}.',
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = f'/tickets/{ticket.id}',
|
||||||
|
)
|
||||||
|
if ticket.assignee and ticket.assignee.email_notif:
|
||||||
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
|
ticket_number = ticket.ticket_number,
|
||||||
|
title = ticket.title,
|
||||||
|
message = f'This ticket has been assigned to you by {assigned_by.full_name}.',
|
||||||
|
ticket_url = ticket_url,
|
||||||
|
)
|
||||||
|
send_email(
|
||||||
|
f'[Ticket Assigned] {ticket.ticket_number}',
|
||||||
|
[ticket.assignee.email],
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Activity Logs{% endblock %}
|
||||||
|
{% block page_title %}Activity Logs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-list-ul me-2"></i>System Activity Log</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs.items %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;white-space:nowrap;">
|
||||||
|
{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono" style="font-size:11px;
|
||||||
|
color:{% if 'create' in log.action %}var(--success){% elif 'delete' in log.action or 'deactivate' in log.action %}var(--danger){% elif 'update' in log.action or 'edit' in log.action or 'change' in log.action %}var(--warning){% elif 'login' in log.action %}var(--accent3){% else %}var(--muted){% endif %};">
|
||||||
|
{{ log.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:13px;">
|
||||||
|
{% if log.user %}{{ log.user.full_name }}<br><span style="font-size:11px;color:var(--muted);">{{ log.user.email }}</span>{% else %}<span style="color:var(--muted);">System</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">
|
||||||
|
{{ log.entity_type or '—' }}{% if log.entity_id %} <span class="mono">#{{ log.entity_id }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
{{ log.details or '—' }}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">
|
||||||
|
{{ log.ip_address or '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if logs.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center py-3">
|
||||||
|
<nav><ul class="pagination mb-0">
|
||||||
|
{% if logs.has_prev %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.prev_num) }}">‹ Prev</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}<li class="page-item {% if p==logs.page %}active{% endif %}"><a class="page-link" href="{{ url_for('admin.activity_logs', page=p) }}">{{ p }}</a></li>
|
||||||
|
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if logs.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.next_num) }}">Next ›</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Create User{% endblock %}
|
||||||
|
{% block page_title %}User Management{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ url_for('admin.users') }}" style="font-size:13px;color:var(--accent);">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Create New User
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
|
||||||
|
<!-- ── Identity ─────────────────────────────────────────────────── -->
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;">
|
||||||
|
Identity
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Full Name *</label>
|
||||||
|
<input type="text" class="form-control" name="full_name"
|
||||||
|
value="{{ form.get('full_name', '') }}" required
|
||||||
|
placeholder="Jane Smith"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Username *</label>
|
||||||
|
<input type="text" class="form-control" name="username"
|
||||||
|
value="{{ form.get('username', '') }}" required
|
||||||
|
placeholder="jsmith"
|
||||||
|
pattern="[a-zA-Z0-9_\-]+"
|
||||||
|
title="Letters, numbers, underscores and hyphens only"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Work Email *</label>
|
||||||
|
<input type="email" class="form-control" name="email"
|
||||||
|
value="{{ form.get('email', '') }}" required
|
||||||
|
placeholder="jane.smith@company.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Department</label>
|
||||||
|
<input type="text" class="form-control" name="department"
|
||||||
|
value="{{ form.get('department', '') }}"
|
||||||
|
placeholder="Finance"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="text" class="form-control" name="phone"
|
||||||
|
value="{{ form.get('phone', '') }}"
|
||||||
|
placeholder="+1 555 000 0000"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Role & Status ────────────────────────────────────────────── -->
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;padding-top:4px;border-top:1px solid var(--border);">
|
||||||
|
Role & Status
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Role *</label>
|
||||||
|
<select class="form-select" name="role">
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r }}"
|
||||||
|
{% if form.get('role', 'employee') == r %}selected{% endif %}>
|
||||||
|
{{ r.replace('_', ' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:5px;">
|
||||||
|
<strong>Employee</strong> — can submit & track own tickets.<br>
|
||||||
|
<strong>IT Staff</strong> — can manage all tickets and KB.<br>
|
||||||
|
<strong>Admin</strong> — full access including user management.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-start pt-4">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;margin-top:10px;">
|
||||||
|
<input type="checkbox" name="is_active" value="1"
|
||||||
|
style="accent-color:var(--accent);width:17px;height:17px;"
|
||||||
|
{% if form.get('is_active', '1') != '0' %}checked{% endif %}/>
|
||||||
|
<span style="font-size:14px;font-weight:500;">Account Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Password ─────────────────────────────────────────────────── -->
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;padding-top:4px;border-top:1px solid var(--border);">
|
||||||
|
Password
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Password *</label>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input type="password" class="form-control" name="password"
|
||||||
|
id="pw-field" required minlength="8"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
style="padding-right:44px;"/>
|
||||||
|
<button type="button" onclick="togglePw('pw-field','eye1')"
|
||||||
|
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
|
||||||
|
<i class="bi bi-eye" id="eye1"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Confirm Password *</label>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input type="password" class="form-control" name="confirm_password"
|
||||||
|
id="pw-field2" required minlength="8"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
style="padding-right:44px;"/>
|
||||||
|
<button type="button" onclick="togglePw('pw-field2','eye2')"
|
||||||
|
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
|
||||||
|
<i class="bi bi-eye" id="eye2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Password strength bar -->
|
||||||
|
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;">
|
||||||
|
<div id="pw-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pw-strength-label" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Actions ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="d-flex gap-2 pt-2" style="border-top:1px solid var(--border);">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-check me-2"></i>Create User
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// ── Show/hide password ────────────────────────────────────────────────────────
|
||||||
|
function togglePw(fieldId, iconId) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
const icon = document.getElementById(iconId);
|
||||||
|
if (field.type === 'password') {
|
||||||
|
field.type = 'text';
|
||||||
|
icon.className = 'bi bi-eye-slash';
|
||||||
|
} else {
|
||||||
|
field.type = 'password';
|
||||||
|
icon.className = 'bi bi-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password strength indicator ───────────────────────────────────────────────
|
||||||
|
document.getElementById('pw-field').addEventListener('input', function () {
|
||||||
|
const val = this.value;
|
||||||
|
let score = 0;
|
||||||
|
if (val.length >= 8) score++;
|
||||||
|
if (val.length >= 12) score++;
|
||||||
|
if (/[A-Z]/.test(val) && /[a-z]/.test(val))score++;
|
||||||
|
if (/[0-9]/.test(val)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(val)) score++;
|
||||||
|
|
||||||
|
const bar = document.getElementById('pw-strength-bar');
|
||||||
|
const label = document.getElementById('pw-strength-label');
|
||||||
|
const levels = [
|
||||||
|
{ pct: '20%', bg: '#dc2626', text: 'Very weak' },
|
||||||
|
{ pct: '40%', bg: '#d97706', text: 'Weak' },
|
||||||
|
{ pct: '60%', bg: '#ca8a04', text: 'Fair' },
|
||||||
|
{ pct: '80%', bg: '#16a34a', text: 'Strong' },
|
||||||
|
{ pct: '100%', bg: '#059669', text: 'Very strong' },
|
||||||
|
];
|
||||||
|
if (val.length === 0) {
|
||||||
|
bar.style.width = '0%';
|
||||||
|
label.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lvl = levels[Math.min(score - 1, 4)] || levels[0];
|
||||||
|
bar.style.width = lvl.pct;
|
||||||
|
bar.style.background = lvl.bg;
|
||||||
|
label.textContent = lvl.text;
|
||||||
|
label.style.color = lvl.bg;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Client-side password match check before submit ────────────────────────────
|
||||||
|
document.querySelector('form').addEventListener('submit', function (e) {
|
||||||
|
const pw = document.getElementById('pw-field').value;
|
||||||
|
const pw2 = document.getElementById('pw-field2').value;
|
||||||
|
if (pw !== pw2) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('pw-field2').style.borderColor = 'var(--danger)';
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.style.cssText = 'font-size:12px;color:var(--danger);margin-top:4px;';
|
||||||
|
msg.textContent = 'Passwords do not match.';
|
||||||
|
const existing = document.getElementById('pw-match-msg');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
msg.id = 'pw-match-msg';
|
||||||
|
document.getElementById('pw-field2').insertAdjacentElement('afterend', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('pw-field2').addEventListener('input', function () {
|
||||||
|
this.style.borderColor = '';
|
||||||
|
const msg = document.getElementById('pw-match-msg');
|
||||||
|
if (msg) msg.remove();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit User{% endblock %}
|
||||||
|
{% block page_title %}Edit User{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="mb-3"><a href="{{ url_for('admin.users') }}" style="font-size:13px;color:var(--accent3);">← Back to Users</a></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-person-gear me-2"></i>Edit: {{ user.full_name }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Full Name</label>
|
||||||
|
<input type="text" class="form-control" name="full_name" value="{{ user.full_name }}" required/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Department</label>
|
||||||
|
<input type="text" class="form-control" name="department" value="{{ user.department or '' }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="text" class="form-control" name="phone" value="{{ user.phone or '' }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select class="form-select" name="role">
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r }}" {% if r == user.role %}selected{% endif %}>{{ r.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||||
|
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}
|
||||||
|
style="accent-color:var(--success);width:16px;height:16px;"/>
|
||||||
|
<span style="font-size:14px;">Account Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12"><hr style="border-color:var(--border);"/></div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">New Password <span style="color:var(--muted);font-weight:400;">(optional)</span></label>
|
||||||
|
<input type="password" class="form-control" name="new_password" placeholder="Leave blank to keep current"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-2"></i>Save</button>
|
||||||
|
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}IT Overview{% endblock %}
|
||||||
|
{% block page_title %}IT Operations Overview{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Stats grid -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% for label, value, icon, color, bg in [
|
||||||
|
('Total Tickets', stats.total_tickets, 'bi-collection', 'var(--text)', 'rgba(255,255,255,.05)'),
|
||||||
|
('Open', stats.open, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)'),
|
||||||
|
('In Progress', stats.in_progress, 'bi-arrow-repeat', 'var(--accent3)', 'rgba(0,180,216,.1)'),
|
||||||
|
('Resolved', stats.resolved, 'bi-check-circle', 'var(--success)', 'rgba(45,212,191,.1)'),
|
||||||
|
('Closed', stats.closed, 'bi-archive', 'var(--muted)', 'rgba(112,112,160,.1)'),
|
||||||
|
('Active Users', stats.total_users, 'bi-people', 'var(--accent2)', 'rgba(123,94,167,.1)'),
|
||||||
|
] %}
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:{{ bg }};color:{{ color }};"><i class="bi {{ icon }}"></i></div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" style="color:{{ color }};">{{ value }}</div>
|
||||||
|
<div class="stat-label">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity log -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-activity me-2"></i>Recent Activity</span>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin.activity_logs') }}" style="font-size:12px;color:var(--accent3);">Full log →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Entity</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in recent_logs %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="mono" style="font-size:11px;
|
||||||
|
color:{% if 'create' in log.action %}var(--success){% elif 'delete' in log.action %}var(--danger){% elif 'update' in log.action or 'edit' in log.action %}var(--warning){% else %}var(--muted){% endif %};">
|
||||||
|
{{ log.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:13px;">
|
||||||
|
{% if log.user %}{{ log.user.full_name }}{% else %}<span style="color:var(--muted);">System</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ log.entity_type or '—' }} {% if log.entity_id %}#{{ log.entity_id }}{% endif %}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ log.details or '—' }}</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">{{ log.created_at.strftime('%b %d %H:%M') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
|
||||||
|
{% block page_title %}{% if article %}Edit Article{% else %}New KB Article{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-3"><a href="{{ url_for('admin.kb_list') }}" style="font-size:13px;color:var(--accent3);">← Back to Knowledge Base</a></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-{% if article %}pencil{% else %}plus-circle{% endif %} me-2"></i>
|
||||||
|
{% if article %}Edit: {{ article.title[:40] }}{% else %}Create New Article{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Title *</label>
|
||||||
|
<input type="text" class="form-control" name="title" required
|
||||||
|
value="{{ article.title if article else '' }}"
|
||||||
|
placeholder="e.g. How to connect to the VPN"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<select class="form-select" name="category">
|
||||||
|
<option value="">— Select Category —</option>
|
||||||
|
{% for cat in ['hardware','software','network','access','email','printer','phone','security','other'] %}
|
||||||
|
<option value="{{ cat }}" {% if article and article.category == cat %}selected{% endif %}>
|
||||||
|
{{ cat.replace('_',' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Tags <span style="color:var(--muted);font-weight:400;">(comma-separated)</span></label>
|
||||||
|
<input type="text" class="form-control" name="tags"
|
||||||
|
value="{{ article.tags if article else '' }}"
|
||||||
|
placeholder="vpn, remote, access"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Content *</label>
|
||||||
|
<textarea class="form-control" name="body" rows="16" required
|
||||||
|
placeholder="Write the article content here. You can use plain text with line breaks.">{{ article.body if article else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="is_published" style="accent-color:var(--accent3);width:16px;height:16px;"
|
||||||
|
{% if not article or article.is_published %}checked{% endif %}/>
|
||||||
|
<span style="font-size:14px;">Publish immediately (visible to all employees)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check2 me-2"></i>{% if article %}Save Changes{% else %}Publish Article{% endif %}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin.kb_list') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Manage Knowledge Base{% endblock %}
|
||||||
|
{% block page_title %}Knowledge Base Management{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-journal-text me-2"></i>Articles <span style="font-size:12px;color:var(--muted);">({{ articles|length }})</span></span>
|
||||||
|
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Article
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if articles %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Title</th><th>Category</th><th>Author</th><th>Published</th><th>Views</th><th>Updated</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for art in articles %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;">{{ art.title[:60] }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ art.category or '—' }}</td>
|
||||||
|
<td style="font-size:13px;">{{ art.author.full_name if art.author else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if art.is_published %}
|
||||||
|
<span style="color:var(--success);font-size:12px;"><i class="bi bi-check-circle-fill"></i> Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--muted);font-size:12px;"><i class="bi bi-dash-circle"></i> Draft</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Preview"><i class="bi bi-eye"></i></a>
|
||||||
|
<a href="{{ url_for('admin.kb_edit', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
|
<form method="POST" action="{{ url_for('admin.kb_delete', article_id=art.id) }}" onsubmit="return confirm('Delete this article?');">
|
||||||
|
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No articles yet. <a href="{{ url_for('admin.kb_new') }}" style="color:var(--accent3);">Create your first article →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}All Tickets{% endblock %}
|
||||||
|
{% block page_title %}All Tickets{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" name="status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{% for s in ['open','in_progress','pending','resolved','closed'] %}
|
||||||
|
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Priority</label>
|
||||||
|
<select class="form-select" name="priority">
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
{% for p in ['low','medium','high','critical'] %}
|
||||||
|
<option value="{{ p }}" {% if p == priority %}selected{% endif %}>{{ p.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Assignment</label>
|
||||||
|
<select class="form-select" name="assigned">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="me" {% if assigned == 'me' %}selected{% endif %}>Assigned to Me</option>
|
||||||
|
<option value="unassigned" {% if assigned == 'unassigned' %}selected{% endif %}>Unassigned</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
|
||||||
|
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary"><i class="bi bi-x-lg"></i></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-collection me-2"></i>Tickets
|
||||||
|
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if tickets.items %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Submitted By</th>
|
||||||
|
<th>Assigned To</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tickets.items %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</a></td>
|
||||||
|
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.title }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
||||||
|
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
|
||||||
|
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
|
||||||
|
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if tickets.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center py-3">
|
||||||
|
<nav><ul class="pagination mb-0">
|
||||||
|
{% if tickets.has_prev %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.prev_num, status=status, priority=priority, assigned=assigned) }}">‹</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p==tickets.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.all_tickets', page=p, status=status, priority=priority, assigned=assigned) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if tickets.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.next_num, status=status, priority=priority, assigned=assigned) }}">›</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-inbox" style="font-size:40px;display:block;margin-bottom:12px;"></i>No tickets match the current filters.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}User Management{% endblock %}
|
||||||
|
{% block page_title %}User Management{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-people me-2"></i>All Users <span style="font-size:12px;color:var(--muted);">({{ users|length }})</span></span>
|
||||||
|
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Add User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Login</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="width:30px;height:30px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">
|
||||||
|
{{ u.full_name[0].upper() }}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:14px;">{{ u.full_name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:13px;color:var(--muted);">{{ u.email }}</td>
|
||||||
|
<td style="font-size:13px;color:var(--muted);">{{ u.department or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:11px;padding:2px 8px;border-radius:4px;
|
||||||
|
background:{% if u.role == 'admin' %}rgba(233,69,96,.15){% elif u.role == 'it_staff' %}rgba(0,180,216,.15){% else %}rgba(112,112,160,.15){% endif %};
|
||||||
|
color:{% if u.role == 'admin' %}var(--accent){% elif u.role == 'it_staff' %}var(--accent3){% else %}var(--muted){% endif %};">
|
||||||
|
{{ u.role.replace('_',' ').upper() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_active %}
|
||||||
|
<span style="color:var(--success);font-size:12px;"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--muted);font-size:12px;"><i class="bi bi-circle" style="font-size:8px;"></i> Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">
|
||||||
|
{{ u.last_login.strftime('%b %d, %Y') if u.last_login else 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('admin.edit_user', user_id=u.id) }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% if u.id != current_user.id and u.is_active %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||||
|
onsubmit="return confirm('Deactivate {{ u.full_name }}?');">
|
||||||
|
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);">
|
||||||
|
<i class="bi bi-person-dash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Login — TechDesk</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.bg-glow{position:fixed;width:700px;height:700px;border-radius:50%;background:radial-gradient(circle,rgba(37,99,235,.06) 0%,transparent 70%);top:-300px;right:-200px;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||||||
|
.brand{text-align:center;margin-bottom:32px;}
|
||||||
|
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
|
||||||
|
h1{font-size:21px;font-weight:700;color:var(--text);}
|
||||||
|
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
|
||||||
|
.form-group{margin-bottom:18px;}
|
||||||
|
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
|
||||||
|
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
|
||||||
|
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
input::placeholder{color:#94a3b8;}
|
||||||
|
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||||||
|
.btn:hover{background:var(--accent-h);}
|
||||||
|
.links{text-align:center;margin-top:20px;font-size:13px;color:var(--muted);}
|
||||||
|
.links a{color:var(--accent);}
|
||||||
|
.checkbox-row{display:flex;align-items:center;gap:8px;margin-bottom:20px;}
|
||||||
|
.checkbox-row input[type=checkbox]{width:auto;accent-color:var(--accent);}
|
||||||
|
.checkbox-row label{margin:0;font-size:13px;text-transform:none;letter-spacing:0;color:var(--text2);}
|
||||||
|
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
|
||||||
|
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||||||
|
.alert-success{background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;}
|
||||||
|
.alert-info{background:#f0f9ff;color:#0284c7;border:1px solid #bae6fd;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="bg-glow"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">TD</div>
|
||||||
|
<h1>TechDesk</h1>
|
||||||
|
<p class="subtitle">IT Helpdesk Portal — Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }}"><i class="bi bi-exclamation-circle me-2"></i>{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" required placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" name="remember" id="remember"/>
|
||||||
|
<label for="remember">Keep me signed in</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}My Profile{% endblock %}
|
||||||
|
{% block page_title %}My Profile{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Account Settings</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<!-- Avatar placeholder -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div style="width:80px;height:80px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#fff;margin:0 auto 10px;">
|
||||||
|
{{ current_user.full_name[0].upper() }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">{{ current_user.email }}</div>
|
||||||
|
<div style="font-size:11px;background:rgba(0,180,216,.1);color:var(--accent3);display:inline-block;padding:2px 10px;border-radius:12px;margin-top:4px;">
|
||||||
|
{{ current_user.role.replace('_',' ').upper() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Full Name</label>
|
||||||
|
<input type="text" class="form-control" name="full_name" value="{{ current_user.full_name }}" required/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Department</label>
|
||||||
|
<input type="text" class="form-control" name="department" value="{{ current_user.department or '' }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="text" class="form-control" name="phone" value="{{ current_user.phone or '' }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" value="{{ current_user.username }}" disabled/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);margin:24px 0;"/>
|
||||||
|
<h6 style="font-size:14px;font-weight:700;margin-bottom:16px;">Notification Preferences</h6>
|
||||||
|
<div class="d-flex flex-column gap-2 mb-4">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="email_notif" {% if current_user.email_notif %}checked{% endif %}
|
||||||
|
style="accent-color:var(--accent3);width:16px;height:16px;"/>
|
||||||
|
<span style="font-size:14px;"><i class="bi bi-envelope me-2" style="color:var(--accent3);"></i>Email Notifications</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="web_notif" {% if current_user.web_notif %}checked{% endif %}
|
||||||
|
style="accent-color:var(--accent3);width:16px;height:16px;"/>
|
||||||
|
<span style="font-size:14px;"><i class="bi bi-bell me-2" style="color:var(--accent3);"></i>In-App Notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);margin:24px 0;"/>
|
||||||
|
<h6 style="font-size:14px;font-weight:700;margin-bottom:16px;">Change Password <span style="font-size:12px;color:var(--muted);font-weight:400;">(leave blank to keep current)</span></h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">New Password</label>
|
||||||
|
<input type="password" class="form-control" name="new_password" placeholder="Min. 8 characters"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" name="confirm_password" placeholder="Repeat password"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-2"></i>Save Changes</button>
|
||||||
|
<a href="{{ url_for('tickets.dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Register — TechDesk</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||||||
|
.brand{text-align:center;margin-bottom:28px;}
|
||||||
|
.logo{width:46px;height:46px;background:var(--accent);border-radius:11px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:15px;color:#fff;margin:0 auto 10px;box-shadow:0 2px 10px rgba(37,99,235,.3);}
|
||||||
|
h1{font-size:20px;font-weight:700;color:var(--text);}
|
||||||
|
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
|
||||||
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
|
||||||
|
.form-group{margin-bottom:16px;}
|
||||||
|
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:6px;}
|
||||||
|
input,select{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 13px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.05);}
|
||||||
|
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
input::placeholder{color:#94a3b8;}
|
||||||
|
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||||||
|
.btn:hover{background:var(--accent-h);}
|
||||||
|
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
|
||||||
|
.links a{color:var(--accent);}
|
||||||
|
.alert{border-radius:8px;padding:10px 13px;font-size:13px;margin-bottom:16px;}
|
||||||
|
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">TD</div>
|
||||||
|
<h1>Create Account</h1>
|
||||||
|
<p class="subtitle">Join TechDesk to submit IT support requests</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Full Name *</label>
|
||||||
|
<input type="text" name="full_name" required placeholder="Jane Smith"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username *</label>
|
||||||
|
<input type="text" name="username" required placeholder="jsmith"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Work Email *</label>
|
||||||
|
<input type="email" name="email" required placeholder="jane.smith@company.com"/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Department</label>
|
||||||
|
<input type="text" name="department" placeholder="Finance"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input type="text" name="phone" placeholder="+1 555 000 0000"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password *</label>
|
||||||
|
<input type="password" name="password" required placeholder="Min. 8 characters"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm Password *</label>
|
||||||
|
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn"><i class="bi bi-person-plus me-2"></i>Create Account</button>
|
||||||
|
</form>
|
||||||
|
<div class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>{% block title %}IT Helpdesk{% endblock %} — TechDesk</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f0f4f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface2: #f8fafc;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border2: #cbd5e1;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-h: #1d4ed8;
|
||||||
|
--accent2: #7c3aed;
|
||||||
|
--accent3: #0891b2;
|
||||||
|
--text: #0f172a;
|
||||||
|
--text2: #334155;
|
||||||
|
--muted: #64748b;
|
||||||
|
--muted2: #94a3b8;
|
||||||
|
--success: #059669;
|
||||||
|
--success-bg:#ecfdf5;
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-bg:#fffbeb;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-bg: #fef2f2;
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-bg: #f0f9ff;
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
--sidebar-bg:#1e293b;
|
||||||
|
--sidebar-text:#94a3b8;
|
||||||
|
--sidebar-active:#ffffff;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);
|
||||||
|
--shadow: 0 4px 6px rgba(0,0,0,.07),0 2px 4px rgba(0,0,0,.05);
|
||||||
|
--shadow-lg: 0 10px 25px rgba(0,0,0,.1),0 4px 10px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden;}
|
||||||
|
a{color:var(--accent);text-decoration:none;}
|
||||||
|
a:hover{color:var(--accent-h);}
|
||||||
|
code,pre,.mono{font-family:'Space Mono',monospace;}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar{width:5px;}
|
||||||
|
::-webkit-scrollbar-track{background:var(--bg);}
|
||||||
|
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px;}
|
||||||
|
|
||||||
|
/* ── Sidebar — dark panel on light page ── */
|
||||||
|
#sidebar{
|
||||||
|
position:fixed;top:0;left:0;width:var(--sidebar-w);height:100vh;
|
||||||
|
background:var(--sidebar-bg);
|
||||||
|
display:flex;flex-direction:column;z-index:100;
|
||||||
|
transition:transform .25s ease;
|
||||||
|
box-shadow:2px 0 8px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
#sidebar .brand{
|
||||||
|
padding:20px 18px 16px;border-bottom:1px solid rgba(255,255,255,.07);
|
||||||
|
display:flex;align-items:center;gap:12px;
|
||||||
|
}
|
||||||
|
#sidebar .brand .logo{
|
||||||
|
width:36px;height:36px;background:var(--accent);border-radius:8px;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-family:'Space Mono',monospace;font-weight:700;font-size:13px;color:#fff;
|
||||||
|
flex-shrink:0;box-shadow:0 2px 8px rgba(37,99,235,.4);
|
||||||
|
}
|
||||||
|
#sidebar .brand h1{font-size:15px;font-weight:700;color:#fff;letter-spacing:.2px;}
|
||||||
|
#sidebar .brand small{font-size:10px;color:rgba(255,255,255,.35);display:block;margin-top:1px;}
|
||||||
|
.nav-section{padding:20px 16px 6px;font-size:9px;font-weight:700;letter-spacing:1.8px;color:rgba(255,255,255,.3);text-transform:uppercase;}
|
||||||
|
.sidebar-nav{list-style:none;padding:0 10px;}
|
||||||
|
.sidebar-nav li a{
|
||||||
|
display:flex;align-items:center;gap:10px;padding:9px 12px;
|
||||||
|
border-radius:8px;color:var(--sidebar-text);font-size:13.5px;font-weight:500;
|
||||||
|
transition:all .15s;
|
||||||
|
}
|
||||||
|
.sidebar-nav li a:hover{background:rgba(255,255,255,.07);color:#fff;}
|
||||||
|
.sidebar-nav li a.active{background:var(--accent);color:#fff;box-shadow:0 2px 8px rgba(37,99,235,.35);}
|
||||||
|
.sidebar-nav li a .bi{font-size:15px;width:18px;text-align:center;}
|
||||||
|
.sidebar-footer{margin-top:auto;padding:14px;border-top:1px solid rgba(255,255,255,.07);}
|
||||||
|
.sidebar-footer .user-card{display:flex;align-items:center;gap:10px;}
|
||||||
|
.sidebar-footer .avatar{width:32px;height:32px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0;}
|
||||||
|
.sidebar-footer .user-name{font-size:13px;font-weight:600;color:#fff;}
|
||||||
|
.sidebar-footer .user-role{font-size:10px;color:rgba(255,255,255,.4);text-transform:capitalize;}
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
#main{margin-left:var(--sidebar-w);min-height:100vh;display:flex;flex-direction:column;}
|
||||||
|
|
||||||
|
/* ── Topbar ── */
|
||||||
|
#topbar{
|
||||||
|
position:sticky;top:0;z-index:50;
|
||||||
|
background:rgba(255,255,255,.92);backdrop-filter:blur(12px);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
padding:11px 28px;display:flex;align-items:center;gap:14px;
|
||||||
|
box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
#topbar .page-title{font-size:17px;font-weight:700;flex:1;color:var(--text);}
|
||||||
|
.notif-btn{position:relative;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;transition:color .15s;}
|
||||||
|
.notif-btn:hover{color:var(--accent);background:var(--info-bg);}
|
||||||
|
.notif-badge{
|
||||||
|
position:absolute;top:-2px;right:-2px;min-width:17px;height:17px;
|
||||||
|
background:var(--danger);color:#fff;font-size:9px;font-weight:700;
|
||||||
|
border-radius:9px;display:flex;align-items:center;justify-content:center;
|
||||||
|
padding:0 4px;display:none;
|
||||||
|
}
|
||||||
|
.notif-badge.show{display:flex;}
|
||||||
|
|
||||||
|
/* ── Page content ── */
|
||||||
|
.page-body{padding:28px;flex:1;}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);}
|
||||||
|
.card-header{background:var(--surface2);border-bottom:1px solid var(--border);padding:13px 20px;font-weight:600;font-size:14px;color:var(--text2);border-radius:var(--radius) var(--radius) 0 0;}
|
||||||
|
.card-body{padding:20px;}
|
||||||
|
|
||||||
|
/* ── Stat cards ── */
|
||||||
|
.stat-card{
|
||||||
|
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
padding:20px;display:flex;align-items:center;gap:16px;
|
||||||
|
transition:all .2s;box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.stat-card:hover{box-shadow:var(--shadow);border-color:var(--border2);transform:translateY(-1px);}
|
||||||
|
.stat-icon{width:46px;height:46px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:21px;flex-shrink:0;}
|
||||||
|
.stat-value{font-size:26px;font-weight:700;font-family:'Space Mono',monospace;line-height:1;color:var(--text);}
|
||||||
|
.stat-label{font-size:12px;color:var(--muted);margin-top:4px;}
|
||||||
|
|
||||||
|
/* ── Badges ── */
|
||||||
|
.badge{font-size:11px;font-weight:600;padding:3px 9px;border-radius:20px;font-family:'Space Mono',monospace;letter-spacing:.2px;}
|
||||||
|
.badge-open{background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;}
|
||||||
|
.badge-in_progress{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe;}
|
||||||
|
.badge-pending{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
|
||||||
|
.badge-resolved{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
|
||||||
|
.badge-closed{background:#f1f5f9;color:var(--muted);border:1px solid var(--border);}
|
||||||
|
.badge-low{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
|
||||||
|
.badge-medium{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
|
||||||
|
.badge-high{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
|
||||||
|
.badge-critical{background:#fff1f2;color:#be123c;border:1px solid #fecdd3;animation:pulse-badge 2s infinite;}
|
||||||
|
@keyframes pulse-badge{0%,100%{opacity:1;}50%{opacity:.65;}}
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn{border-radius:8px;font-size:13px;font-weight:500;padding:8px 16px;transition:all .15s;}
|
||||||
|
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 1px 3px rgba(37,99,235,.3);}
|
||||||
|
.btn-primary:hover{background:var(--accent-h);border-color:var(--accent-h);box-shadow:0 2px 6px rgba(37,99,235,.4);}
|
||||||
|
.btn-outline-primary{border-color:var(--accent);color:var(--accent);background:transparent;}
|
||||||
|
.btn-outline-primary:hover{background:var(--accent);color:#fff;}
|
||||||
|
.btn-secondary{background:#fff;border-color:var(--border);color:var(--text2);box-shadow:var(--shadow-sm);}
|
||||||
|
.btn-secondary:hover{background:var(--surface2);border-color:var(--border2);color:var(--text);}
|
||||||
|
.btn-sm{padding:5px 12px;font-size:12px;}
|
||||||
|
|
||||||
|
/* ── Form controls ── */
|
||||||
|
.form-control,.form-select{
|
||||||
|
background:#fff;border:1px solid var(--border);color:var(--text);
|
||||||
|
border-radius:8px;padding:9px 14px;font-size:14px;
|
||||||
|
transition:border-color .15s,box-shadow .15s;
|
||||||
|
box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.form-control:focus,.form-select:focus{
|
||||||
|
background:#fff;border-color:var(--accent);
|
||||||
|
color:var(--text);box-shadow:0 0 0 3px rgba(37,99,235,.1);
|
||||||
|
}
|
||||||
|
.form-control::placeholder{color:var(--muted2);}
|
||||||
|
.form-label{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:6px;}
|
||||||
|
textarea.form-control{resize:vertical;}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.table{color:var(--text);--bs-table-bg:transparent;}
|
||||||
|
.table thead th{background:var(--surface2);color:var(--muted);font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;border-bottom:1px solid var(--border);padding:11px 16px;}
|
||||||
|
.table tbody td{border-bottom:1px solid var(--border);padding:11px 16px;vertical-align:middle;font-size:14px;}
|
||||||
|
.table tbody tr:hover td{background:#f8fafc;}
|
||||||
|
.table-hover>tbody>tr:hover>*{background:#f8fafc;}
|
||||||
|
|
||||||
|
/* ── Alerts / flashes ── */
|
||||||
|
.alert{border-radius:8px;font-size:14px;padding:12px 16px;}
|
||||||
|
.alert-success{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
|
||||||
|
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
|
||||||
|
.alert-info{background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;}
|
||||||
|
.alert-warning{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
|
||||||
|
|
||||||
|
/* ── Dropdown ── */
|
||||||
|
.dropdown-menu{background:#fff;border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);}
|
||||||
|
.dropdown-item{color:var(--text2);font-size:13.5px;padding:8px 16px;}
|
||||||
|
.dropdown-item:hover{background:var(--surface2);color:var(--text);}
|
||||||
|
.dropdown-divider{border-color:var(--border);}
|
||||||
|
|
||||||
|
/* ── Notification panel ── */
|
||||||
|
#notif-panel{
|
||||||
|
position:fixed;top:58px;right:16px;width:360px;max-height:480px;
|
||||||
|
background:#fff;border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
box-shadow:var(--shadow-lg);z-index:1000;
|
||||||
|
display:none;overflow:hidden;flex-direction:column;
|
||||||
|
}
|
||||||
|
#notif-panel.show{display:flex;}
|
||||||
|
#notif-panel .notif-head{padding:13px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;background:var(--surface2);}
|
||||||
|
#notif-panel .notif-head h6{font-size:13px;font-weight:700;margin:0;color:var(--text);}
|
||||||
|
#notif-panel .notif-list{overflow-y:auto;flex:1;}
|
||||||
|
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;gap:12px;cursor:pointer;transition:background .12s;}
|
||||||
|
.notif-item:hover{background:var(--surface2);}
|
||||||
|
.notif-item.unread{border-left:3px solid var(--accent);background:#eff6ff;}
|
||||||
|
.notif-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);margin-top:5px;flex-shrink:0;}
|
||||||
|
.notif-title{font-size:13px;font-weight:600;margin-bottom:2px;color:var(--text);}
|
||||||
|
.notif-msg{font-size:12px;color:var(--muted);}
|
||||||
|
.notif-time{font-size:11px;color:var(--muted2);margin-top:2px;font-family:'Space Mono',monospace;}
|
||||||
|
|
||||||
|
/* ── Chat widget ── */
|
||||||
|
#chat-fab{
|
||||||
|
position:fixed;bottom:28px;right:28px;width:52px;height:52px;
|
||||||
|
background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||||||
|
cursor:pointer;box-shadow:0 4px 16px rgba(37,99,235,.4);z-index:200;
|
||||||
|
transition:transform .2s,box-shadow .2s;
|
||||||
|
}
|
||||||
|
#chat-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(37,99,235,.5);}
|
||||||
|
#chat-fab .bi{font-size:22px;color:#fff;}
|
||||||
|
#chat-window{
|
||||||
|
position:fixed;bottom:90px;right:28px;width:380px;height:520px;
|
||||||
|
background:#fff;border:1px solid var(--border);border-radius:16px;
|
||||||
|
box-shadow:var(--shadow-lg);z-index:200;
|
||||||
|
display:none;flex-direction:column;overflow:hidden;
|
||||||
|
}
|
||||||
|
#chat-window.show{display:flex;}
|
||||||
|
.chat-head{background:var(--accent);padding:14px 18px;display:flex;align-items:center;gap:12px;}
|
||||||
|
.chat-head .bot-avatar{width:36px;height:36px;background:rgba(255,255,255,.2);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;}
|
||||||
|
.chat-head .bot-name{font-size:14px;font-weight:600;color:#fff;}
|
||||||
|
.chat-head .bot-status{font-size:11px;color:rgba(255,255,255,.75);}
|
||||||
|
.chat-head .close-chat{margin-left:auto;background:none;border:none;color:rgba(255,255,255,.8);font-size:18px;cursor:pointer;}
|
||||||
|
.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px;background:var(--bg);}
|
||||||
|
.chat-bubble{max-width:85%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.55;}
|
||||||
|
.chat-bubble.bot{background:#fff;border:1px solid var(--border);border-radius:4px 12px 12px 12px;align-self:flex-start;color:var(--text);box-shadow:var(--shadow-sm);}
|
||||||
|
.chat-bubble.user{background:var(--accent);color:#fff;border-radius:12px 4px 12px 12px;align-self:flex-end;}
|
||||||
|
.chat-bubble a{color:var(--accent3);}
|
||||||
|
.chat-input-area{padding:12px;border-top:1px solid var(--border);display:flex;gap:8px;background:#fff;}
|
||||||
|
.chat-input-area input{flex:1;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:9px 14px;font-size:13px;}
|
||||||
|
.chat-input-area input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
.chat-send{background:var(--accent);border:none;color:#fff;padding:9px 14px;border-radius:8px;cursor:pointer;transition:background .15s;}
|
||||||
|
.chat-send:hover{background:var(--accent-h);}
|
||||||
|
.typing-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--muted2);animation:typing 1.2s infinite;}
|
||||||
|
.typing-dot:nth-child(2){animation-delay:.2s;}
|
||||||
|
.typing-dot:nth-child(3){animation-delay:.4s;}
|
||||||
|
@keyframes typing{0%,60%,100%{transform:translateY(0);}30%{transform:translateY(-5px);}}
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
.pagination .page-link{background:#fff;border-color:var(--border);color:var(--text2);}
|
||||||
|
.pagination .page-link:hover{background:var(--surface2);border-color:var(--border2);color:var(--text);}
|
||||||
|
.pagination .active .page-link{background:var(--accent);border-color:var(--accent);color:#fff;}
|
||||||
|
|
||||||
|
/* ── Ticket detail ── */
|
||||||
|
.ticket-header{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:20px;box-shadow:var(--shadow-sm);}
|
||||||
|
.comment-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow-sm);}
|
||||||
|
.comment-card.internal{border-left:3px solid var(--warning);background:var(--warning-bg);}
|
||||||
|
.comment-author{font-size:13px;font-weight:600;color:var(--text);}
|
||||||
|
.comment-time{font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;}
|
||||||
|
.comment-body{font-size:14px;margin-top:8px;white-space:pre-wrap;color:var(--text2);}
|
||||||
|
.history-item{font-size:12px;color:var(--muted);padding:6px 0;border-bottom:1px solid var(--border);}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media(max-width:768px){
|
||||||
|
#sidebar{transform:translateX(-100%);}
|
||||||
|
#sidebar.open{transform:translateX(0);}
|
||||||
|
#main{margin-left:0;}
|
||||||
|
#chat-window{width:calc(100vw - 16px);right:8px;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<!-- ── Sidebar ── -->
|
||||||
|
<nav id="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">TD</div>
|
||||||
|
<div>
|
||||||
|
<h1>TechDesk</h1>
|
||||||
|
<small>IT Helpdesk System</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="nav-section">Main</p>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li><a href="{{ url_for('tickets.dashboard') }}" class="{{ 'active' if request.endpoint == 'tickets.dashboard' }}">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('tickets.ticket_list') }}" class="{{ 'active' if request.endpoint == 'tickets.ticket_list' }}">
|
||||||
|
<i class="bi bi-ticket-detailed"></i> My Tickets
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('tickets.create_ticket') }}" class="{{ 'active' if request.endpoint == 'tickets.create_ticket' }}">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Ticket
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="nav-section">Resources</p>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li><a href="{{ url_for('tickets.knowledge_base') }}" class="{{ 'active' if 'knowledge_base' in request.endpoint }}">
|
||||||
|
<i class="bi bi-book"></i> Knowledge Base
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('tickets.notifications') }}" class="{{ 'active' if request.endpoint == 'tickets.notifications' }}">
|
||||||
|
<i class="bi bi-bell"></i> Notifications
|
||||||
|
{% if unread_notifications > 0 %}<span class="badge badge-open ms-auto">{{ unread_notifications }}</span>{% endif %}
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<p class="nav-section">IT Management</p>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li><a href="{{ url_for('admin.index') }}" class="{{ 'active' if request.endpoint == 'admin.index' }}">
|
||||||
|
<i class="bi bi-bar-chart-line"></i> IT Overview
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
|
||||||
|
<i class="bi bi-collection"></i> All Tickets
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
|
||||||
|
<i class="bi bi-journal-text"></i> Manage KB
|
||||||
|
</a></li>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
||||||
|
<i class="bi bi-people"></i> Users
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
|
||||||
|
<i class="bi bi-list-ul"></i> Activity Logs
|
||||||
|
</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-card">
|
||||||
|
<div class="avatar">{{ current_user.full_name[0].upper() }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="user-name">{{ current_user.full_name }}</div>
|
||||||
|
<div class="user-role">{{ current_user.role.replace('_',' ') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto dropdown">
|
||||||
|
<button class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown" style="padding:5px 8px;">
|
||||||
|
<i class="bi bi-three-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"><i class="bi bi-person me-2"></i>Profile</a></li>
|
||||||
|
<li><hr class="dropdown-divider"/></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── Main ── -->
|
||||||
|
<div id="main">
|
||||||
|
<header id="topbar">
|
||||||
|
<button class="btn btn-sm btn-secondary d-md-none" onclick="document.getElementById('sidebar').classList.toggle('open')">
|
||||||
|
<i class="bi bi-list"></i>
|
||||||
|
</button>
|
||||||
|
<div class="page-title">{% block page_title %}{% endblock %}</div>
|
||||||
|
<!-- Notification bell -->
|
||||||
|
<button class="notif-btn" onclick="toggleNotifPanel()">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
<span class="notif-badge {% if unread_notifications > 0 %}show{% endif %}" id="notif-badge-count">
|
||||||
|
{{ unread_notifications if unread_notifications > 0 }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('auth.profile') }}" class="btn btn-sm btn-secondary">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>{{ current_user.full_name.split()[0] }}
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
|
||||||
|
{{ msg }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Notification Panel ── -->
|
||||||
|
<div id="notif-panel">
|
||||||
|
<div class="notif-head">
|
||||||
|
<h6><i class="bi bi-bell me-2"></i>Notifications</h6>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="markAllRead()">Mark all read</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list" id="notif-list">
|
||||||
|
<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px 16px;border-top:1px solid var(--border);">
|
||||||
|
<a href="{{ url_for('tickets.notifications') }}" style="font-size:12px;color:var(--accent3);">View all notifications →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chat FAB ── -->
|
||||||
|
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
|
||||||
|
<i class="bi bi-robot"></i>
|
||||||
|
</div>
|
||||||
|
<div id="chat-window">
|
||||||
|
<div class="chat-head">
|
||||||
|
<div class="bot-avatar">🤖</div>
|
||||||
|
<div>
|
||||||
|
<div class="bot-name">IT Assistant</div>
|
||||||
|
<div class="bot-status">● Online</div>
|
||||||
|
</div>
|
||||||
|
<button class="close-chat" onclick="toggleChat()"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
<div class="chat-bubble bot">
|
||||||
|
Hi! I'm your IT Assistant 👋 I can help you report issues, create tickets, or answer IT questions.<br><br>
|
||||||
|
What can I help you with today?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<input type="text" id="chat-input" placeholder="Describe your issue..." onkeydown="if(event.key==='Enter')sendChat()"/>
|
||||||
|
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Not authenticated — minimal layout -->
|
||||||
|
<div id="main" style="margin-left:0;">
|
||||||
|
<div class="page-body">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
|
||||||
|
{{ msg }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{{ self.content() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── WebSocket ────────────────────────────────────────────────────────────────
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
const socket = io({
|
||||||
|
path: '/socket.io/',
|
||||||
|
transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake
|
||||||
|
upgrade: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
socket.on('connect',()=>{console.log('[Socket] connected');});
|
||||||
|
socket.on('new_notification',(n)=>{
|
||||||
|
updateBadge(1, true);
|
||||||
|
prependNotif(n);
|
||||||
|
showToast(n.title, n.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBadge(delta, increment=false){
|
||||||
|
const el = document.getElementById('notif-badge-count');
|
||||||
|
if(!el) return;
|
||||||
|
let cur = parseInt(el.textContent)||0;
|
||||||
|
const next = increment ? cur+delta : delta;
|
||||||
|
el.textContent = next>0?next:'';
|
||||||
|
el.classList.toggle('show', next>0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification panel ────────────────────────────────────────────────────────
|
||||||
|
let panelOpen = false;
|
||||||
|
function toggleNotifPanel(){
|
||||||
|
const panel = document.getElementById('notif-panel');
|
||||||
|
panelOpen = !panelOpen;
|
||||||
|
panel.classList.toggle('show', panelOpen);
|
||||||
|
if(panelOpen) loadNotifications();
|
||||||
|
}
|
||||||
|
document.addEventListener('click',(e)=>{
|
||||||
|
if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){
|
||||||
|
panelOpen=false;
|
||||||
|
document.getElementById('notif-panel').classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadNotifications(){
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/notifications/unread-count');
|
||||||
|
const d = await r.json();
|
||||||
|
updateBadge(d.count);
|
||||||
|
}catch(e){}
|
||||||
|
|
||||||
|
const list = document.getElementById('notif-list');
|
||||||
|
try{
|
||||||
|
const r = await fetch('/notifications?json=1');
|
||||||
|
// Fallback: show link
|
||||||
|
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">'+
|
||||||
|
'<a href="/notifications" style="color:var(--accent3)">View all notifications</a></div>';
|
||||||
|
}catch(e){
|
||||||
|
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Unable to load</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependNotif(n){
|
||||||
|
const list = document.getElementById('notif-list');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className='notif-item unread';
|
||||||
|
item.innerHTML=`<div class="notif-dot"></div><div><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message||''}</div><div class="notif-time">Just now</div></div>`;
|
||||||
|
if(n.link) item.onclick=()=>{ window.location=n.link; };
|
||||||
|
list.prepend(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead(){
|
||||||
|
await fetch('/api/notifications/mark-all-read',{method:'POST'});
|
||||||
|
updateBadge(0);
|
||||||
|
document.querySelectorAll('.notif-item.unread').forEach(el=>{
|
||||||
|
el.classList.remove('unread');
|
||||||
|
const dot=el.querySelector('.notif-dot');
|
||||||
|
if(dot) dot.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(title, msg){
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.style.cssText='position:fixed;bottom:90px;right:28px;background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent3);border-radius:8px;padding:12px 16px;z-index:300;max-width:300px;box-shadow:0 4px 16px rgba(0,0,0,.4);animation:slideIn .25s ease;';
|
||||||
|
t.innerHTML=`<div style="font-size:13px;font-weight:600;margin-bottom:4px;">${title}</div><div style="font-size:12px;color:var(--muted);">${msg||''}</div>`;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(()=>t.remove(),5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chatbot ───────────────────────────────────────────────────────────────────
|
||||||
|
let chatHistory = [];
|
||||||
|
let chatOpen = false;
|
||||||
|
function toggleChat(){
|
||||||
|
chatOpen=!chatOpen;
|
||||||
|
document.getElementById('chat-window').classList.toggle('show',chatOpen);
|
||||||
|
if(chatOpen) document.getElementById('chat-input').focus();
|
||||||
|
}
|
||||||
|
async function sendChat(){
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const msg = input.value.trim();
|
||||||
|
if(!msg) return;
|
||||||
|
input.value='';
|
||||||
|
appendBubble(msg,'user');
|
||||||
|
chatHistory.push({role:'user',content:msg});
|
||||||
|
const typing = appendTyping();
|
||||||
|
try{
|
||||||
|
const r = await fetch('/chatbot/message',{
|
||||||
|
method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({message:msg,history:chatHistory})
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
typing.remove();
|
||||||
|
const reply = d.reply || 'Sorry, I encountered an error.';
|
||||||
|
appendBubble(reply,'bot');
|
||||||
|
chatHistory.push({role:'assistant',content:reply});
|
||||||
|
if(d.ticket){
|
||||||
|
const notif=document.createElement('div');
|
||||||
|
notif.className='chat-bubble bot';
|
||||||
|
notif.style.cssText='background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);';
|
||||||
|
notif.innerHTML=`🎫 <strong>${d.ticket.ticket_number}</strong> — <a href="${d.ticket.url}">View Ticket</a>`;
|
||||||
|
document.getElementById('chat-messages').appendChild(notif);
|
||||||
|
}
|
||||||
|
}catch(e){typing.remove();appendBubble('Sorry, something went wrong.','bot');}
|
||||||
|
scrollChat();
|
||||||
|
}
|
||||||
|
function appendBubble(text, role){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className=`chat-bubble ${role}`;
|
||||||
|
el.innerHTML=text.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
|
||||||
|
document.getElementById('chat-messages').appendChild(el);
|
||||||
|
scrollChat();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
function appendTyping(){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='chat-bubble bot';
|
||||||
|
el.innerHTML='<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
||||||
|
document.getElementById('chat-messages').appendChild(el);
|
||||||
|
scrollChat();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
function scrollChat(){
|
||||||
|
const m=document.getElementById('chat-messages');
|
||||||
|
m.scrollTop=m.scrollHeight;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
<style>@keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}</style>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Ticket{% endblock %}
|
||||||
|
{% block page_title %}Submit a New Ticket{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>New Support Request
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Issue Title *</label>
|
||||||
|
<input type="text" class="form-control" name="title" required
|
||||||
|
placeholder="Brief description of the issue (e.g. 'Cannot connect to VPN')"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Category *</label>
|
||||||
|
<select class="form-select" name="category" required>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}">{{ cat.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Priority *</label>
|
||||||
|
<select class="form-select" name="priority" required>
|
||||||
|
{% for p in priorities %}
|
||||||
|
<option value="{{ p }}" {% if p == 'medium' %}selected{% endif %}>{{ p.upper() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Location / Floor</label>
|
||||||
|
<input type="text" class="form-control" name="location" placeholder="e.g. 3rd Floor, Room 302"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Asset Tag / Device Serial</label>
|
||||||
|
<input type="text" class="form-control" name="asset_tag" placeholder="e.g. ASSET-1234"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Detailed Description *</label>
|
||||||
|
<textarea class="form-control" name="description" rows="6" required
|
||||||
|
placeholder="Please describe the issue in detail: - What were you trying to do? - What happened? - Any error messages? - When did it start?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Attachments (screenshots, logs, etc.)</label>
|
||||||
|
<input type="file" class="form-control" name="attachments" multiple
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:5px;">
|
||||||
|
Accepted: PNG, JPG, PDF, DOC, DOCX, TXT, ZIP, LOG. Max 16 MB per file.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority guide -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;">
|
||||||
|
<div style="font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">Priority Guide</div>
|
||||||
|
<div class="row g-2" style="font-size:12px;">
|
||||||
|
<div class="col-sm-3"><span class="badge badge-low me-1">LOW</span> Minor inconvenience, no work stoppage</div>
|
||||||
|
<div class="col-sm-3"><span class="badge badge-medium me-1">MEDIUM</span> Impacting productivity, workaround available</div>
|
||||||
|
<div class="col-sm-3"><span class="badge badge-high me-1">HIGH</span> Significant impact, no workaround</div>
|
||||||
|
<div class="col-sm-3"><span class="badge badge-critical me-1">CRITICAL</span> Complete outage or security incident</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-send me-2"></i>Submit Ticket
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('tickets.dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4 d-none d-lg-block">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-robot me-2"></i>Prefer Chatting?</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size:13px;color:var(--muted);">Let our AI assistant guide you through reporting your issue conversationally.</p>
|
||||||
|
<button class="btn btn-outline-primary w-100" onclick="toggleChat()">
|
||||||
|
<i class="bi bi-chat-dots me-2"></i>Open IT Assistant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-lightbulb me-2"></i>Tips</div>
|
||||||
|
<div class="card-body" style="font-size:13px;color:var(--muted);">
|
||||||
|
<p>✅ Include error messages exactly as shown</p>
|
||||||
|
<p>✅ Mention what changed recently</p>
|
||||||
|
<p>✅ List affected users or devices</p>
|
||||||
|
<p>✅ Attach relevant screenshots or logs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block page_title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(96,165,250,.1);color:var(--info);">
|
||||||
|
<i class="bi bi-ticket-detailed"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ open_count }}</div>
|
||||||
|
<div class="stat-label">Open Tickets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(0,180,216,.1);color:var(--accent3);">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ active_count }}</div>
|
||||||
|
<div class="stat-label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(45,212,191,.1);color:var(--success);">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ resolved_count }}</div>
|
||||||
|
<div class="stat-label">Resolved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- My Tickets -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-ticket-detailed me-2"></i>My Recent Tickets</span>
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Ticket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if my_tickets %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in my_tickets %}
|
||||||
|
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
|
||||||
|
<td><span class="mono" style="font-size:12px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
|
||||||
|
<td>{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %}</td>
|
||||||
|
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-inbox" style="font-size:36px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No tickets yet. <a href="{{ url_for('tickets.create_ticket') }}">Submit your first ticket →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions + KB -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-lightning-charge me-2"></i>Quick Actions</div>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>Report New Issue
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tickets.ticket_list') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>View All My Tickets
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" onclick="toggleChat()">
|
||||||
|
<i class="bi bi-robot me-2"></i>Ask IT Assistant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-book me-2"></i>Knowledge Base</span>
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base') }}" style="font-size:12px;color:var(--accent3);">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if articles %}
|
||||||
|
{% for art in articles %}
|
||||||
|
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" style="display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--border);color:var(--text);">
|
||||||
|
<i class="bi bi-file-earmark-text" style="color:var(--accent3);font-size:16px;flex-shrink:0;"></i>
|
||||||
|
<span style="font-size:13px;">{{ art.title[:55] }}</span>
|
||||||
|
<span style="margin-left:auto;font-size:11px;color:var(--muted);">{{ art.view_count }} views</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-3 text-center" style="font-size:13px;color:var(--muted);">No articles yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}IT Dashboard{% endblock %}
|
||||||
|
{% block page_title %}IT Operations Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% set stat_items = [
|
||||||
|
('open', open_count, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)', 'Open'),
|
||||||
|
('in_progress', in_progress_count, 'bi-arrow-repeat', 'var(--accent3)', 'rgba(0,180,216,.1)', 'In Progress'),
|
||||||
|
('pending', pending_count, 'bi-hourglass-split', 'var(--warning)', 'rgba(251,191,36,.1)', 'Pending'),
|
||||||
|
('resolved', resolved_count, 'bi-check2-circle', 'var(--success)', 'rgba(45,212,191,.1)', 'Resolved'),
|
||||||
|
] %}
|
||||||
|
{% for status, count, icon, color, bg, label in stat_items %}
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<a href="{{ url_for('admin.all_tickets', status=status) }}" style="text-decoration:none;">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:{{ bg }};color:{{ color }};">
|
||||||
|
<i class="bi {{ icon }}"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" style="color:{{ color }};">{{ count }}</div>
|
||||||
|
<div class="stat-label">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Assigned to me -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-person-check me-2"></i>Assigned to Me</span>
|
||||||
|
<a href="{{ url_for('admin.all_tickets', assigned='me') }}" style="font-size:12px;color:var(--accent3);">View all →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if my_tickets %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead><tr><th>Ticket #</th><th>Title</th><th>Priority</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in my_tickets %}
|
||||||
|
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
|
||||||
|
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
|
||||||
|
<td style="font-size:13px;">{{ t.title[:40] }}{% if t.title|length>40 %}…{% endif %}</td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;"><i class="bi bi-inbox" style="font-size:28px;display:block;margin-bottom:8px;"></i>No tickets assigned to you.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent tickets -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-clock-history me-2"></i>Recent Tickets</span>
|
||||||
|
<a href="{{ url_for('admin.all_tickets') }}" style="font-size:12px;color:var(--accent3);">All tickets →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead><tr><th>Ticket #</th><th>User</th><th>Category</th><th>Priority</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in recent_tickets %}
|
||||||
|
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
|
||||||
|
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
|
||||||
|
<td style="font-size:13px;">{{ t.creator.full_name.split()[0] }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick links -->
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-lightning-charge me-2"></i>Quick Actions</div>
|
||||||
|
<div class="card-body d-flex flex-wrap gap-2">
|
||||||
|
<a href="{{ url_for('admin.all_tickets', status='open') }}" class="btn btn-outline-primary btn-sm"><i class="bi bi-circle me-1"></i>Open Queue</a>
|
||||||
|
<a href="{{ url_for('admin.all_tickets', assigned='unassigned') }}" class="btn btn-secondary btn-sm"><i class="bi bi-person-x me-1"></i>Unassigned</a>
|
||||||
|
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-secondary btn-sm"><i class="bi bi-plus-circle me-1"></i>New KB Article</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary btn-sm"><i class="bi bi-people me-1"></i>Manage Users</a>
|
||||||
|
<a href="{{ url_for('admin.activity_logs') }}" class="btn btn-secondary btn-sm"><i class="bi bi-list-ul me-1"></i>Activity Logs</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ ticket.ticket_number }}{% endblock %}
|
||||||
|
{% block page_title %}{{ ticket.ticket_number }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
|
||||||
|
<!-- Ticket header -->
|
||||||
|
<div class="ticket-header mb-4">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
||||||
|
<h2 style="font-size:20px;font-weight:700;line-height:1.3;margin:0;">{{ ticket.title }}</h2>
|
||||||
|
<div class="d-flex gap-2 flex-shrink-0">
|
||||||
|
<span class="badge badge-{{ ticket.priority }}">{{ ticket.priority.upper() }}</span>
|
||||||
|
<span class="badge badge-{{ ticket.status }}">{{ ticket.status.replace('_',' ').upper() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-3" style="font-size:12px;color:var(--muted);">
|
||||||
|
<span><i class="bi bi-hash me-1"></i><span class="mono">{{ ticket.ticket_number }}</span></span>
|
||||||
|
<span><i class="bi bi-person me-1"></i>{{ ticket.creator.full_name }}</span>
|
||||||
|
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span>
|
||||||
|
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
||||||
|
{% if ticket.location %}<span><i class="bi bi-geo-alt me-1"></i>{{ ticket.location }}</span>{% endif %}
|
||||||
|
{% if ticket.asset_tag %}<span><i class="bi bi-cpu me-1"></i>{{ ticket.asset_tag }}</span>{% endif %}
|
||||||
|
{% if ticket.ai_generated %}<span style="color:var(--accent3);"><i class="bi bi-robot me-1"></i>AI-generated</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="bi bi-file-text me-2"></i>Description</div>
|
||||||
|
<div class="card-body" style="white-space:pre-wrap;font-size:14px;line-height:1.7;">{{ ticket.description }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments on ticket -->
|
||||||
|
{% set ticket_atts = ticket.attachments.filter_by(comment_id=None).all() %}
|
||||||
|
{% if ticket_atts %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="bi bi-paperclip me-2"></i>Attachments</div>
|
||||||
|
<div class="card-body d-flex flex-wrap gap-2">
|
||||||
|
{% for att in ticket_atts %}
|
||||||
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
|
||||||
|
class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>{{ att.filename }}
|
||||||
|
<span style="font-size:10px;color:var(--muted);">({{ (att.file_size/1024)|int }}KB)</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Resolution notes (IT only, or if resolved) -->
|
||||||
|
{% if ticket.resolution_notes %}
|
||||||
|
<div class="card mb-4" style="border-color:var(--success);">
|
||||||
|
<div class="card-header" style="background:rgba(45,212,191,.08);color:var(--success);">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>Resolution Notes
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="white-space:pre-wrap;font-size:14px;">{{ ticket.resolution_notes }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mb-3 d-flex align-items-center justify-content-between">
|
||||||
|
<h5 style="font-size:16px;font-weight:700;margin:0;">
|
||||||
|
<i class="bi bi-chat-dots me-2"></i>Comments
|
||||||
|
<span style="font-size:13px;color:var(--muted);">({{ comments|length }})</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
|
||||||
|
{{ comment.author.full_name[0].upper() }}
|
||||||
|
</div>
|
||||||
|
<span class="comment-author">{{ comment.author.full_name }}</span>
|
||||||
|
{% if comment.author.is_it_staff %}
|
||||||
|
<span style="font-size:10px;background:rgba(0,180,216,.15);color:var(--accent3);padding:1px 7px;border-radius:4px;font-weight:600;">IT STAFF</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if comment.is_internal %}
|
||||||
|
<span style="font-size:10px;background:rgba(251,191,36,.15);color:var(--warning);padding:1px 7px;border-radius:4px;font-weight:600;">INTERNAL NOTE</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="comment-time">{{ comment.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
||||||
|
{% if current_user.is_it_staff or comment.author_id == current_user.id %}
|
||||||
|
<form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
|
||||||
|
onsubmit="return confirm('Delete this comment?');">
|
||||||
|
<button type="submit" class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;"
|
||||||
|
title="Delete comment">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">{{ comment.body }}</div>
|
||||||
|
<!-- Comment attachments -->
|
||||||
|
{% set c_atts = comment.attachments.all() %}
|
||||||
|
{% if c_atts %}
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
|
{% for att in c_atts %}
|
||||||
|
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>{{ att.filename }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">
|
||||||
|
<i class="bi bi-chat" style="font-size:28px;display:block;margin-bottom:8px;"></i>
|
||||||
|
No comments yet. Be the first to add an update.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Add comment -->
|
||||||
|
{% if ticket.status not in ('closed',) %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea class="form-control" name="body" rows="4" required
|
||||||
|
placeholder="Add your update, follow-up, or response here…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Attachments</label>
|
||||||
|
<input type="file" class="form-control" name="attachments" multiple
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<div class="mb-3 d-flex align-items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_internal" name="is_internal" style="accent-color:var(--warning);"/>
|
||||||
|
<label for="is_internal" style="font-size:13px;color:var(--warning);margin:0;cursor:pointer;">
|
||||||
|
<i class="bi bi-lock me-1"></i>Internal note (IT staff only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-send me-2"></i>Post Comment
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar column -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
|
||||||
|
<!-- IT Update Panel -->
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-pencil-square me-2"></i>Update Ticket</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('tickets.update_ticket', ticket_id=ticket.id) }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" name="status">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if s == ticket.status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Priority</label>
|
||||||
|
<select class="form-select" name="priority">
|
||||||
|
{% for p in priorities %}
|
||||||
|
<option value="{{ p }}" {% if p == ticket.priority %}selected{% endif %}>{{ p.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Assign To</label>
|
||||||
|
<select class="form-select" name="assigned_to_id">
|
||||||
|
<option value="">— Unassigned —</option>
|
||||||
|
{% for staff in it_staff %}
|
||||||
|
<option value="{{ staff.id }}" {% if ticket.assigned_to_id == staff.id %}selected{% endif %}>
|
||||||
|
{{ staff.full_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Due Date</label>
|
||||||
|
<input type="date" class="form-control" name="due_date"
|
||||||
|
value="{{ ticket.due_date.strftime('%Y-%m-%d') if ticket.due_date else '' }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Resolution Notes</label>
|
||||||
|
<textarea class="form-control" name="resolution_notes" rows="3"
|
||||||
|
placeholder="Describe how the issue was resolved…">{{ ticket.resolution_notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Internal Notes <span style="color:var(--warning);">(IT only)</span></label>
|
||||||
|
<textarea class="form-control" name="internal_notes" rows="2"
|
||||||
|
placeholder="Private notes for IT team…">{{ ticket.internal_notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-check2 me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Ticket Info -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
|
||||||
|
<div class="card-body" style="font-size:13px;">
|
||||||
|
{% macro info_row(icon, label, value) %}
|
||||||
|
<div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<i class="bi {{ icon }}" style="color:var(--muted);width:16px;text-align:center;margin-top:1px;flex-shrink:0;"></i>
|
||||||
|
<div style="color:var(--muted);">{{ label }}</div>
|
||||||
|
<div style="margin-left:auto;text-align:right;max-width:55%;">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
{{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }}
|
||||||
|
{{ info_row('bi-tag', 'Category', ticket.category.replace('_',' ').title()) }}
|
||||||
|
{{ info_row('bi-person', 'Submitted by', ticket.creator.full_name) }}
|
||||||
|
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
|
||||||
|
{{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }}
|
||||||
|
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }}
|
||||||
|
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date.strftime('%b %d, %Y')) }}{% endif %}
|
||||||
|
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at.strftime('%b %d, %Y')) }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
|
{% if history %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
{% for h in history %}
|
||||||
|
<div class="history-item">
|
||||||
|
<strong>{{ h.field_name.replace('_',' ').title() }}</strong>
|
||||||
|
changed from <em>{{ h.old_value or '—' }}</em> to <em>{{ h.new_value or '—' }}</em>
|
||||||
|
<br>
|
||||||
|
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ article.title }}{% endblock %}
|
||||||
|
{% block page_title %}Knowledge Base{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base') }}" style="font-size:13px;color:var(--accent3);">
|
||||||
|
← Back to Knowledge Base
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-file-earmark-text me-2"></i>Article</span>
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<a href="{{ url_for('admin.kb_edit', article_id=article.id) }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 style="font-size:22px;font-weight:700;margin-bottom:12px;">{{ article.title }}</h1>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-bottom:24px;display:flex;gap:16px;">
|
||||||
|
{% if article.category %}<span><i class="bi bi-tag me-1"></i>{{ article.category.replace('_',' ').title() }}</span>{% endif %}
|
||||||
|
<span><i class="bi bi-person me-1"></i>{{ article.author.full_name if article.author else 'IT Team' }}</span>
|
||||||
|
<span><i class="bi bi-calendar3 me-1"></i>{{ article.updated_at.strftime('%B %d, %Y') }}</span>
|
||||||
|
<span><i class="bi bi-eye me-1"></i>{{ article.view_count }} views</span>
|
||||||
|
</div>
|
||||||
|
{% if article.tags %}
|
||||||
|
<div class="mb-3">
|
||||||
|
{% for tag in article.tags.split(',') %}
|
||||||
|
<span style="background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px 10px;font-size:11px;color:var(--muted);margin-right:4px;">{{ tag.strip() }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="white-space:pre-wrap;font-size:14px;line-height:1.8;color:var(--text);">{{ article.body }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<span style="font-size:13px;color:var(--muted);">Did this article solve your issue?</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-ticket me-1"></i>Still need help
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Knowledge Base{% endblock %}
|
||||||
|
{% block page_title %}Knowledge Base{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-book me-2"></i>Self-Service Articles</span>
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Article
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if articles %}
|
||||||
|
{% for art in articles %}
|
||||||
|
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}"
|
||||||
|
style="display:flex;align-items:center;gap:14px;padding:14px 20px;border-bottom:1px solid var(--border);color:var(--text);">
|
||||||
|
<div style="width:40px;height:40px;background:rgba(0,180,216,.1);border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
|
<i class="bi bi-file-earmark-text" style="color:var(--accent3);font-size:18px;"></i>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:14px;font-weight:600;">{{ art.title }}</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
|
||||||
|
{% if art.category %}{{ art.category.replace('_',' ').title() }} · {% endif %}
|
||||||
|
{{ art.updated_at.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);flex-shrink:0;">
|
||||||
|
<i class="bi bi-eye me-1"></i>{{ art.view_count }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No articles published yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-lightbulb me-2"></i>Can't find an answer?</div>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-ticket me-2"></i>Submit a Ticket
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" onclick="toggleChat()">
|
||||||
|
<i class="bi bi-robot me-2"></i>Ask IT Assistant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}My Tickets{% endblock %}
|
||||||
|
{% block page_title %}Tickets{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" name="q" value="{{ search }}" placeholder="Search by title, ticket #, description…"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" name="status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Priority</label>
|
||||||
|
<select class="form-select" name="priority">
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
{% for p in priorities %}
|
||||||
|
<option value="{{ p }}" {% if p == priority %}selected{% endif %}>{{ p.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<select class="form-select" name="category">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{% for c in categories %}
|
||||||
|
<option value="{{ c }}" {% if c == category %}selected{% endif %}>{{ c.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
|
||||||
|
<a href="{{ url_for('tickets.ticket_list') }}" class="btn btn-secondary"><i class="bi bi-x-lg"></i></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-ticket-detailed me-2"></i>
|
||||||
|
Tickets
|
||||||
|
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
|
||||||
|
</span>
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Ticket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if tickets.items %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket #</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
{% if current_user.is_it_staff %}<th>Submitted By</th>{% endif %}
|
||||||
|
<th>Date</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tickets.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}"
|
||||||
|
class="mono" style="font-size:12px;color:var(--accent3);">{{ t.ticket_number }}</a>
|
||||||
|
{% if t.ai_generated %}
|
||||||
|
<i class="bi bi-robot" style="font-size:11px;color:var(--muted);" title="Created via AI Assistant"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" style="color:var(--text);font-size:14px;">
|
||||||
|
{{ t.title[:55] }}{% if t.title|length > 55 %}…{% endif %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
||||||
|
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
|
||||||
|
{% endif %}
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if tickets.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center py-3">
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
{% if tickets.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('tickets.ticket_list', page=tickets.prev_num, status=status, priority=priority, category=category, q=search) }}">‹ Prev</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == tickets.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('tickets.ticket_list', page=p, status=status, priority=priority, category=category, q=search) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if tickets.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('tickets.ticket_list', page=tickets.next_num, status=status, priority=priority, category=category, q=search) }}">Next ›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-inbox" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No tickets found.
|
||||||
|
{% if search or status or priority or category %}
|
||||||
|
<a href="{{ url_for('tickets.ticket_list') }}" style="color:var(--accent3);">Clear filters</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('tickets.create_ticket') }}" style="color:var(--accent3);">Submit your first ticket →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Notifications{% endblock %}
|
||||||
|
{% block page_title %}Notifications{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-bell me-2"></i>All Notifications</span>
|
||||||
|
<form method="POST" action="{{ url_for('tickets.mark_notifications_read') }}">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="bi bi-check2-all me-1"></i>Mark All Read
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if notifs.items %}
|
||||||
|
{% for n in notifs.items %}
|
||||||
|
<a href="{{ n.link or '#' }}"
|
||||||
|
style="display:flex;gap:14px;padding:16px 20px;border-bottom:1px solid var(--border);color:var(--text);
|
||||||
|
{% if not n.is_read %}border-left:3px solid var(--accent3);background:rgba(0,180,216,.03);{% endif %}">
|
||||||
|
<div style="margin-top:2px;flex-shrink:0;">
|
||||||
|
{% if not n.is_read %}
|
||||||
|
<div style="width:8px;height:8px;border-radius:50%;background:var(--accent3);margin-top:3px;"></div>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-check2" style="color:var(--muted);font-size:14px;"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:14px;font-weight:{% if not n.is_read %}600{% else %}400{% endif %};">
|
||||||
|
{{ n.title }}
|
||||||
|
</div>
|
||||||
|
{% if n.message %}
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:3px;">{{ n.message[:120] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:5px;font-family:'Space Mono',monospace;">
|
||||||
|
{{ n.created_at.strftime('%b %d, %Y at %H:%M') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink:0;align-self:center;">
|
||||||
|
<i class="bi bi-chevron-right" style="color:var(--muted);font-size:12px;"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if notifs.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center py-3">
|
||||||
|
<nav><ul class="pagination mb-0">
|
||||||
|
{% if notifs.has_prev %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('tickets.notifications', page=notifs.prev_num) }}">‹</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in notifs.iter_pages() %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p==notifs.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('tickets.notifications', page=p) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if notifs.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="{{ url_for('tickets.notifications', page=notifs.next_num) }}">›</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-bell-slash" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No notifications yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||||
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
f"mysql+pymysql://{os.environ.get('DB_USER', 'root')}:"
|
||||||
|
f"{os.environ.get('DB_PASSWORD', '')}@"
|
||||||
|
f"{os.environ.get('DB_HOST', 'localhost')}:"
|
||||||
|
f"{os.environ.get('DB_PORT', '3306')}/"
|
||||||
|
f"{os.environ.get('DB_NAME', 'it_tickets')}"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
'pool_recycle': 300,
|
||||||
|
'pool_pre_ping': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'localhost')
|
||||||
|
MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
|
||||||
|
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'True').lower() == 'true'
|
||||||
|
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||||
|
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
||||||
|
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'IT Helpdesk <noreply@yourdomain.com>')
|
||||||
|
|
||||||
|
# IT Department
|
||||||
|
IT_DEPT_EMAIL = os.environ.get('IT_DEPT_EMAIL', 'it@yourdomain.com')
|
||||||
|
APP_BASE_URL = os.environ.get('APP_BASE_URL', 'http://localhost:5000')
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'app/static/uploads')
|
||||||
|
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024))
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
||||||
|
|
||||||
|
# Anthropic AI
|
||||||
|
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY', '')
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
|
||||||
|
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'default': ProductionConfig
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# gunicorn.conf.py
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
# Server socket
|
||||||
|
bind = "127.0.0.1:7000"
|
||||||
|
backlog = 2048
|
||||||
|
|
||||||
|
# Worker processes
|
||||||
|
worker_class = "eventlet" # Required for Flask-SocketIO
|
||||||
|
workers = 1 # eventlet requires exactly 1 worker
|
||||||
|
worker_connections = 1000
|
||||||
|
timeout = 120
|
||||||
|
keepalive = 5
|
||||||
|
|
||||||
|
# Application
|
||||||
|
module = "run:app"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = "logs/gunicorn_access.log"
|
||||||
|
errorlog = "logs/gunicorn_error.log"
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sµs'
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "it_ticket_system"
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False
|
||||||
|
pidfile = "/tmp/it_tickets.pid"
|
||||||
|
user = None # Set to your app user, e.g. "www-data"
|
||||||
|
group = None
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# SSL (configure if not using nginx for SSL termination)
|
||||||
|
# keyfile = "/etc/ssl/private/your.key"
|
||||||
|
# certfile = "/etc/ssl/certs/your.crt"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TechDesk IT Ticket System (Gunicorn + SocketIO)
|
||||||
|
After=network.target mysql.service
|
||||||
|
Wants=mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/it-ticket-system
|
||||||
|
Environment="FLASK_ENV=production"
|
||||||
|
EnvironmentFile=/var/www/it-ticket-system/.env
|
||||||
|
ExecStart=/var/www/it-ticket-system/venv/bin/gunicorn \
|
||||||
|
--config gunicorn.conf.py \
|
||||||
|
run:app
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=5
|
||||||
|
PrivateTmp=true
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=it-ticket-system
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Binary file not shown.
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# /etc/nginx/sites-available/it-ticket-system
|
||||||
|
# Symlink to sites-enabled:
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/it-ticket-system /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
upstream it_ticket_app {
|
||||||
|
server 127.0.0.1:5000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect HTTP → HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tickets.yourdomain.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name tickets.yourdomain.com;
|
||||||
|
|
||||||
|
# ── SSL Certificates (Let's Encrypt / certbot) ──────────────────────────
|
||||||
|
ssl_certificate /etc/letsencrypt/live/tickets.yourdomain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/tickets.yourdomain.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# ── Security Headers ────────────────────────────────────────────────────
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
# ── Logging ─────────────────────────────────────────────────────────────
|
||||||
|
access_log /var/log/nginx/it_tickets_access.log;
|
||||||
|
error_log /var/log/nginx/it_tickets_error.log warn;
|
||||||
|
|
||||||
|
# ── Client limits ───────────────────────────────────────────────────────
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
# ── Static files (served directly by nginx) ─────────────────────────────
|
||||||
|
location /static/ {
|
||||||
|
alias /var/www/it-ticket-system/app/static/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── WebSocket (Socket.IO) ───────────────────────────────────────────────
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://it_ticket_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Application ─────────────────────────────────────────────────────────
|
||||||
|
location / {
|
||||||
|
proxy_pass http://it_ticket_app;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 8k;
|
||||||
|
proxy_buffers 8 8k;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-Mail==0.10.0
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
Flask-SocketIO==5.3.6
|
||||||
|
PyMySQL==1.1.1
|
||||||
|
cryptography==42.0.8
|
||||||
|
Werkzeug==3.0.3
|
||||||
|
gunicorn==22.0.0
|
||||||
|
eventlet==0.36.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
Pillow==10.4.0
|
||||||
|
WTForms==3.1.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
requests==2.32.3
|
||||||
|
APScheduler==3.10.4
|
||||||
|
marshmallow==3.21.3
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
from app import create_app, socketio
|
||||||
|
|
||||||
|
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
socketio.run(app, host='0.0.0.0', port=5000, debug=app.debug)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- TechDesk IT Ticket System — MySQL Database Setup
|
||||||
|
-- Run as root: mysql -u root -p < setup_db.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Create database
|
||||||
|
CREATE DATABASE IF NOT EXISTS it_tickets
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Create dedicated application user
|
||||||
|
CREATE USER IF NOT EXISTS 'it_tickets_user'@'localhost'
|
||||||
|
IDENTIFIED BY 'CHANGE_THIS_PASSWORD';
|
||||||
|
|
||||||
|
-- Grant privileges
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, REFERENCES
|
||||||
|
ON it_tickets.*
|
||||||
|
TO 'it_tickets_user'@'localhost';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
SELECT User, Host FROM mysql.user WHERE User = 'it_tickets_user';
|
||||||
|
SHOW GRANTS FOR 'it_tickets_user'@'localhost';
|
||||||
Reference in New Issue
Block a user