05/22 Enhance codes and fix bugs 3

This commit is contained in:
2026-05-22 15:19:34 -04:00
parent 12b5fd9ce0
commit 5c9a124a71
11 changed files with 665 additions and 36 deletions
+41 -3
View File
@@ -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
View File
@@ -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}')
+36
View File
@@ -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}>'
+22 -3
View File
@@ -425,10 +425,14 @@ def all_tickets():
.distinct() .distinct()
) )
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
View File
@@ -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
View File
@@ -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':
+167
View File
@@ -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;">&#128202; 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;">'
'&#9888; 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 &bull; Weekly digest &bull; 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}')
+21
View File
@@ -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 => {
+66 -21
View File
@@ -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;
const catSel = document.querySelector('select[name=category]'); function applyTemplate(data) {
if (catSel) { if (!data) return;
for (const opt of catSel.options) { if (data.title) document.querySelector('input[name=title]').value = data.title;
if (opt.value === cat) { opt.selected = true; break; } if (data.desc) document.querySelector('textarea[name=description]').value = data.desc;
} const catSel = document.querySelector('select[name=category]');
}
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 %}
+117 -1
View File
@@ -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')