05/22 Enhance codes and fix bugs 3
This commit is contained in:
@@ -118,6 +118,8 @@ Both decorators must be applied **after** `@login_required`.
|
||||
| `PasswordResetToken` | `password_reset_tokens` | SHA-256 hashed, 1-hour expiry, single-use. |
|
||||
| `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
@@ -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}')
|
||||
|
||||
|
||||
@@ -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}>'
|
||||
+22
-3
@@ -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/<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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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;">📊 Weekly IT Summary</h1>'
|
||||
f'<p style="color:#94a3b8;margin:6px 0 0;font-size:13px;">'
|
||||
f'{week_ago.strftime("%b %d")} – {now.strftime("%b %d, %Y")}</p>'
|
||||
'</div>'
|
||||
'<div style="padding:28px 32px;">'
|
||||
|
||||
# KPI row
|
||||
'<div style="display:flex;gap:16px;margin-bottom:24px;">'
|
||||
f'<div style="flex:1;background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">'
|
||||
f'<div style="font-size:28px;font-weight:700;color:#059669;">{len(new_tickets)}</div>'
|
||||
f'<div style="font-size:12px;color:#64748b;">New this week</div></div>'
|
||||
f'<div style="flex:1;background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">'
|
||||
f'<div style="font-size:28px;font-weight:700;color:#2563eb;">{len(resolved)}</div>'
|
||||
f'<div style="font-size:12px;color:#64748b;">Resolved this week</div></div>'
|
||||
f'<div style="flex:1;background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">'
|
||||
f'<div style="font-size:28px;font-weight:700;color:#dc2626;">{len(open_tickets)}</div>'
|
||||
f'<div style="font-size:12px;color:#64748b;">Still open</div></div>'
|
||||
f'<div style="flex:1;background:#fffbeb;border-radius:8px;padding:16px;text-align:center;">'
|
||||
f'<div style="font-size:28px;font-weight:700;color:#d97706;">{len(overdue)}</div>'
|
||||
f'<div style="font-size:12px;color:#64748b;">Overdue</div></div>'
|
||||
'</div>'
|
||||
|
||||
# Open tickets table
|
||||
'<h3 style="font-size:14px;font-weight:700;color:#1e293b;margin:0 0 10px;">Open Tickets</h3>'
|
||||
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||||
'<thead><tr style="background:#f8fafc;">'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{_ticket_rows(open_tickets)}</tbody>'
|
||||
'</table>'
|
||||
|
||||
# Overdue section (only if any)
|
||||
+ (
|
||||
'<h3 style="font-size:14px;font-weight:700;color:#dc2626;margin:0 0 10px;">'
|
||||
'⚠ Overdue Tickets</h3>'
|
||||
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||||
'<thead><tr style="background:#fef2f2;">'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{_ticket_rows(overdue)}</tbody>'
|
||||
'</table>'
|
||||
if overdue else ''
|
||||
) +
|
||||
|
||||
f'<a href="{dashboard_url}" style="display:inline-block;background:#2563eb;color:#fff;'
|
||||
f'padding:11px 22px;border-radius:6px;text-decoration:none;font-size:13px;">Open Dashboard</a>'
|
||||
'</div>'
|
||||
'<div style="background:#f8fafc;padding:14px 32px;text-align:center;'
|
||||
'color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">'
|
||||
'TechDesk IT Helpdesk • Weekly digest • Automated message'
|
||||
'</div></div></body></html>'
|
||||
)
|
||||
|
||||
staff = User.query.filter(
|
||||
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
User.email_notif == True,
|
||||
).all()
|
||||
|
||||
if not staff:
|
||||
logger.info('[DIGEST] No staff with email notifications enabled — skipping digest')
|
||||
return
|
||||
|
||||
subject = f'[TechDesk] Weekly IT Summary — {now.strftime("%b %d, %Y")}'
|
||||
for member in staff:
|
||||
send_email(subject, [member.email], html)
|
||||
logger.info(f'[DIGEST] Sent weekly digest to {member.email}')
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f'[DIGEST] Failed to send weekly digest: {exc}', exc_info=True)
|
||||
logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
const catSel = document.querySelector('select[name=category]');
|
||||
if (catSel) {
|
||||
for (const opt of catSel.options) {
|
||||
if (opt.value === cat) { opt.selected = true; break; }
|
||||
}
|
||||
}
|
||||
function applyTemplate(data) {
|
||||
if (!data) return;
|
||||
if (data.title) document.querySelector('input[name=title]').value = data.title;
|
||||
if (data.desc) document.querySelector('textarea[name=description]').value = data.desc;
|
||||
const catSel = document.querySelector('select[name=category]');
|
||||
const 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 %}
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user