commit dfdbb548752c8aaf8597787d9614b6aadb0baa84 Author: NguyenND Date: Wed Mar 25 11:37:46 2026 -0400 Initial commit 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 0000000..713abf9 Binary files /dev/null and b/it-ticket-system.zip differ 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';