From dfdbb548752c8aaf8597787d9614b6aadb0baa84 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 25 Mar 2026 11:37:46 -0400 Subject: [PATCH] Initial commit --- .env | 36 + DEPLOYMENT_GUIDE.md | 632 ++++++++++++++++++ app/__init__.py | 115 ++++ app/models.py | 264 ++++++++ app/routes/__init__.py | 0 app/routes/admin.py | 265 ++++++++ app/routes/api.py | 83 +++ app/routes/auth.py | 124 ++++ app/routes/chatbot.py | 114 ++++ app/routes/tickets.py | 370 ++++++++++ app/services/__init__.py | 0 app/services/log_service.py | 91 +++ app/services/notification_service.py | 255 +++++++ app/templates/admin/activity_logs.html | 67 ++ app/templates/admin/create_user.html | 215 ++++++ app/templates/admin/edit_user.html | 58 ++ app/templates/admin/index.html | 68 ++ app/templates/admin/kb_edit.html | 64 ++ app/templates/admin/kb_list.html | 55 ++ app/templates/admin/tickets.html | 110 +++ app/templates/admin/users.html | 77 +++ app/templates/auth/login.html | 76 +++ app/templates/auth/profile.html | 79 +++ app/templates/auth/register.html | 87 +++ app/templates/base.html | 588 ++++++++++++++++ app/templates/tickets/create.html | 110 +++ app/templates/tickets/dashboard_employee.html | 125 ++++ app/templates/tickets/dashboard_it.html | 104 +++ app/templates/tickets/detail.html | 250 +++++++ app/templates/tickets/kb_article.html | 55 ++ app/templates/tickets/knowledge_base.html | 60 ++ app/templates/tickets/list.html | 149 +++++ app/templates/tickets/notifications.html | 80 +++ config/config.py | 59 ++ gunicorn.conf.py | 36 + it-ticket-system.service | 29 + it-ticket-system.zip | Bin 0 -> 71636 bytes nginx.conf | 79 +++ requirements.txt | 19 + run.py | 7 + setup_db.sql | 24 + 41 files changed, 5079 insertions(+) create mode 100644 .env create mode 100644 DEPLOYMENT_GUIDE.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/api.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/chatbot.py create mode 100644 app/routes/tickets.py create mode 100644 app/services/__init__.py create mode 100644 app/services/log_service.py create mode 100644 app/services/notification_service.py create mode 100644 app/templates/admin/activity_logs.html create mode 100644 app/templates/admin/create_user.html create mode 100644 app/templates/admin/edit_user.html create mode 100644 app/templates/admin/index.html create mode 100644 app/templates/admin/kb_edit.html create mode 100644 app/templates/admin/kb_list.html create mode 100644 app/templates/admin/tickets.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/profile.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/tickets/create.html create mode 100644 app/templates/tickets/dashboard_employee.html create mode 100644 app/templates/tickets/dashboard_it.html create mode 100644 app/templates/tickets/detail.html create mode 100644 app/templates/tickets/kb_article.html create mode 100644 app/templates/tickets/knowledge_base.html create mode 100644 app/templates/tickets/list.html create mode 100644 app/templates/tickets/notifications.html create mode 100644 config/config.py create mode 100644 gunicorn.conf.py create mode 100644 it-ticket-system.service create mode 100644 it-ticket-system.zip create mode 100644 nginx.conf create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 setup_db.sql diff --git a/.env b/.env new file mode 100644 index 0000000..5f4ae1a --- /dev/null +++ b/.env @@ -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 + +# 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! diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..8f958da --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -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/`, `POST /tickets//update`, `POST /comments//delete`, `GET /attachments/`, `GET/POST /notifications`, `GET /kb`, `GET /kb/` | +| `app/routes/admin.py` | Admin/IT views | `GET /admin/`, `GET /admin/tickets`, `GET /admin/users`, `GET/POST /admin/users//edit`, `POST /admin/users//delete`, KB CRUD, `GET /admin/logs` | +| `app/routes/api.py` | JSON API + WebSocket | `GET /api/notifications/unread-count`, `POST /api/notifications//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 | diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d3ed74f --- /dev/null +++ b/app/__init__.py @@ -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}') \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..c2a36e0 --- /dev/null +++ b/app/models.py @@ -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'' + + +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'' + + +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'' + + +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'' + + +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'' + + +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'' + + +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'' + + +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'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..072355e --- /dev/null +++ b/app/routes/admin.py @@ -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//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//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//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//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] \ No newline at end of file diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..75dadb3 --- /dev/null +++ b/app/routes/api.py @@ -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//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}') diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..4028142 --- /dev/null +++ b/app/routes/auth.py @@ -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') diff --git a/app/routes/chatbot.py b/app/routes/chatbot.py new file mode 100644 index 0000000..7d7fda8 --- /dev/null +++ b/app/routes/chatbot.py @@ -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}) diff --git a/app/routes/tickets.py b/app/routes/tickets.py new file mode 100644 index 0000000..e017dc4 --- /dev/null +++ b/app/routes/tickets.py @@ -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/', 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//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//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/') +@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/') +@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] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/log_service.py b/app/services/log_service.py new file mode 100644 index 0000000..fb2bdc0 --- /dev/null +++ b/app/services/log_service.py @@ -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: , , , ... + - X-Real-IP: + + 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}') \ No newline at end of file diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..ba2df7b --- /dev/null +++ b/app/services/notification_service.py @@ -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 = """ + +
+
+

