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. |
| `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. |
| `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
@@ -128,6 +130,8 @@ Both decorators must be applied **after** `@login_required`.
- `history`
- `links_as_source` / `links_as_target` (via `TicketLink`)
- `satisfaction` (via `TicketSatisfaction`)
- `watchers` (via `TicketWatcher`)
- `time_entries` (via `TimeEntry`)
**When deleting a ticket, physical files in `UPLOAD_FOLDER` must be removed
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
- `POST /tickets/<id>/link` / `POST /tickets/<id>/unlink/<link_id>`
- `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
- `POST /kb/<id>/feedback`
- `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/tickets` — all tickets with search, filter, pagination
- `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/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
@@ -236,18 +242,25 @@ log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id)
| `ticket_bulk_resolve` | Bulk resolve |
| `ticket_bulk_close` | Bulk close |
| `ticket_bulk_assign_me` | Bulk assign to self |
| `ticket_bulk_assign_to` | Bulk assign to specific user |
| `ticket_bulk_unassign` | Bulk unassign |
| `kb_create` / `kb_edit` / `kb_delete` | KB article CRUD |
| `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`
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()`.
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`
- `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
└── 003_render_comments — backfills comment bodies to HTML;
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:**
@@ -511,10 +527,13 @@ Migrations live in `migrations/versions/`. The chain is:
| File upload validation | Magic-byte MIME inspection + extension allowlist |
| 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 JS | `_esc()` helper in `base.html` escapes all server-supplied strings before DOM insertion (notifications, toasts) |
| 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` |
| Role enforcement | `@admin_required` / `@it_required` decorators; per-route checks where needed |
| 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) | ✅ |
| Activity log viewer | ✅ |
| 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
@@ -717,6 +754,7 @@ When an error is reported:
| `002_add_system_settings` | Create `system_settings` table |
| `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` |
| `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)
+14 -1
View File
@@ -230,8 +230,10 @@ def _start_background_schedulers(app):
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from app.services.sla_service import check_sla_breaches
from app.services.email_ingestion_service import check_inbound_email
from app.services.notification_service import send_weekly_digest
from app.models import SystemSetting
with app.app_context():
@@ -259,9 +261,20 @@ def _start_background_schedulers(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()
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:
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
+36
View File
@@ -599,3 +599,39 @@ class TicketSatisfaction(db.Model):
def __repr__(self):
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
View File
@@ -426,9 +426,13 @@ def all_tickets():
)
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,
status=status, priority=priority, assigned=assigned,
search=search)
search=search, it_staff=it_staff)
@admin_bp.route('/tickets/<int:ticket_id>/delete', methods=['POST'])
@@ -993,11 +997,16 @@ def bulk_ticket_action():
flash('No tickets selected.', 'warning')
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:
flash('Invalid action.', 'danger')
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
if action == 'delete' and not current_user.is_admin:
flash('Only administrators may delete tickets.', 'danger')
@@ -1071,6 +1080,15 @@ def bulk_ticket_action():
current_user.full_name, current_user.id)
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:
ticket.assigned_to_id = None
log_ticket_history(ticket, 'assigned_to',
@@ -1095,6 +1113,7 @@ def bulk_ticket_action():
'resolve': 'resolved',
'close': 'closed',
'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',
}
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_login import login_required, current_user
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.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'
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.
@@ -78,8 +99,12 @@ def _call_groq(api_key, history, user_msg):
for msg in clean_history
]
system_content = _SYSTEM_PROMPT
if kb_context:
system_content += '\n\n' + kb_context
messages = (
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
[{'role': 'system', 'content': system_content}]
+ clean_history
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
)
@@ -119,8 +144,19 @@ def chat():
'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:
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:
logger.error(f'[CHATBOT API ERROR] {exc}')
return jsonify({
+77 -2
View File
@@ -10,11 +10,12 @@ from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
KBFeedback, TicketTemplate, TicketSatisfaction)
KBFeedback, TicketTemplate, TicketSatisfaction,
TicketWatcher, TimeEntry)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
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.sla_service import clear_sla_notification
@@ -446,12 +447,21 @@ def ticket_detail(ticket_id):
CannedResponse.category, CannedResponse.title
).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',
ticket=ticket, comments=comments,
it_staff=it_staff, history=history,
statuses=_statuses(), priorities=_priorities(),
linked_tickets=linked_tickets,
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:
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':
from flask import jsonify
return jsonify({'ok': True, 'changes': changes,
@@ -976,6 +994,63 @@ def ticket_survey(token):
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 ─────────────────────────────────────────────────────────────────
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
t = Thread(target=_send, daemon=has_request_context())
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}')
+21
View File
@@ -207,6 +207,19 @@
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;">
<i class="bi bi-person-check me-1"></i>Assign to Me
</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"
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
<i class="bi bi-person-dash me-1"></i>Unassign
@@ -258,6 +271,14 @@
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) {
selectAll.addEventListener('change', () => {
document.querySelectorAll('.ticket-cb').forEach(cb => {
+64 -19
View File
@@ -17,6 +17,7 @@
<div class="col-6 col-md-4">
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
style="padding:10px 14px;font-size:13px;"
data-name="{{ t.name }}"
data-title="{{ t.title_hint }}"
data-category="{{ t.category }}"
data-priority="{{ t.priority }}"
@@ -26,6 +27,23 @@
</div>
{% endfor %}
</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>
{% endif %}
@@ -135,33 +153,60 @@
</div>
{% block scripts %}
<script>
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
const title = btn.dataset.title;
const cat = btn.dataset.category;
const prio = btn.dataset.priority;
const desc = btn.dataset.description;
(function () {
const preview = document.getElementById('tmpl-preview');
const applyBtn = document.getElementById('tmpl-apply-btn');
const prevTitle = document.getElementById('tmpl-prev-title');
const prevCat = document.getElementById('tmpl-prev-cat');
const prevPrio = document.getElementById('tmpl-prev-prio');
const prevDesc = document.getElementById('tmpl-prev-desc');
if (title) document.querySelector('input[name=title]').value = title;
if (desc) document.querySelector('textarea[name=description]').value = desc;
let activeData = null;
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]');
if (catSel) {
for (const opt of catSel.options) {
if (opt.value === cat) { opt.selected = true; break; }
}
}
const prioSel = document.querySelector('select[name=priority]');
if (prioSel) {
for (const opt of prioSel.options) {
if (opt.value === prio) { opt.selected = true; break; }
}
}
// Scroll to form
if (catSel) { for (const o of catSel.options) { if (o.value === data.cat) { o.selected = true; break; } } }
if (prioSel) { for (const o of prioSel.options) { if (o.value === data.prio) { o.selected = true; break; } } }
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
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>
{% 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-calendar3', 'Created', ticket.created_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 %}
</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 -->
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
{% if ticket.satisfaction %}
@@ -1050,4 +1142,28 @@ function buildCommentEl(c) {
</div>
</div>
{% 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 %}
@@ -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')