05/22 Enhance codes and fix bugs 3
This commit is contained in:
@@ -118,6 +118,8 @@ Both decorators must be applied **after** `@login_required`.
|
|||||||
| `PasswordResetToken` | `password_reset_tokens` | SHA-256 hashed, 1-hour expiry, single-use. |
|
| `PasswordResetToken` | `password_reset_tokens` | SHA-256 hashed, 1-hour expiry, single-use. |
|
||||||
| `TicketTemplate` | `ticket_templates` | Pre-filled ticket scaffolds selectable on the new ticket form. |
|
| `TicketTemplate` | `ticket_templates` | Pre-filled ticket scaffolds selectable on the new ticket form. |
|
||||||
| `TicketSatisfaction` | `ticket_satisfaction` | One survey per resolved ticket; token-authenticated survey URL. |
|
| `TicketSatisfaction` | `ticket_satisfaction` | One survey per resolved ticket; token-authenticated survey URL. |
|
||||||
|
| `TicketWatcher` | `ticket_watchers` | Users subscribed to updates on a ticket. Unique `(ticket_id, user_id)`. Added migration 005. |
|
||||||
|
| `TimeEntry` | `time_entries` | IT staff time log per ticket. Stores `minutes` + optional `note`. Added migration 005. |
|
||||||
|
|
||||||
### Cascade Rules
|
### Cascade Rules
|
||||||
|
|
||||||
@@ -128,6 +130,8 @@ Both decorators must be applied **after** `@login_required`.
|
|||||||
- `history`
|
- `history`
|
||||||
- `links_as_source` / `links_as_target` (via `TicketLink`)
|
- `links_as_source` / `links_as_target` (via `TicketLink`)
|
||||||
- `satisfaction` (via `TicketSatisfaction`)
|
- `satisfaction` (via `TicketSatisfaction`)
|
||||||
|
- `watchers` (via `TicketWatcher`)
|
||||||
|
- `time_entries` (via `TimeEntry`)
|
||||||
|
|
||||||
**When deleting a ticket, physical files in `UPLOAD_FOLDER` must be removed
|
**When deleting a ticket, physical files in `UPLOAD_FOLDER` must be removed
|
||||||
manually before the DB delete** — SQLAlchemy cascades handle DB rows only.
|
manually before the DB delete** — SQLAlchemy cascades handle DB rows only.
|
||||||
@@ -169,6 +173,8 @@ manually before the DB delete** — SQLAlchemy cascades handle DB rows only.
|
|||||||
- `GET /kb` / `GET /kb/<id>` — knowledge base
|
- `GET /kb` / `GET /kb/<id>` — knowledge base
|
||||||
- `POST /tickets/<id>/link` / `POST /tickets/<id>/unlink/<link_id>`
|
- `POST /tickets/<id>/link` / `POST /tickets/<id>/unlink/<link_id>`
|
||||||
- `POST /tickets/<id>/reopen`
|
- `POST /tickets/<id>/reopen`
|
||||||
|
- `POST /tickets/<id>/watch` / `POST /tickets/<id>/unwatch` — AJAX; toggle watcher subscription
|
||||||
|
- `POST /tickets/<id>/log-time` — IT staff only; AJAX-aware; logs `TimeEntry`
|
||||||
- `GET /canned-responses` — JSON endpoint for IT staff comment box
|
- `GET /canned-responses` — JSON endpoint for IT staff comment box
|
||||||
- `POST /kb/<id>/feedback`
|
- `POST /kb/<id>/feedback`
|
||||||
- `GET/POST /survey/<token>` — public (no login required)
|
- `GET/POST /survey/<token>` — public (no login required)
|
||||||
@@ -178,7 +184,7 @@ manually before the DB delete** — SQLAlchemy cascades handle DB rows only.
|
|||||||
- `GET /admin/users` / `POST /admin/users/new` / `POST /admin/users/<id>/edit` / `POST /admin/users/<id>/delete`
|
- `GET /admin/users` / `POST /admin/users/new` / `POST /admin/users/<id>/edit` / `POST /admin/users/<id>/delete`
|
||||||
- `GET /admin/tickets` — all tickets with search, filter, pagination
|
- `GET /admin/tickets` — all tickets with search, filter, pagination
|
||||||
- `POST /admin/tickets/<id>/delete` — **admin only**; deletes ticket + physical files
|
- `POST /admin/tickets/<id>/delete` — **admin only**; deletes ticket + physical files
|
||||||
- `POST /admin/tickets/bulk-action` — actions: resolve, close, assign_me, unassign, **delete** (delete is admin-only)
|
- `POST /admin/tickets/bulk-action` — actions: resolve, close, assign_me, assign_to, unassign, **delete** (delete is admin-only; `assign_to` requires `assign_to_id` form field)
|
||||||
- `GET /admin/tickets/export` — CSV export with active filters
|
- `GET /admin/tickets/export` — CSV export with active filters
|
||||||
- `GET /admin/kb` / `GET/POST /admin/kb/new` / `GET/POST /admin/kb/<id>/edit` / `POST /admin/kb/<id>/delete` / `POST /admin/kb/<id>/publish`
|
- `GET /admin/kb` / `GET/POST /admin/kb/new` / `GET/POST /admin/kb/<id>/edit` / `POST /admin/kb/<id>/delete` / `POST /admin/kb/<id>/publish`
|
||||||
- `POST /admin/kb/upload-image` — TinyMCE image upload
|
- `POST /admin/kb/upload-image` — TinyMCE image upload
|
||||||
@@ -236,18 +242,25 @@ log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id)
|
|||||||
| `ticket_bulk_resolve` | Bulk resolve |
|
| `ticket_bulk_resolve` | Bulk resolve |
|
||||||
| `ticket_bulk_close` | Bulk close |
|
| `ticket_bulk_close` | Bulk close |
|
||||||
| `ticket_bulk_assign_me` | Bulk assign to self |
|
| `ticket_bulk_assign_me` | Bulk assign to self |
|
||||||
|
| `ticket_bulk_assign_to` | Bulk assign to specific user |
|
||||||
| `ticket_bulk_unassign` | Bulk unassign |
|
| `ticket_bulk_unassign` | Bulk unassign |
|
||||||
| `kb_create` / `kb_edit` / `kb_delete` | KB article CRUD |
|
| `kb_create` / `kb_edit` / `kb_delete` | KB article CRUD |
|
||||||
| `kb_attachment_delete` | KB attachment removed |
|
| `kb_attachment_delete` | KB attachment removed |
|
||||||
|
| `time_log` | IT staff logs time on a ticket |
|
||||||
|
| `ticket_create_chatbot` | Ticket created via AI chatbot |
|
||||||
|
|
||||||
### `notification_service.py`
|
### `notification_service.py`
|
||||||
|
|
||||||
Functions: `notify_new_ticket`, `notify_status_change`, `notify_comment_added`,
|
Functions: `notify_new_ticket`, `notify_status_change`, `notify_comment_added`,
|
||||||
`notify_assignment`, `send_satisfaction_survey`
|
`notify_assignment`, `send_satisfaction_survey`, `notify_watchers`,
|
||||||
|
`send_weekly_digest`
|
||||||
|
|
||||||
**Critical rule:** Always call notification functions **after** `db.session.commit()`.
|
**Critical rule:** Always call notification functions **after** `db.session.commit()`.
|
||||||
Calling them before commit means they act on uncommitted state.
|
Calling them before commit means they act on uncommitted state.
|
||||||
|
|
||||||
|
- **`notify_watchers(ticket, event_title, event_message, exclude_user_id=None)`** — sends in-app notifications to all `TicketWatcher` rows for a ticket. Call after commit from `update_ticket`. Pass `exclude_user_id=current_user.id` so the actor doesn't notify themselves.
|
||||||
|
- **`send_weekly_digest(app)`** — APScheduler job; runs every Monday at 08:00 UTC via `CronTrigger`. Sends a rich HTML email to all active IT staff / admin users with `email_notif=True`. Summarises new, resolved, still-open, and overdue tickets for the past 7 days.
|
||||||
|
|
||||||
### `sla_service.py`
|
### `sla_service.py`
|
||||||
|
|
||||||
- `set_due_date(ticket, app)` — called at ticket creation; reads SLA hours from `SystemSetting`
|
- `set_due_date(ticket, app)` — called at ticket creation; reads SLA hours from `SystemSetting`
|
||||||
@@ -488,6 +501,9 @@ Migrations live in `migrations/versions/`. The chain is:
|
|||||||
└── 002_add_system_settings — creates system_settings table
|
└── 002_add_system_settings — creates system_settings table
|
||||||
└── 003_render_comments — backfills comment bodies to HTML;
|
└── 003_render_comments — backfills comment bodies to HTML;
|
||||||
widens alembic_version.version_num to VARCHAR(64)
|
widens alembic_version.version_num to VARCHAR(64)
|
||||||
|
└── 004_widen_system_setting_value — system_settings.value VARCHAR(500) → TEXT
|
||||||
|
└── 005_add_watchers_and_time_entries — creates ticket_watchers
|
||||||
|
and time_entries tables
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
@@ -511,10 +527,13 @@ Migrations live in `migrations/versions/`. The chain is:
|
|||||||
| File upload validation | Magic-byte MIME inspection + extension allowlist |
|
| File upload validation | Magic-byte MIME inspection + extension allowlist |
|
||||||
| HTML sanitization | bleach on all comment bodies and KB article bodies |
|
| HTML sanitization | bleach on all comment bodies and KB article bodies |
|
||||||
| XSS in KB | bleach allowlist covers TinyMCE-produced tags; `_sanitize_kb_body()` in `admin.py` |
|
| XSS in KB | bleach allowlist covers TinyMCE-produced tags; `_sanitize_kb_body()` in `admin.py` |
|
||||||
|
| XSS in JS | `_esc()` helper in `base.html` escapes all server-supplied strings before DOM insertion (notifications, toasts) |
|
||||||
| SQL injection | SQLAlchemy ORM parameterized queries throughout |
|
| SQL injection | SQLAlchemy ORM parameterized queries throughout |
|
||||||
| Real client IP | `ProxyFix` middleware trusts one nginx hop; `log_service._get_real_ip()` reads `X-Forwarded-For` |
|
| Real client IP | `ProxyFix` middleware trusts one nginx hop; `log_service._get_real_ip()` reads `X-Forwarded-For` |
|
||||||
| Role enforcement | `@admin_required` / `@it_required` decorators; per-route checks where needed |
|
| Role enforcement | `@admin_required` / `@it_required` decorators; per-route checks where needed |
|
||||||
| Avatar/file access | All file-serve routes require `@login_required` (except logo) |
|
| Avatar/file access | All file-serve routes require `@login_required` (except logo) |
|
||||||
|
| HTTP security headers | `@app.after_request` hook in `create_app()` sets `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `X-XSS-Protection`, `Permissions-Policy` via `setdefault` (never overrides app-set headers) |
|
||||||
|
| Session cookie security | `ProductionConfig` sets `SESSION_COOKIE_SECURE=True`, `SESSION_COOKIE_HTTPONLY=True`, `SESSION_COOKIE_SAMESITE='Lax'` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -597,6 +616,24 @@ proxies; static files are served directly).
|
|||||||
| Admin ticket deletion (single + bulk) | ✅ |
|
| Admin ticket deletion (single + bulk) | ✅ |
|
||||||
| Activity log viewer | ✅ |
|
| Activity log viewer | ✅ |
|
||||||
| Configurable display timezone | ✅ |
|
| Configurable display timezone | ✅ |
|
||||||
|
| HTTP security headers (X-Frame-Options, CSP-adjacent, etc.) | ✅ |
|
||||||
|
| Session cookie security flags (Secure, HttpOnly, SameSite) | ✅ |
|
||||||
|
| XSS protection in JS notification/toast HTML via `_esc()` | ✅ |
|
||||||
|
| Reusable `confirmModal()` system replacing all `confirm()` dialogs | ✅ |
|
||||||
|
| Toast notifications repositioned to top-right (no FAB overlap) | ✅ |
|
||||||
|
| Page navigation progress bar (thin top bar on link clicks) | ✅ |
|
||||||
|
| Dark mode with localStorage persistence and anti-FOCT script | ✅ |
|
||||||
|
| AJAX save on IT Update Panel (no page reload) | ✅ |
|
||||||
|
| Mobile-responsive tables via `table-responsive` wrappers | ✅ |
|
||||||
|
| Employee dashboard: status progress bar + urgency flags per ticket | ✅ |
|
||||||
|
| Linked tickets shown read-only to employees on ticket detail | ✅ |
|
||||||
|
| SLA status badge on ticket detail (On track / Overdue / Completed) | ✅ |
|
||||||
|
| Template preview panel on new ticket form | ✅ |
|
||||||
|
| Bulk assign to specific IT staff member | ✅ |
|
||||||
|
| Weekly IT digest email (APScheduler, Monday 08:00 UTC) | ✅ |
|
||||||
|
| Chatbot KB context injection (top matching articles in system prompt) | ✅ |
|
||||||
|
| Ticket watchers (subscribe/unwatch, in-app notifications on update) | ✅ |
|
||||||
|
| Time tracking (IT staff log minutes per ticket, AJAX form) | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -608,7 +645,7 @@ proxies; static files are served directly).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: 2026-04-17*
|
*Last updated: 2026-05-22*
|
||||||
|
|
||||||
## 18. Notification Architecture
|
## 18. Notification Architecture
|
||||||
|
|
||||||
@@ -717,6 +754,7 @@ When an error is reported:
|
|||||||
| `002_add_system_settings` | Create `system_settings` table |
|
| `002_add_system_settings` | Create `system_settings` table |
|
||||||
| `003_render_comments` | Backfill comment bodies to HTML; widen `alembic_version.version_num` to `VARCHAR(64)` |
|
| `003_render_comments` | Backfill comment bodies to HTML; widen `alembic_version.version_num` to `VARCHAR(64)` |
|
||||||
| `004_widen_system_setting_value` | Widen `system_settings.value` from `VARCHAR(500)` to `TEXT` |
|
| `004_widen_system_setting_value` | Widen `system_settings.value` from `VARCHAR(500)` to `TEXT` |
|
||||||
|
| `005_add_watchers_and_time_entries` | Create `ticket_watchers` (UniqueConstraint + index) and `time_entries` (index) tables |
|
||||||
|
|
||||||
## 21. Browser Tab Notification Counter (added 2026-04-17)
|
## 21. Browser Tab Notification Counter (added 2026-04-17)
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -230,8 +230,10 @@ def _start_background_schedulers(app):
|
|||||||
try:
|
try:
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from app.services.sla_service import check_sla_breaches
|
from app.services.sla_service import check_sla_breaches
|
||||||
from app.services.email_ingestion_service import check_inbound_email
|
from app.services.email_ingestion_service import check_inbound_email
|
||||||
|
from app.services.notification_service import send_weekly_digest
|
||||||
from app.models import SystemSetting
|
from app.models import SystemSetting
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -259,9 +261,20 @@ def _start_background_schedulers(app):
|
|||||||
args = [app],
|
args = [app],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Weekly digest — every Monday at 08:00 UTC
|
||||||
|
scheduler.add_job(
|
||||||
|
func = send_weekly_digest,
|
||||||
|
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
|
||||||
|
id = 'weekly_digest',
|
||||||
|
name = 'Weekly IT Digest Email',
|
||||||
|
replace_existing = True,
|
||||||
|
args = [app],
|
||||||
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
|
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
|
||||||
f'email ingestion every {email_interval} min')
|
f'email ingestion every {email_interval} min, '
|
||||||
|
'weekly digest every Monday 08:00 UTC')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
|
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
|
||||||
|
|
||||||
|
|||||||
@@ -599,3 +599,39 @@ class TicketSatisfaction(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
|
return f'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
|
||||||
|
|
||||||
|
|
||||||
|
class TicketWatcher(db.Model):
|
||||||
|
"""Users who subscribe to updates on a ticket they didn't create."""
|
||||||
|
__tablename__ = 'ticket_watchers'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint('ticket_id', 'user_id', name='uq_watcher_ticket_user'),)
|
||||||
|
|
||||||
|
ticket = db.relationship('Ticket', backref=db.backref('watchers', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
user = db.relationship('User', backref=db.backref('watching', lazy='dynamic'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TicketWatcher ticket={self.ticket_id} user={self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntry(db.Model):
|
||||||
|
"""IT staff time log for a ticket."""
|
||||||
|
__tablename__ = 'time_entries'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
minutes = db.Column(db.Integer, nullable=False)
|
||||||
|
note = db.Column(db.String(200))
|
||||||
|
logged_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
ticket = db.relationship('Ticket', backref=db.backref('time_entries', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
user = db.relationship('User', backref=db.backref('time_entries', lazy='dynamic'))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TimeEntry ticket={self.ticket_id} minutes={self.minutes}>'
|
||||||
+21
-2
@@ -426,9 +426,13 @@ def all_tickets():
|
|||||||
)
|
)
|
||||||
|
|
||||||
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
|
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
|
||||||
|
it_staff = User.query.filter(
|
||||||
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
|
User.is_active == True
|
||||||
|
).order_by(User.full_name).all()
|
||||||
return render_template('admin/tickets.html', tickets=tickets,
|
return render_template('admin/tickets.html', tickets=tickets,
|
||||||
status=status, priority=priority, assigned=assigned,
|
status=status, priority=priority, assigned=assigned,
|
||||||
search=search)
|
search=search, it_staff=it_staff)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/tickets/<int:ticket_id>/delete', methods=['POST'])
|
@admin_bp.route('/tickets/<int:ticket_id>/delete', methods=['POST'])
|
||||||
@@ -993,11 +997,16 @@ def bulk_ticket_action():
|
|||||||
flash('No tickets selected.', 'warning')
|
flash('No tickets selected.', 'warning')
|
||||||
return redirect(url_for('admin.all_tickets'))
|
return redirect(url_for('admin.all_tickets'))
|
||||||
|
|
||||||
valid_actions = ('resolve', 'close', 'assign_me', 'unassign', 'delete')
|
valid_actions = ('resolve', 'close', 'assign_me', 'unassign', 'assign_to', 'delete')
|
||||||
if action not in valid_actions:
|
if action not in valid_actions:
|
||||||
flash('Invalid action.', 'danger')
|
flash('Invalid action.', 'danger')
|
||||||
return redirect(url_for('admin.all_tickets'))
|
return redirect(url_for('admin.all_tickets'))
|
||||||
|
|
||||||
|
assign_to_id = request.form.get('assign_to_id', type=int)
|
||||||
|
if action == 'assign_to' and not assign_to_id:
|
||||||
|
flash('Please select a staff member to assign to.', 'warning')
|
||||||
|
return redirect(url_for('admin.all_tickets'))
|
||||||
|
|
||||||
# Bulk delete is admin-only
|
# Bulk delete is admin-only
|
||||||
if action == 'delete' and not current_user.is_admin:
|
if action == 'delete' and not current_user.is_admin:
|
||||||
flash('Only administrators may delete tickets.', 'danger')
|
flash('Only administrators may delete tickets.', 'danger')
|
||||||
@@ -1071,6 +1080,15 @@ def bulk_ticket_action():
|
|||||||
current_user.full_name, current_user.id)
|
current_user.full_name, current_user.id)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
elif action == 'assign_to' and ticket.assigned_to_id != assign_to_id:
|
||||||
|
target = db.session.get(User, assign_to_id)
|
||||||
|
if target:
|
||||||
|
ticket.assigned_to_id = assign_to_id
|
||||||
|
log_ticket_history(ticket, 'assigned_to',
|
||||||
|
old_assigned or 'Unassigned',
|
||||||
|
target.full_name, current_user.id)
|
||||||
|
changed = True
|
||||||
|
|
||||||
elif action == 'unassign' and ticket.assigned_to_id is not None:
|
elif action == 'unassign' and ticket.assigned_to_id is not None:
|
||||||
ticket.assigned_to_id = None
|
ticket.assigned_to_id = None
|
||||||
log_ticket_history(ticket, 'assigned_to',
|
log_ticket_history(ticket, 'assigned_to',
|
||||||
@@ -1095,6 +1113,7 @@ def bulk_ticket_action():
|
|||||||
'resolve': 'resolved',
|
'resolve': 'resolved',
|
||||||
'close': 'closed',
|
'close': 'closed',
|
||||||
'assign_me': 'assigned to you',
|
'assign_me': 'assigned to you',
|
||||||
|
'assign_to': f'assigned to {db.session.get(User, assign_to_id).full_name if assign_to_id else "staff"}',
|
||||||
'unassign': 'unassigned',
|
'unassign': 'unassigned',
|
||||||
}
|
}
|
||||||
flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success')
|
flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success')
|
||||||
|
|||||||
+40
-4
@@ -4,7 +4,7 @@ import requests
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory
|
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory, KnowledgeBase
|
||||||
from app.services.notification_service import notify_new_ticket
|
from app.services.notification_service import notify_new_ticket
|
||||||
from app.services.log_service import log_action
|
from app.services.log_service import log_action
|
||||||
|
|
||||||
@@ -35,7 +35,28 @@ _GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'
|
|||||||
_GROQ_MODEL = 'llama-3.3-70b-versatile'
|
_GROQ_MODEL = 'llama-3.3-70b-versatile'
|
||||||
|
|
||||||
|
|
||||||
def _call_groq(api_key, history, user_msg):
|
def _search_kb(query, limit=3):
|
||||||
|
"""Return up to `limit` published KB articles relevant to the user query.
|
||||||
|
|
||||||
|
Uses a simple keyword presence check against the title and tags columns.
|
||||||
|
This avoids full-text indexes and works across all MySQL configs.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
words = [w for w in re.split(r'\W+', query.lower()) if len(w) > 3]
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
articles = KnowledgeBase.query.filter_by(is_published=True).all()
|
||||||
|
scored = []
|
||||||
|
for art in articles:
|
||||||
|
haystack = (art.title + ' ' + (art.tags or '')).lower()
|
||||||
|
score = sum(1 for w in words if w in haystack)
|
||||||
|
if score:
|
||||||
|
scored.append((score, art))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
return [art for _, art in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def _call_groq(api_key, history, user_msg, kb_context=''):
|
||||||
"""
|
"""
|
||||||
Call the Groq API (OpenAI-compatible) and return the assistant's reply text.
|
Call the Groq API (OpenAI-compatible) and return the assistant's reply text.
|
||||||
|
|
||||||
@@ -78,8 +99,12 @@ def _call_groq(api_key, history, user_msg):
|
|||||||
for msg in clean_history
|
for msg in clean_history
|
||||||
]
|
]
|
||||||
|
|
||||||
|
system_content = _SYSTEM_PROMPT
|
||||||
|
if kb_context:
|
||||||
|
system_content += '\n\n' + kb_context
|
||||||
|
|
||||||
messages = (
|
messages = (
|
||||||
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
[{'role': 'system', 'content': system_content}]
|
||||||
+ clean_history
|
+ clean_history
|
||||||
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
|
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
|
||||||
)
|
)
|
||||||
@@ -119,8 +144,19 @@ def chat():
|
|||||||
'ticket': None,
|
'ticket': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Inject relevant KB articles as context so the chatbot can reference
|
||||||
|
# self-help content before suggesting a ticket is needed.
|
||||||
|
kb_articles = _search_kb(user_msg)
|
||||||
|
kb_context = ''
|
||||||
|
if kb_articles:
|
||||||
|
lines = ['RELEVANT KNOWLEDGE BASE ARTICLES (reference these if applicable):']
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
for art in kb_articles:
|
||||||
|
lines.append(f'- {art.title}: {base_url}/kb/{art.id}')
|
||||||
|
kb_context = '\n'.join(lines)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reply_text = _call_groq(api_key, history, user_msg)
|
reply_text = _call_groq(api_key, history, user_msg, kb_context=kb_context)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f'[CHATBOT API ERROR] {exc}')
|
logger.error(f'[CHATBOT API ERROR] {exc}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
+77
-2
@@ -10,11 +10,12 @@ from app import db
|
|||||||
from app.models import (Ticket, Comment, Attachment, Notification,
|
from app.models import (Ticket, Comment, Attachment, Notification,
|
||||||
TicketStatus, TicketPriority, TicketCategory,
|
TicketStatus, TicketPriority, TicketCategory,
|
||||||
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
|
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
|
||||||
KBFeedback, TicketTemplate, TicketSatisfaction)
|
KBFeedback, TicketTemplate, TicketSatisfaction,
|
||||||
|
TicketWatcher, TimeEntry)
|
||||||
from app.services.notification_service import (
|
from app.services.notification_service import (
|
||||||
notify_new_ticket, notify_status_change,
|
notify_new_ticket, notify_status_change,
|
||||||
notify_comment_added, notify_assignment,
|
notify_comment_added, notify_assignment,
|
||||||
send_satisfaction_survey,
|
send_satisfaction_survey, notify_watchers,
|
||||||
)
|
)
|
||||||
from app.services.log_service import log_action, log_ticket_history
|
from app.services.log_service import log_action, log_ticket_history
|
||||||
from app.services.sla_service import clear_sla_notification
|
from app.services.sla_service import clear_sla_notification
|
||||||
@@ -446,12 +447,21 @@ def ticket_detail(ticket_id):
|
|||||||
CannedResponse.category, CannedResponse.title
|
CannedResponse.category, CannedResponse.title
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
is_watching = TicketWatcher.query.filter_by(
|
||||||
|
ticket_id=ticket_id, user_id=current_user.id
|
||||||
|
).first() is not None
|
||||||
|
total_minutes = sum(e.minutes for e in ticket.time_entries.all())
|
||||||
|
|
||||||
return render_template('tickets/detail.html',
|
return render_template('tickets/detail.html',
|
||||||
ticket=ticket, comments=comments,
|
ticket=ticket, comments=comments,
|
||||||
it_staff=it_staff, history=history,
|
it_staff=it_staff, history=history,
|
||||||
statuses=_statuses(), priorities=_priorities(),
|
statuses=_statuses(), priorities=_priorities(),
|
||||||
linked_tickets=linked_tickets,
|
linked_tickets=linked_tickets,
|
||||||
canned_responses=canned_responses,
|
canned_responses=canned_responses,
|
||||||
|
now=datetime.utcnow(),
|
||||||
|
is_watching=is_watching,
|
||||||
|
total_minutes=total_minutes,
|
||||||
|
time_entries=ticket.time_entries.order_by(TimeEntry.logged_at.desc()).limit(10).all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -538,6 +548,14 @@ def update_ticket(ticket_id):
|
|||||||
if new_status == TicketStatus.RESOLVED:
|
if new_status == TicketStatus.RESOLVED:
|
||||||
send_satisfaction_survey(ticket)
|
send_satisfaction_survey(ticket)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
notify_watchers(
|
||||||
|
ticket,
|
||||||
|
event_title = f'Ticket {ticket.ticket_number} updated',
|
||||||
|
event_message = '; '.join(changes),
|
||||||
|
exclude_user_id = current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
return jsonify({'ok': True, 'changes': changes,
|
return jsonify({'ok': True, 'changes': changes,
|
||||||
@@ -976,6 +994,63 @@ def ticket_survey(token):
|
|||||||
quick_rating=quick_rating)
|
quick_rating=quick_rating)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Watchers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/<int:ticket_id>/watch', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def watch_ticket(ticket_id):
|
||||||
|
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||||
|
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||||
|
abort(403)
|
||||||
|
existing = TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).first()
|
||||||
|
if not existing:
|
||||||
|
db.session.add(TicketWatcher(ticket_id=ticket_id, user_id=current_user.id))
|
||||||
|
db.session.commit()
|
||||||
|
from flask import jsonify
|
||||||
|
return jsonify({'watching': True})
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/<int:ticket_id>/unwatch', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def unwatch_ticket(ticket_id):
|
||||||
|
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||||
|
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||||
|
abort(403)
|
||||||
|
TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).delete()
|
||||||
|
db.session.commit()
|
||||||
|
from flask import jsonify
|
||||||
|
return jsonify({'watching': False})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Time Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tickets_bp.route('/tickets/<int:ticket_id>/log-time', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def log_time(ticket_id):
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
abort(403)
|
||||||
|
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||||
|
try:
|
||||||
|
minutes = int(request.form.get('minutes', 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
minutes = 0
|
||||||
|
if minutes <= 0:
|
||||||
|
flash('Please enter a valid number of minutes.', 'warning')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
note = request.form.get('note', '').strip()[:200]
|
||||||
|
entry = TimeEntry(ticket_id=ticket_id, user_id=current_user.id, minutes=minutes, note=note)
|
||||||
|
db.session.add(entry)
|
||||||
|
log_action(current_user.id, 'time_log', 'ticket', ticket_id,
|
||||||
|
f'minutes={minutes} note={note!r}')
|
||||||
|
db.session.commit()
|
||||||
|
from flask import jsonify
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
total = sum(e.minutes for e in ticket.time_entries.all())
|
||||||
|
return jsonify({'ok': True, 'minutes': minutes, 'total_minutes': total})
|
||||||
|
flash(f'{minutes} minutes logged.', 'success')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _sla_due_date(priority: str) -> 'datetime':
|
def _sla_due_date(priority: str) -> 'datetime':
|
||||||
|
|||||||
@@ -546,4 +546,171 @@ def send_satisfaction_survey(ticket):
|
|||||||
from flask import has_request_context
|
from flask import has_request_context
|
||||||
t = Thread(target=_send, daemon=has_request_context())
|
t = Thread(target=_send, daemon=has_request_context())
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Watcher Notifications ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def notify_watchers(ticket, event_title, event_message, exclude_user_id=None):
|
||||||
|
"""Send in-app notifications to all watchers of a ticket.
|
||||||
|
|
||||||
|
Watchers are notified of status changes, new comments, and assignments.
|
||||||
|
The user who triggered the event (exclude_user_id) is skipped so they
|
||||||
|
don't receive a notification about their own action.
|
||||||
|
"""
|
||||||
|
from app.models import TicketWatcher, NotificationType
|
||||||
|
watchers = TicketWatcher.query.filter_by(ticket_id=ticket.id).all()
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
|
link = f'{base_url}/tickets/{ticket.id}'
|
||||||
|
for w in watchers:
|
||||||
|
if w.user_id == exclude_user_id:
|
||||||
|
continue
|
||||||
|
create_notification(
|
||||||
|
user_id = w.user_id,
|
||||||
|
notif_type = NotificationType.TICKET_UPDATE,
|
||||||
|
title = event_title,
|
||||||
|
message = event_message,
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
link = link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Weekly IT Digest ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_weekly_digest(app):
|
||||||
|
"""Send a weekly summary email to all active IT staff / admin users.
|
||||||
|
|
||||||
|
Summarises the past 7 days: new tickets, resolved tickets, still-open
|
||||||
|
tickets with priority breakdown, and any SLA-overdue tickets.
|
||||||
|
Intended to be called once a week by an APScheduler job.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.models import Ticket, TicketStatus, TicketPriority
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
base_url = app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
|
||||||
|
new_tickets = Ticket.query.filter(Ticket.created_at >= week_ago).all()
|
||||||
|
resolved = Ticket.query.filter(
|
||||||
|
Ticket.resolved_at >= week_ago
|
||||||
|
).all()
|
||||||
|
open_tickets = Ticket.query.filter(
|
||||||
|
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
|
||||||
|
).order_by(Ticket.created_at.asc()).all()
|
||||||
|
overdue = [t for t in open_tickets if t.due_date and t.due_date < now]
|
||||||
|
|
||||||
|
# Priority breakdown for open tickets
|
||||||
|
prio_counts = {}
|
||||||
|
for t in open_tickets:
|
||||||
|
prio_counts[t.priority] = prio_counts.get(t.priority, 0) + 1
|
||||||
|
|
||||||
|
# Build ticket rows HTML
|
||||||
|
def _ticket_rows(tickets, limit=10):
|
||||||
|
rows = ''
|
||||||
|
for t in tickets[:limit]:
|
||||||
|
prio_color = _priority_badge_color(t.priority)
|
||||||
|
url = f'{base_url}/tickets/{t.id}'
|
||||||
|
rows += (
|
||||||
|
f'<tr>'
|
||||||
|
f'<td style="padding:6px 8px;font-family:monospace;font-size:12px;color:#0891b2;">'
|
||||||
|
f'<a href="{url}" style="color:#0891b2;text-decoration:none;">{t.ticket_number}</a></td>'
|
||||||
|
f'<td style="padding:6px 8px;font-size:13px;">{t.title[:60]}</td>'
|
||||||
|
f'<td style="padding:6px 8px;"><span style="background:{prio_color};color:#fff;'
|
||||||
|
f'padding:1px 7px;border-radius:4px;font-size:11px;">{t.priority.upper()}</span></td>'
|
||||||
|
f'<td style="padding:6px 8px;font-size:12px;color:#64748b;">'
|
||||||
|
f'{t.assignee.full_name if t.assignee else "—"}</td>'
|
||||||
|
f'</tr>'
|
||||||
|
)
|
||||||
|
if len(tickets) > limit:
|
||||||
|
rows += (
|
||||||
|
f'<tr><td colspan="4" style="padding:6px 8px;font-size:12px;color:#64748b;text-align:center;">'
|
||||||
|
f'… and {len(tickets) - limit} more</td></tr>'
|
||||||
|
)
|
||||||
|
return rows or '<tr><td colspan="4" style="padding:6px 8px;color:#94a3b8;font-size:13px;">None</td></tr>'
|
||||||
|
|
||||||
|
dashboard_url = f'{base_url}/admin/'
|
||||||
|
|
||||||
|
html = (
|
||||||
|
'<html><body style="font-family:Arial,sans-serif;background:#f0f4f8;padding:20px;">'
|
||||||
|
'<div style="max-width:680px;margin:0 auto;background:#fff;border-radius:10px;'
|
||||||
|
'overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">'
|
||||||
|
'<div style="background:#1e293b;padding:24px 32px;">'
|
||||||
|
f'<h1 style="color:#fff;margin:0;font-size:20px;">📊 Weekly IT Summary</h1>'
|
||||||
|
f'<p style="color:#94a3b8;margin:6px 0 0;font-size:13px;">'
|
||||||
|
f'{week_ago.strftime("%b %d")} – {now.strftime("%b %d, %Y")}</p>'
|
||||||
|
'</div>'
|
||||||
|
'<div style="padding:28px 32px;">'
|
||||||
|
|
||||||
|
# KPI row
|
||||||
|
'<div style="display:flex;gap:16px;margin-bottom:24px;">'
|
||||||
|
f'<div style="flex:1;background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">'
|
||||||
|
f'<div style="font-size:28px;font-weight:700;color:#059669;">{len(new_tickets)}</div>'
|
||||||
|
f'<div style="font-size:12px;color:#64748b;">New this week</div></div>'
|
||||||
|
f'<div style="flex:1;background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">'
|
||||||
|
f'<div style="font-size:28px;font-weight:700;color:#2563eb;">{len(resolved)}</div>'
|
||||||
|
f'<div style="font-size:12px;color:#64748b;">Resolved this week</div></div>'
|
||||||
|
f'<div style="flex:1;background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">'
|
||||||
|
f'<div style="font-size:28px;font-weight:700;color:#dc2626;">{len(open_tickets)}</div>'
|
||||||
|
f'<div style="font-size:12px;color:#64748b;">Still open</div></div>'
|
||||||
|
f'<div style="flex:1;background:#fffbeb;border-radius:8px;padding:16px;text-align:center;">'
|
||||||
|
f'<div style="font-size:28px;font-weight:700;color:#d97706;">{len(overdue)}</div>'
|
||||||
|
f'<div style="font-size:12px;color:#64748b;">Overdue</div></div>'
|
||||||
|
'</div>'
|
||||||
|
|
||||||
|
# Open tickets table
|
||||||
|
'<h3 style="font-size:14px;font-weight:700;color:#1e293b;margin:0 0 10px;">Open Tickets</h3>'
|
||||||
|
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||||||
|
'<thead><tr style="background:#f8fafc;">'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||||||
|
'</tr></thead>'
|
||||||
|
f'<tbody>{_ticket_rows(open_tickets)}</tbody>'
|
||||||
|
'</table>'
|
||||||
|
|
||||||
|
# Overdue section (only if any)
|
||||||
|
+ (
|
||||||
|
'<h3 style="font-size:14px;font-weight:700;color:#dc2626;margin:0 0 10px;">'
|
||||||
|
'⚠ Overdue Tickets</h3>'
|
||||||
|
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||||||
|
'<thead><tr style="background:#fef2f2;">'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||||||
|
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||||||
|
'</tr></thead>'
|
||||||
|
f'<tbody>{_ticket_rows(overdue)}</tbody>'
|
||||||
|
'</table>'
|
||||||
|
if overdue else ''
|
||||||
|
) +
|
||||||
|
|
||||||
|
f'<a href="{dashboard_url}" style="display:inline-block;background:#2563eb;color:#fff;'
|
||||||
|
f'padding:11px 22px;border-radius:6px;text-decoration:none;font-size:13px;">Open Dashboard</a>'
|
||||||
|
'</div>'
|
||||||
|
'<div style="background:#f8fafc;padding:14px 32px;text-align:center;'
|
||||||
|
'color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">'
|
||||||
|
'TechDesk IT Helpdesk • Weekly digest • Automated message'
|
||||||
|
'</div></div></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
staff = User.query.filter(
|
||||||
|
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||||
|
User.is_active == True,
|
||||||
|
User.email_notif == True,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not staff:
|
||||||
|
logger.info('[DIGEST] No staff with email notifications enabled — skipping digest')
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = f'[TechDesk] Weekly IT Summary — {now.strftime("%b %d, %Y")}'
|
||||||
|
for member in staff:
|
||||||
|
send_email(subject, [member.email], html)
|
||||||
|
logger.info(f'[DIGEST] Sent weekly digest to {member.email}')
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f'[DIGEST] Failed to send weekly digest: {exc}', exc_info=True)
|
||||||
logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')
|
logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')
|
||||||
@@ -207,6 +207,19 @@
|
|||||||
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;">
|
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;">
|
||||||
<i class="bi bi-person-check me-1"></i>Assign to Me
|
<i class="bi bi-person-check me-1"></i>Assign to Me
|
||||||
</button>
|
</button>
|
||||||
|
<div class="d-flex gap-1 align-items-center">
|
||||||
|
<select name="assign_to_id" id="bulk-assign-select"
|
||||||
|
style="background:#2d3f57;color:#fff;border:1px solid #4a5e78;border-radius:6px;padding:4px 8px;font-size:12px;height:30px;">
|
||||||
|
<option value="">Assign to…</option>
|
||||||
|
{% for s in it_staff %}
|
||||||
|
<option value="{{ s.id }}">{{ s.full_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" name="action" value="assign_to" id="bulk-assign-btn"
|
||||||
|
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;" disabled>
|
||||||
|
<i class="bi bi-person-check-fill me-1"></i>Assign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="submit" name="action" value="unassign"
|
<button type="submit" name="action" value="unassign"
|
||||||
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
|
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
|
||||||
<i class="bi bi-person-dash me-1"></i>Unassign
|
<i class="bi bi-person-dash me-1"></i>Unassign
|
||||||
@@ -258,6 +271,14 @@
|
|||||||
updateBar();
|
updateBar();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assignSelect = document.getElementById('bulk-assign-select');
|
||||||
|
const assignBtn = document.getElementById('bulk-assign-btn');
|
||||||
|
if (assignSelect && assignBtn) {
|
||||||
|
assignSelect.addEventListener('change', () => {
|
||||||
|
assignBtn.disabled = !assignSelect.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (selectAll) {
|
if (selectAll) {
|
||||||
selectAll.addEventListener('change', () => {
|
selectAll.addEventListener('change', () => {
|
||||||
document.querySelectorAll('.ticket-cb').forEach(cb => {
|
document.querySelectorAll('.ticket-cb').forEach(cb => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="col-6 col-md-4">
|
<div class="col-6 col-md-4">
|
||||||
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
|
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
|
||||||
style="padding:10px 14px;font-size:13px;"
|
style="padding:10px 14px;font-size:13px;"
|
||||||
|
data-name="{{ t.name }}"
|
||||||
data-title="{{ t.title_hint }}"
|
data-title="{{ t.title_hint }}"
|
||||||
data-category="{{ t.category }}"
|
data-category="{{ t.category }}"
|
||||||
data-priority="{{ t.priority }}"
|
data-priority="{{ t.priority }}"
|
||||||
@@ -26,6 +27,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Template preview panel -->
|
||||||
|
<div id="tmpl-preview" style="display:none;margin-top:14px;padding:14px 16px;
|
||||||
|
background:var(--surface2);border:1px solid var(--border);border-radius:8px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
||||||
|
<span style="font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;">Preview</span>
|
||||||
|
<button type="button" id="tmpl-apply-btn" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check2 me-1"></i>Apply Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px;" id="tmpl-prev-title">—</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||||||
|
<span style="font-size:11px;padding:1px 8px;border-radius:4px;background:var(--border);color:var(--muted);" id="tmpl-prev-cat">—</span>
|
||||||
|
<span id="tmpl-prev-prio" class="badge">—</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.55;max-height:100px;overflow-y:auto;" id="tmpl-prev-desc">—</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -135,33 +153,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('.template-btn').forEach(btn => {
|
(function () {
|
||||||
btn.addEventListener('click', () => {
|
const preview = document.getElementById('tmpl-preview');
|
||||||
const title = btn.dataset.title;
|
const applyBtn = document.getElementById('tmpl-apply-btn');
|
||||||
const cat = btn.dataset.category;
|
const prevTitle = document.getElementById('tmpl-prev-title');
|
||||||
const prio = btn.dataset.priority;
|
const prevCat = document.getElementById('tmpl-prev-cat');
|
||||||
const desc = btn.dataset.description;
|
const prevPrio = document.getElementById('tmpl-prev-prio');
|
||||||
|
const prevDesc = document.getElementById('tmpl-prev-desc');
|
||||||
|
|
||||||
if (title) document.querySelector('input[name=title]').value = title;
|
let activeData = null;
|
||||||
if (desc) document.querySelector('textarea[name=description]').value = desc;
|
|
||||||
|
|
||||||
|
function applyTemplate(data) {
|
||||||
|
if (!data) return;
|
||||||
|
if (data.title) document.querySelector('input[name=title]').value = data.title;
|
||||||
|
if (data.desc) document.querySelector('textarea[name=description]').value = data.desc;
|
||||||
const catSel = document.querySelector('select[name=category]');
|
const catSel = document.querySelector('select[name=category]');
|
||||||
if (catSel) {
|
|
||||||
for (const opt of catSel.options) {
|
|
||||||
if (opt.value === cat) { opt.selected = true; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const prioSel = document.querySelector('select[name=priority]');
|
const prioSel = document.querySelector('select[name=priority]');
|
||||||
if (prioSel) {
|
if (catSel) { for (const o of catSel.options) { if (o.value === data.cat) { o.selected = true; break; } } }
|
||||||
for (const opt of prioSel.options) {
|
if (prioSel) { for (const o of prioSel.options) { if (o.value === data.prio) { o.selected = true; break; } } }
|
||||||
if (opt.value === prio) { opt.selected = true; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Scroll to form
|
|
||||||
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
|
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
|
||||||
document.querySelector('input[name=title]').focus();
|
document.querySelector('input[name=title]').focus();
|
||||||
|
// Highlight briefly
|
||||||
|
document.querySelector('input[name=title]').style.outline = '2px solid var(--accent)';
|
||||||
|
setTimeout(() => { document.querySelector('input[name=title]').style.outline = ''; }, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.template-btn').forEach(btn => {
|
||||||
|
function showPreview() {
|
||||||
|
activeData = {
|
||||||
|
name: btn.dataset.name,
|
||||||
|
title: btn.dataset.title,
|
||||||
|
cat: btn.dataset.category,
|
||||||
|
prio: btn.dataset.priority,
|
||||||
|
desc: btn.dataset.description,
|
||||||
|
};
|
||||||
|
prevTitle.textContent = activeData.title || activeData.name;
|
||||||
|
prevCat.textContent = (activeData.cat || '').replace(/_/g, ' ');
|
||||||
|
prevDesc.textContent = activeData.desc || '(no description)';
|
||||||
|
prevPrio.textContent = (activeData.prio || '').toUpperCase();
|
||||||
|
prevPrio.className = 'badge badge-' + (activeData.prio || 'medium');
|
||||||
|
preview.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('mouseenter', showPreview);
|
||||||
|
btn.addEventListener('focus', showPreview);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
showPreview();
|
||||||
|
applyTemplate(activeData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.addEventListener('click', () => applyTemplate(activeData));
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -811,11 +811,103 @@ function buildCommentEl(c) {
|
|||||||
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
|
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
|
||||||
{{ info_row('bi-calendar3', 'Created', ticket.created_at | localtime("%b %d, %Y")) }}
|
{{ info_row('bi-calendar3', 'Created', ticket.created_at | localtime("%b %d, %Y")) }}
|
||||||
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at | localtime("%b %d, %Y")) }}
|
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at | localtime("%b %d, %Y")) }}
|
||||||
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date | localtime("%b %d, %Y")) }}{% endif %}
|
{% if ticket.due_date %}
|
||||||
|
{% set is_done = ticket.status in ('resolved', 'closed') %}
|
||||||
|
{% set overdue = not is_done and ticket.due_date < now %}
|
||||||
|
{% set due_str = ticket.due_date | localtime("%b %d, %Y") %}
|
||||||
|
{% if is_done %}
|
||||||
|
{% set sla_badge = '<span style="font-size:10px;padding:1px 6px;border-radius:4px;background:rgba(5,150,105,.12);color:var(--success);font-weight:600;margin-left:6px;">Completed</span>' %}
|
||||||
|
{% elif overdue %}
|
||||||
|
{% set sla_badge = '<span style="font-size:10px;padding:1px 6px;border-radius:4px;background:rgba(220,38,38,.12);color:var(--danger);font-weight:600;margin-left:6px;"><i class=\"bi bi-exclamation-triangle-fill\"></i> Overdue</span>' %}
|
||||||
|
{% else %}
|
||||||
|
{% set sla_badge = '<span style="font-size:10px;padding:1px 6px;border-radius:4px;background:rgba(5,150,105,.12);color:var(--success);font-weight:600;margin-left:6px;">On track</span>' %}
|
||||||
|
{% endif %}
|
||||||
|
{{ info_row('bi-alarm', 'SLA Due', due_str ~ sla_badge) }}
|
||||||
|
{% endif %}
|
||||||
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at | localtime("%b %d, %Y")) }}{% endif %}
|
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at | localtime("%b %d, %Y")) }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Watch / Unwatch -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
|
||||||
|
<span style="font-size:13px;color:var(--muted);">
|
||||||
|
<i class="bi bi-eye me-1"></i>
|
||||||
|
<span id="watch-label">{% if is_watching %}Watching this ticket{% else %}Not watching{% endif %}</span>
|
||||||
|
</span>
|
||||||
|
<button id="watch-btn" class="btn btn-sm {% if is_watching %}btn-secondary{% else %}btn-outline-secondary{% endif %}"
|
||||||
|
style="font-size:12px;"
|
||||||
|
onclick="toggleWatch({{ ticket.id }}, {{ 'true' if is_watching else 'false' }})">
|
||||||
|
<i class="bi {% if is_watching %}bi-eye-slash{% else %}bi-eye{% endif %}" id="watch-icon"></i>
|
||||||
|
<span id="watch-btn-text">{% if is_watching %}Unwatch{% else %}Watch{% endif %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Tracking (IT staff only) -->
|
||||||
|
{% if current_user.is_it_staff %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-clock me-2"></i>Time Logged</span>
|
||||||
|
<span style="font-size:12px;color:var(--muted);" id="total-time-display">
|
||||||
|
{{ (total_minutes // 60) }}h {{ (total_minutes % 60) }}m total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="time-log-form" class="d-flex gap-2 align-items-end mb-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div style="flex:0 0 80px;">
|
||||||
|
<label class="form-label" style="font-size:11px;margin-bottom:3px;">Minutes</label>
|
||||||
|
<input type="number" name="minutes" min="1" max="480" class="form-control form-control-sm"
|
||||||
|
placeholder="30" id="time-minutes-input"/>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="form-label" style="font-size:11px;margin-bottom:3px;">Note (optional)</label>
|
||||||
|
<input type="text" name="note" maxlength="200" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. Investigated logs"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Log</button>
|
||||||
|
</form>
|
||||||
|
{% if time_entries %}
|
||||||
|
<div style="font-size:12px;">
|
||||||
|
{% for e in time_entries %}
|
||||||
|
<div class="d-flex justify-content-between py-1" style="border-top:1px solid var(--border);">
|
||||||
|
<span style="color:var(--muted);">{{ e.user.full_name.split()[0] }} — {{ e.note or '—' }}</span>
|
||||||
|
<span style="font-weight:600;white-space:nowrap;">{{ e.minutes }}m</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('time-log-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(form);
|
||||||
|
fetch('{{ url_for("tickets.log_time", ticket_id=ticket.id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content},
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.ok) {
|
||||||
|
const h = Math.floor(d.total_minutes / 60), m = d.total_minutes % 60;
|
||||||
|
document.getElementById('total-time-display').textContent = h + 'h ' + m + 'm total';
|
||||||
|
form.reset();
|
||||||
|
showToast('Time logged', d.minutes + ' minutes logged successfully.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => showToast('Error', 'Could not log time. Please try again.'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
|
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
|
||||||
{% if ticket.satisfaction %}
|
{% if ticket.satisfaction %}
|
||||||
@@ -1050,4 +1142,28 @@ function buildCommentEl(c) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function toggleWatch(ticketId, currently) {
|
||||||
|
const action = currently ? 'unwatch' : 'watch';
|
||||||
|
fetch('/tickets/' + ticketId + '/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const nowWatching = d.watching;
|
||||||
|
document.getElementById('watch-label').textContent = nowWatching ? 'Watching this ticket' : 'Not watching';
|
||||||
|
document.getElementById('watch-icon').className = nowWatching ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
document.getElementById('watch-btn-text').textContent = nowWatching ? 'Unwatch' : 'Watch';
|
||||||
|
const btn = document.getElementById('watch-btn');
|
||||||
|
btn.className = 'btn btn-sm ' + (nowWatching ? 'btn-secondary' : 'btn-outline-secondary');
|
||||||
|
btn.setAttribute('onclick', 'toggleWatch(' + ticketId + ', ' + (nowWatching ? 'true' : 'false') + ')');
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Add ticket_watchers and time_entries tables
|
||||||
|
|
||||||
|
Revision ID: 005_add_watchers_and_time_entries
|
||||||
|
Revises: 004_widen_system_setting_value
|
||||||
|
Create Date: 2026-05-22
|
||||||
|
|
||||||
|
Rationale
|
||||||
|
---------
|
||||||
|
Two new features require dedicated tables:
|
||||||
|
|
||||||
|
ticket_watchers — lets any logged-in user subscribe to ticket updates beyond
|
||||||
|
the ticket creator and assignee. One row per (ticket, user) pair; cascade
|
||||||
|
deletes with the ticket.
|
||||||
|
|
||||||
|
time_entries — lets IT staff log minutes worked on a ticket, with an optional
|
||||||
|
short note. Cascade deletes with the ticket.
|
||||||
|
|
||||||
|
Apply
|
||||||
|
-----
|
||||||
|
flask db upgrade
|
||||||
|
|
||||||
|
Rollback
|
||||||
|
--------
|
||||||
|
flask db downgrade
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '005_add_watchers_and_time_entries'
|
||||||
|
down_revision = '004_widen_system_setting_value'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'ticket_watchers',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('ticket_id', sa.Integer, sa.ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint('ticket_id', 'user_id', name='uq_watcher_ticket_user'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_ticket_watchers_ticket_id', 'ticket_watchers', ['ticket_id'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'time_entries',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('ticket_id', sa.Integer, sa.ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('minutes', sa.Integer, nullable=False),
|
||||||
|
sa.Column('note', sa.String(200)),
|
||||||
|
sa.Column('logged_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index('ix_time_entries_ticket_id', 'time_entries', ['ticket_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_time_entries_ticket_id', table_name='time_entries')
|
||||||
|
op.drop_table('time_entries')
|
||||||
|
op.drop_index('ix_ticket_watchers_ticket_id', table_name='ticket_watchers')
|
||||||
|
op.drop_table('ticket_watchers')
|
||||||
Reference in New Issue
Block a user