🎫 New IT Ticket Created

+
+
+

A new support ticket has been submitted.

+ + + + + + +
Ticket #{{ ticket_number }}
Title{{ title }}
Category{{ category }}
Priority{{ priority }}
Submitted by{{ submitted_by }}
+
+

{{ description }}

+
+ View Ticket +
+
+ IT Helpdesk System • This is an automated notification. +
+
+ +""" + +_STATUS_UPDATE_EMAIL = """ + +
+
+

🔄 Ticket Update

+
+
+

Your ticket {{ ticket_number }} has been updated.

+

{{ title }}

+

{{ message }}

+ View Ticket +
+
+ IT Helpdesk System • This is an automated notification. +
+
+ +""" + + +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}') diff --git a/app/templates/admin/activity_logs.html b/app/templates/admin/activity_logs.html new file mode 100644 index 0000000..4b04347 --- /dev/null +++ b/app/templates/admin/activity_logs.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Activity Logs{% endblock %} +{% block page_title %}Activity Logs{% endblock %} + +{% block content %} +
+
System Activity Log
+
+ + + + + + + + + + + + + {% for log in logs.items %} + + + + + + + + + {% endfor %} + +
TimestampActionUserEntityDetailsIP Address
+ {{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }} + + + {{ log.action }} + + + {% if log.user %}{{ log.user.full_name }}
{{ log.user.email }}{% else %}System{% endif %} +
+ {{ log.entity_type or '—' }}{% if log.entity_id %} #{{ log.entity_id }}{% endif %} + + {{ log.details or '—' }} + + {{ log.ip_address or '—' }} +
+ + {% if logs.pages > 1 %} +
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/create_user.html b/app/templates/admin/create_user.html new file mode 100644 index 0000000..6010678 --- /dev/null +++ b/app/templates/admin/create_user.html @@ -0,0 +1,215 @@ +{% extends "base.html" %} +{% block title %}Create User{% endblock %} +{% block page_title %}User Management{% endblock %} + +{% block content %} +
+
+ + + +
+
+ Create New User +
+
+
+ + +
+ Identity +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Role & Status +
+
+
+ + +
+ Employee — can submit & track own tickets.
+ IT Staff — can manage all tickets and KB.
+ Admin — full access including user management. +
+
+
+ +
+
+ + +
+ Password +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+ + Cancel +
+ +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/edit_user.html b/app/templates/admin/edit_user.html new file mode 100644 index 0000000..3d3e60e --- /dev/null +++ b/app/templates/admin/edit_user.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Edit User{% endblock %} +{% block page_title %}Edit User{% endblock %} + +{% block content %} +
+
+ +
+
Edit: {{ user.full_name }}
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

+
+ + +
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 0000000..a332045 --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}IT Overview{% endblock %} +{% block page_title %}IT Operations Overview{% endblock %} + +{% block content %} + +
+ {% 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)'), + ] %} +
+
+
+
+
{{ value }}
+
{{ label }}
+
+
+
+ {% endfor %} +
+ + +
+
+ Recent Activity + {% if current_user.is_admin %} + Full log → + {% endif %} +
+
+ + + + + + + + + + + + {% for log in recent_logs %} + + + + + + + + {% endfor %} + +
ActionUserEntityDetailsTime
+ + {{ log.action }} + + + {% if log.user %}{{ log.user.full_name }}{% else %}System{% endif %} + {{ log.entity_type or '—' }} {% if log.entity_id %}#{{ log.entity_id }}{% endif %}{{ log.details or '—' }}{{ log.created_at.strftime('%b %d %H:%M') }}
+
+
+{% endblock %} diff --git a/app/templates/admin/kb_edit.html b/app/templates/admin/kb_edit.html new file mode 100644 index 0000000..043a459 --- /dev/null +++ b/app/templates/admin/kb_edit.html @@ -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 %} +
+
+ +
+
+ + {% if article %}Edit: {{ article.title[:40] }}{% else %}Create New Article{% endif %} +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/kb_list.html b/app/templates/admin/kb_list.html new file mode 100644 index 0000000..20c7900 --- /dev/null +++ b/app/templates/admin/kb_list.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Manage Knowledge Base{% endblock %} +{% block page_title %}Knowledge Base Management{% endblock %} + +{% block content %} +
+
+ Articles ({{ articles|length }}) + + New Article + +
+
+ {% if articles %} + + + + + + {% for art in articles %} + + + + + + + + + + {% endfor %} + +
TitleCategoryAuthorPublishedViewsUpdatedActions
{{ art.title[:60] }}{{ art.category or '—' }}{{ art.author.full_name if art.author else '—' }} + {% if art.is_published %} + Yes + {% else %} + Draft + {% endif %} + {{ art.view_count }}{{ art.updated_at.strftime('%b %d, %Y') }} +
+ + +
+ +
+
+
+ {% else %} +
+ + No articles yet. Create your first article → +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html new file mode 100644 index 0000000..a0e9509 --- /dev/null +++ b/app/templates/admin/tickets.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}All Tickets{% endblock %} +{% block page_title %}All Tickets{% endblock %} + +{% block content %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ Tickets + ({{ tickets.total }}) +
+
+ {% if tickets.items %} + + + + + + + + + + + + + + + + {% for t in tickets.items %} + + + + + + + + + + + + {% endfor %} + +
Ticket #TitleCategoryStatusPrioritySubmitted ByAssigned ToDate
{{ t.ticket_number }}{{ t.title }}{{ t.category.replace('_',' ').title() }}{{ t.status.replace('_',' ').upper() }}{{ t.priority.upper() }}{{ t.creator.full_name }}{{ t.assignee.full_name if t.assignee else '—' }}{{ t.created_at.strftime('%b %d') }}
+ + {% if tickets.pages > 1 %} +
+ +
+ {% endif %} + {% else %} +
+ No tickets match the current filters. +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..575a702 --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}User Management{% endblock %} +{% block page_title %}User Management{% endblock %} + +{% block content %} +
+
+ All Users ({{ users|length }}) + + Add User + +
+
+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
NameEmailDepartmentRoleStatusLast LoginActions
+
+
+ {{ u.full_name[0].upper() }} +
+ {{ u.full_name }} +
+
{{ u.email }}{{ u.department or '—' }} + + {{ u.role.replace('_',' ').upper() }} + + + {% if u.is_active %} + Active + {% else %} + Inactive + {% endif %} + + {{ u.last_login.strftime('%b %d, %Y') if u.last_login else 'Never' }} + +
+ + + + {% if u.id != current_user.id and u.is_active %} +
+ +
+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..0c46b41 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,76 @@ + + + + + + Login — TechDesk + + + + + + +
+
+
+
+ +

TechDesk

+

IT Helpdesk Portal — Sign in to continue

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + \ No newline at end of file diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..1f28d01 --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}My Profile{% endblock %} +{% block page_title %}My Profile{% endblock %} + +{% block content %} +
+
+
+
Account Settings
+
+
+ +
+
+ {{ current_user.full_name[0].upper() }} +
+
{{ current_user.email }}
+
+ {{ current_user.role.replace('_',' ').upper() }} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Notification Preferences
+
+ + +
+ +
+
Change Password (leave blank to keep current)
+
+
+ + +
+
+ + +
+
+ +
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..26b1951 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,87 @@ + + + + + Register — TechDesk + + + + + +
+
+
+ +

Create Account

+

Join TechDesk to submit IT support requests

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..017c325 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,588 @@ + + + + + + {% block title %}IT Helpdesk{% endblock %} — TechDesk + + + + + + + {% block head %}{% endblock %} + + + +{% if current_user.is_authenticated %} + + + + +
+
+ +
{% block page_title %}{% endblock %}
+ + + + {{ current_user.full_name.split()[0] }} + +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ + +
+
+
Notifications
+ +
+
+
Loading...
+
+ +
+ + +
+ +
+
+
+
🤖
+
+
IT Assistant
+
● Online
+
+ +
+
+
+ Hi! I'm your IT Assistant 👋 I can help you report issues, create tickets, or answer IT questions.

+ What can I help you with today? +
+
+
+ + +
+
+ +{% else %} + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + {{ self.content() }} +
+
+{% endif %} + + + + + +{% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/tickets/create.html b/app/templates/tickets/create.html new file mode 100644 index 0000000..091995e --- /dev/null +++ b/app/templates/tickets/create.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}New Ticket{% endblock %} +{% block page_title %}Submit a New Ticket{% endblock %} + +{% block content %} +
+
+
+
+ New Support Request +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ Accepted: PNG, JPG, PDF, DOC, DOCX, TXT, ZIP, LOG. Max 16 MB per file. +
+
+ + +
+
+
Priority Guide
+
+
LOW Minor inconvenience, no work stoppage
+
MEDIUM Impacting productivity, workaround available
+
HIGH Significant impact, no workaround
+
CRITICAL Complete outage or security incident
+
+
+
+
+ +
+ + Cancel +
+
+
+
+
+ +
+
+
Prefer Chatting?
+
+

Let our AI assistant guide you through reporting your issue conversationally.

+ +
+
+
+
Tips
+
+

✅ Include error messages exactly as shown

+

✅ Mention what changed recently

+

✅ List affected users or devices

+

✅ Attach relevant screenshots or logs

+
+
+
+
+{% endblock %} diff --git a/app/templates/tickets/dashboard_employee.html b/app/templates/tickets/dashboard_employee.html new file mode 100644 index 0000000..2561e0f --- /dev/null +++ b/app/templates/tickets/dashboard_employee.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + +
+
+
+
+ +
+
+
{{ open_count }}
+
Open Tickets
+
+
+
+
+
+
+ +
+
+
{{ active_count }}
+
In Progress
+
+
+
+
+
+
+ +
+
+
{{ resolved_count }}
+
Resolved
+
+
+
+
+ +
+ +
+
+
+ My Recent Tickets + + New Ticket + +
+
+ {% if my_tickets %} + + + + + + + + + + + + {% for t in my_tickets %} + + + + + + + + {% endfor %} + +
Ticket #TitleStatusPriorityDate
{{ t.ticket_number }}{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %}{{ t.status.replace('_',' ').upper() }}{{ t.priority.upper() }}{{ t.created_at.strftime('%b %d') }}
+ {% else %} +
+ + No tickets yet. Submit your first ticket → +
+ {% endif %} +
+
+
+ + +
+
+
Quick Actions
+ +
+ +
+
+ Knowledge Base + View all → +
+
+ {% if articles %} + {% for art in articles %} + + + {{ art.title[:55] }} + {{ art.view_count }} views + + {% endfor %} + {% else %} +
No articles yet.
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/tickets/dashboard_it.html b/app/templates/tickets/dashboard_it.html new file mode 100644 index 0000000..3a30019 --- /dev/null +++ b/app/templates/tickets/dashboard_it.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}IT Dashboard{% endblock %} +{% block page_title %}IT Operations Dashboard{% endblock %} + +{% block content %} + +
+ {% 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 %} + + {% endfor %} +
+ +
+ +
+
+
+ Assigned to Me + View all → +
+
+ {% if my_tickets %} + + + + {% for t in my_tickets %} + + + + + + + {% endfor %} + +
Ticket #TitlePriorityStatus
{{ t.ticket_number }}{{ t.title[:40] }}{% if t.title|length>40 %}…{% endif %}{{ t.priority.upper() }}{{ t.status.replace('_',' ').upper() }}
+ {% else %} +
No tickets assigned to you.
+ {% endif %} +
+
+
+ + +
+
+
+ Recent Tickets + All tickets → +
+
+ + + + {% for t in recent_tickets %} + + + + + + + {% endfor %} + +
Ticket #UserCategoryPriority
{{ t.ticket_number }}{{ t.creator.full_name.split()[0] }}{{ t.category.replace('_',' ').title() }}{{ t.priority.upper() }}
+
+
+
+
+ + +
+
+
+
Quick Actions
+
+ Open Queue + Unassigned + New KB Article + {% if current_user.is_admin %} + Manage Users + Activity Logs + {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html new file mode 100644 index 0000000..ba7b953 --- /dev/null +++ b/app/templates/tickets/detail.html @@ -0,0 +1,250 @@ +{% extends "base.html" %} +{% block title %}{{ ticket.ticket_number }}{% endblock %} +{% block page_title %}{{ ticket.ticket_number }}{% endblock %} + +{% block content %} +
+ +
+ + +
+
+

{{ ticket.title }}

+
+ {{ ticket.priority.upper() }} + {{ ticket.status.replace('_',' ').upper() }} +
+
+
+ {{ ticket.ticket_number }} + {{ ticket.creator.full_name }} + {{ ticket.category.replace('_',' ').title() }} + {{ ticket.created_at.strftime('%b %d, %Y %H:%M') }} + {% if ticket.location %}{{ ticket.location }}{% endif %} + {% if ticket.asset_tag %}{{ ticket.asset_tag }}{% endif %} + {% if ticket.ai_generated %}AI-generated{% endif %} +
+
+ + +
+
Description
+
{{ ticket.description }}
+
+ + + {% set ticket_atts = ticket.attachments.filter_by(comment_id=None).all() %} + {% if ticket_atts %} +
+
Attachments
+
+ {% for att in ticket_atts %} + + {{ att.filename }} + ({{ (att.file_size/1024)|int }}KB) + + {% endfor %} +
+
+ {% endif %} + + + {% if ticket.resolution_notes %} +
+
+ Resolution Notes +
+
{{ ticket.resolution_notes }}
+
+ {% endif %} + + +
+
+ Comments + ({{ comments|length }}) +
+
+ + {% for comment in comments %} +
+
+
+
+ {{ comment.author.full_name[0].upper() }} +
+ {{ comment.author.full_name }} + {% if comment.author.is_it_staff %} + IT STAFF + {% endif %} + {% if comment.is_internal %} + INTERNAL NOTE + {% endif %} +
+
+ {{ comment.created_at.strftime('%b %d, %Y %H:%M') }} + {% if current_user.is_it_staff or comment.author_id == current_user.id %} +
+ +
+ {% endif %} +
+
+
{{ comment.body }}
+ + {% set c_atts = comment.attachments.all() %} + {% if c_atts %} +
+ {% for att in c_atts %} + + {{ att.filename }} + + {% endfor %} +
+ {% endif %} +
+ {% else %} +
+ + No comments yet. Be the first to add an update. +
+ {% endfor %} + + + {% if ticket.status not in ('closed',) %} +
+
Add Comment
+
+
+
+ +
+
+ + +
+ {% if current_user.is_it_staff %} +
+ + +
+ {% endif %} + +
+
+
+ {% endif %} +
+ + +
+ + + {% if current_user.is_it_staff %} +
+
Update Ticket
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ {% endif %} + + +
+
Ticket Details
+
+ {% macro info_row(icon, label, value) %} +
+ +
{{ label }}
+
{{ value }}
+
+ {% endmacro %} + {{ info_row('bi-hash', 'Number', ''~ticket.ticket_number~'') }} + {{ 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 %} +
+
+ + + {% if history %} +
+
Change History
+
+ {% for h in history %} +
+ {{ h.field_name.replace('_',' ').title() }} + changed from {{ h.old_value or '—' }} to {{ h.new_value or '—' }} +
+ by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }} +
+ {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/tickets/kb_article.html b/app/templates/tickets/kb_article.html new file mode 100644 index 0000000..b38a657 --- /dev/null +++ b/app/templates/tickets/kb_article.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }}{% endblock %} +{% block page_title %}Knowledge Base{% endblock %} + +{% block content %} +
+
+ +
+
+ Article + {% if current_user.is_it_staff %} + + Edit + + {% endif %} +
+
+

{{ article.title }}

+
+ {% if article.category %}{{ article.category.replace('_',' ').title() }}{% endif %} + {{ article.author.full_name if article.author else 'IT Team' }} + {{ article.updated_at.strftime('%B %d, %Y') }} + {{ article.view_count }} views +
+ {% if article.tags %} +
+ {% for tag in article.tags.split(',') %} + {{ tag.strip() }} + {% endfor %} +
+ {% endif %} +
{{ article.body }}
+
+
+
+
+ Did this article solve your issue? + +
+
+
+
+{% endblock %} diff --git a/app/templates/tickets/knowledge_base.html b/app/templates/tickets/knowledge_base.html new file mode 100644 index 0000000..c964ecb --- /dev/null +++ b/app/templates/tickets/knowledge_base.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Knowledge Base{% endblock %} +{% block page_title %}Knowledge Base{% endblock %} + +{% block content %} +
+
+
+
+ Self-Service Articles + {% if current_user.is_it_staff %} + + New Article + + {% endif %} +
+
+ {% if articles %} + {% for art in articles %} + +
+ +
+
+
{{ art.title }}
+
+ {% if art.category %}{{ art.category.replace('_',' ').title() }} · {% endif %} + {{ art.updated_at.strftime('%b %d, %Y') }} +
+
+
+ {{ art.view_count }} +
+
+ {% endfor %} + {% else %} +
+ + No articles published yet. +
+ {% endif %} +
+
+
+
+
+
Can't find an answer?
+
+ + Submit a Ticket + + +
+
+
+
+{% endblock %} diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html new file mode 100644 index 0000000..68b4551 --- /dev/null +++ b/app/templates/tickets/list.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}My Tickets{% endblock %} +{% block page_title %}Tickets{% endblock %} + +{% block content %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + Tickets + ({{ tickets.total }}) + + + New Ticket + +
+
+ {% if tickets.items %} + + + + + + + + + {% if current_user.is_it_staff %}{% endif %} + + + + + + {% for t in tickets.items %} + + + + + + + {% if current_user.is_it_staff %} + + {% endif %} + + + + {% endfor %} + +
Ticket #TitleCategoryStatusPrioritySubmitted ByDate
+ {{ t.ticket_number }} + {% if t.ai_generated %} + + {% endif %} + + + {{ t.title[:55] }}{% if t.title|length > 55 %}…{% endif %} + + {{ t.category.replace('_',' ').title() }}{{ t.status.replace('_',' ').upper() }}{{ t.priority.upper() }}{{ t.creator.full_name }}{{ t.created_at.strftime('%b %d, %Y') }} + + + +
+ + + {% if tickets.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
+ + No tickets found. + {% if search or status or priority or category %} + Clear filters + {% else %} + Submit your first ticket → + {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/tickets/notifications.html b/app/templates/tickets/notifications.html new file mode 100644 index 0000000..c0b5323 --- /dev/null +++ b/app/templates/tickets/notifications.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Notifications{% endblock %} +{% block page_title %}Notifications{% endblock %} + +{% block content %} +
+
+
+
+ All Notifications +
+ +
+
+
+ {% if notifs.items %} + {% for n in notifs.items %} + +
+ {% if not n.is_read %} +
+ {% else %} + + {% endif %} +
+
+
+ {{ n.title }} +
+ {% if n.message %} +
{{ n.message[:120] }}
+ {% endif %} +
+ {{ n.created_at.strftime('%b %d, %Y at %H:%M') }} +
+
+
+ +
+
+ {% endfor %} + + + {% if notifs.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
+ + No notifications yet. +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..01cd637 --- /dev/null +++ b/config/config.py @@ -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 ') + + # 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 +} diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..774a0c1 --- /dev/null +++ b/gunicorn.conf.py @@ -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" diff --git a/it-ticket-system.service b/it-ticket-system.service new file mode 100644 index 0000000..87dc0e0 --- /dev/null +++ b/it-ticket-system.service @@ -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 diff --git a/it-ticket-system.zip b/it-ticket-system.zip new file mode 100644 index 0000000000000000000000000000000000000000..713abf956e2a39b4780e0c7148cef013e0820075 GIT binary patch literal 71636 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

EF@ju6>ZYU@?} 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 DiS6f7 literal 0 HcmV?d00001 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e1d6320 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..54f5b78 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..047a5e2 --- /dev/null +++ b/run.py @@ -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) diff --git a/setup_db.sql b/setup_db.sql new file mode 100644 index 0000000..c261d2f --- /dev/null +++ b/setup_db.sql @@ -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';