diff --git a/Claude.md b/Claude.md index b76ca56..1ca3107 100644 --- a/Claude.md +++ b/Claude.md @@ -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/` — knowledge base - `POST /tickets//link` / `POST /tickets//unlink/` - `POST /tickets//reopen` +- `POST /tickets//watch` / `POST /tickets//unwatch` — AJAX; toggle watcher subscription +- `POST /tickets//log-time` — IT staff only; AJAX-aware; logs `TimeEntry` - `GET /canned-responses` — JSON endpoint for IT staff comment box - `POST /kb//feedback` - `GET/POST /survey/` — 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//edit` / `POST /admin/users//delete` - `GET /admin/tickets` — all tickets with search, filter, pagination - `POST /admin/tickets//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//edit` / `POST /admin/kb//delete` / `POST /admin/kb//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) diff --git a/app/__init__.py b/app/__init__.py index 524307c..fa83798 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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}') diff --git a/app/models.py b/app/models.py index 2fe5847..ff82c56 100644 --- a/app/models.py +++ b/app/models.py @@ -598,4 +598,40 @@ class TicketSatisfaction(db.Model): return self.rating is not None def __repr__(self): - return f'' \ No newline at end of file + return f'' + + +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'' + + +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'' \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py index 28f9a5c..b88b191 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -425,10 +425,14 @@ def all_tickets(): .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, status=status, priority=priority, assigned=assigned, - search=search) + search=search, it_staff=it_staff) @admin_bp.route('/tickets//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') diff --git a/app/routes/chatbot.py b/app/routes/chatbot.py index ce677e1..39d5d34 100644 --- a/app/routes/chatbot.py +++ b/app/routes/chatbot.py @@ -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({ diff --git a/app/routes/tickets.py b/app/routes/tickets.py index a09d510..8f259af 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -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//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//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//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': diff --git a/app/services/notification_service.py b/app/services/notification_service.py index 82058b0..05d1dcc 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -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'' + f'' + f'{t.ticket_number}' + f'{t.title[:60]}' + f'{t.priority.upper()}' + f'' + f'{t.assignee.full_name if t.assignee else "—"}' + f'' + ) + if len(tickets) > limit: + rows += ( + f'' + f'… and {len(tickets) - limit} more' + ) + return rows or 'None' + + dashboard_url = f'{base_url}/admin/' + + html = ( + '' + '
' + '
' + f'

📊 Weekly IT Summary

' + f'

' + f'{week_ago.strftime("%b %d")} – {now.strftime("%b %d, %Y")}

' + '
' + '
' + + # KPI row + '
' + f'
' + f'
{len(new_tickets)}
' + f'
New this week
' + f'
' + f'
{len(resolved)}
' + f'
Resolved this week
' + f'
' + f'
{len(open_tickets)}
' + f'
Still open
' + f'
' + f'
{len(overdue)}
' + f'
Overdue
' + '
' + + # Open tickets table + '

Open Tickets

' + '' + '' + '' + '' + '' + '' + '' + f'{_ticket_rows(open_tickets)}' + '
TICKETTITLEPRIORITYASSIGNED
' + + # Overdue section (only if any) + + ( + '

' + '⚠ Overdue Tickets

' + '' + '' + '' + '' + '' + '' + '' + f'{_ticket_rows(overdue)}' + '
TICKETTITLEPRIORITYASSIGNED
' + if overdue else '' + ) + + + f'Open Dashboard' + '
' + '
' + 'TechDesk IT Helpdesk • Weekly digest • Automated message' + '
' + ) + + 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}') \ No newline at end of file diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html index 07c3acb..cc2fdca 100644 --- a/app/templates/admin/tickets.html +++ b/app/templates/admin/tickets.html @@ -207,6 +207,19 @@ class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;"> Assign to Me +
+ + +
+ +
+
+ + +
+
+ {% endif %} @@ -135,33 +153,60 @@ {% block scripts %} {% endblock %} {% endblock %} \ No newline at end of file diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 5017401..e3012d1 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -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 = 'Completed' %} + {% elif overdue %} + {% set sla_badge = ' Overdue' %} + {% else %} + {% set sla_badge = 'On track' %} + {% 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 is_watching %}Watching this ticket{% else %}Not watching{% endif %} + + +
+
+ + + {% if current_user.is_it_staff %} +
+
+ Time Logged + + {{ (total_minutes // 60) }}h {{ (total_minutes % 60) }}m total + +
+
+
+ +
+ + +
+
+ + +
+ +
+ {% if time_entries %} +
+ {% for e in time_entries %} +
+ {{ e.user.full_name.split()[0] }} — {{ e.note or '—' }} + {{ e.minutes }}m +
+ {% endfor %} +
+ {% endif %} +
+
+ + {% endif %} + {% if ticket.satisfaction %} @@ -1050,4 +1142,28 @@ function buildCommentEl(c) { {% endif %} +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/migrations/versions/005_add_watchers_and_time_entries.py b/migrations/versions/005_add_watchers_and_time_entries.py new file mode 100644 index 0000000..b4ac148 --- /dev/null +++ b/migrations/versions/005_add_watchers_and_time_entries.py @@ -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')