04/07 updated linking ticket
This commit is contained in:
@@ -188,3 +188,46 @@ def search_users():
|
|||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
]})
|
]})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Ticket Search API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/tickets/search')
|
||||||
|
@login_required
|
||||||
|
def search_tickets():
|
||||||
|
"""Return tickets matching a query string, used by the link-ticket picker.
|
||||||
|
|
||||||
|
Searches ticket_number and title (case-insensitive).
|
||||||
|
?q=<search term> — required, min 1 char
|
||||||
|
?exclude=<id> — ticket ID to exclude (the current ticket)
|
||||||
|
Restricted to IT staff only.
|
||||||
|
"""
|
||||||
|
if not current_user.is_it_staff:
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
exclude_id = request.args.get('exclude', 0, type=int)
|
||||||
|
|
||||||
|
if not q:
|
||||||
|
return jsonify({'tickets': []})
|
||||||
|
|
||||||
|
from app.models import Ticket
|
||||||
|
query = Ticket.query.filter(
|
||||||
|
Ticket.ticket_number.ilike(f'%{q}%') |
|
||||||
|
Ticket.title.ilike(f'%{q}%')
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(Ticket.id != exclude_id)
|
||||||
|
|
||||||
|
results = query.order_by(Ticket.created_at.desc()).limit(10).all()
|
||||||
|
logger.info(f'[TICKET SEARCH] q="{q}" results={len(results)} user_id={current_user.id}')
|
||||||
|
return jsonify({'tickets': [
|
||||||
|
{
|
||||||
|
'id' : t.id,
|
||||||
|
'ticket_number': t.ticket_number,
|
||||||
|
'title' : t.title,
|
||||||
|
'status' : t.status,
|
||||||
|
'priority' : t.priority,
|
||||||
|
}
|
||||||
|
for t in results
|
||||||
|
]})
|
||||||
+18
-8
@@ -687,22 +687,32 @@ def link_ticket(ticket_id):
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||||
other_id = request.form.get('linked_ticket_id', type=int)
|
raw_input = request.form.get('linked_ticket_id', '').strip()
|
||||||
link_type = request.form.get('link_type', 'related')
|
link_type = request.form.get('link_type', 'related')
|
||||||
|
|
||||||
if not other_id:
|
if not raw_input:
|
||||||
flash('Please specify a ticket to link.', 'danger')
|
flash('Please search for and select a ticket to link.', 'danger')
|
||||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
# Resolve by ticket_number (e.g. TKT-20260406-0001) or numeric DB id
|
||||||
|
if raw_input.upper().startswith('TKT-'):
|
||||||
|
other = Ticket.query.filter_by(ticket_number=raw_input.upper()).first()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
other = db.session.get(Ticket, int(raw_input))
|
||||||
|
except ValueError:
|
||||||
|
other = Ticket.query.filter_by(ticket_number=raw_input.upper()).first()
|
||||||
|
|
||||||
|
if not other:
|
||||||
|
flash(f'Ticket "{raw_input}" not found.', 'danger')
|
||||||
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
other_id = other.id
|
||||||
|
|
||||||
if other_id == ticket_id:
|
if other_id == ticket_id:
|
||||||
flash('A ticket cannot be linked to itself.', 'danger')
|
flash('A ticket cannot be linked to itself.', 'danger')
|
||||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
other = db.session.get(Ticket, other_id)
|
|
||||||
if not other:
|
|
||||||
flash(f'Ticket #{other_id} not found.', 'danger')
|
|
||||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
|
||||||
|
|
||||||
# Enforce canonical ordering (smaller id first) for the uniqueness constraint
|
# Enforce canonical ordering (smaller id first) for the uniqueness constraint
|
||||||
a, b = sorted([ticket_id, other_id])
|
a, b = sorted([ticket_id, other_id])
|
||||||
existing = TicketLink.query.filter_by(ticket_id=a, linked_ticket_id=b).first()
|
existing = TicketLink.query.filter_by(ticket_id=a, linked_ticket_id=b).first()
|
||||||
|
|||||||
@@ -548,6 +548,8 @@ function buildCommentEl(c) {
|
|||||||
`;
|
`;
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -667,12 +669,44 @@ function buildCommentEl(c) {
|
|||||||
|
|
||||||
<!-- Link form (collapsed by default) -->
|
<!-- Link form (collapsed by default) -->
|
||||||
<div class="collapse mt-3" id="link-form-collapse">
|
<div class="collapse mt-3" id="link-form-collapse">
|
||||||
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}">
|
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}"
|
||||||
|
id="link-ticket-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<!-- Hidden field submitted to the server — populated by the picker -->
|
||||||
|
<input type="hidden" name="linked_ticket_id" id="link-ticket-id"/>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label" style="font-size:12px;">Ticket ID or Number</label>
|
<label class="form-label" style="font-size:12px;">Search Ticket</label>
|
||||||
<input type="number" class="form-control form-control-sm"
|
<!-- Live search input -->
|
||||||
name="linked_ticket_id" placeholder="e.g. 42" required min="1"/>
|
<div style="position:relative;">
|
||||||
|
<input type="text" id="link-ticket-search"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Type ticket number or title…"
|
||||||
|
autocomplete="off"/>
|
||||||
|
<!-- Results dropdown -->
|
||||||
|
<div id="link-ticket-results"
|
||||||
|
style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;
|
||||||
|
background:#fff;border:1px solid var(--border);border-radius:8px;
|
||||||
|
box-shadow:0 6px 20px rgba(0,0,0,.12);z-index:200;
|
||||||
|
max-height:220px;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Selected ticket display -->
|
||||||
|
<div id="link-selected-ticket"
|
||||||
|
style="display:none;margin-top:6px;padding:7px 10px;
|
||||||
|
background:var(--surface2);border:1px solid var(--border);
|
||||||
|
border-radius:6px;font-size:12px;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||||
|
<div>
|
||||||
|
<span id="link-sel-number" class="mono"
|
||||||
|
style="color:var(--accent3);font-weight:600;font-size:11px;"></span>
|
||||||
|
<span id="link-sel-title" style="color:var(--text);margin-left:6px;"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="link-clear-btn"
|
||||||
|
style="background:none;border:none;color:var(--muted);
|
||||||
|
cursor:pointer;padding:0;font-size:13px;flex-shrink:0;"
|
||||||
|
title="Clear selection">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label" style="font-size:12px;">Relationship</label>
|
<label class="form-label" style="font-size:12px;">Relationship</label>
|
||||||
@@ -682,7 +716,8 @@ function buildCommentEl(c) {
|
|||||||
<option value="follow_up">Follow-up</option>
|
<option value="follow_up">Follow-up</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
<button type="submit" id="link-submit-btn"
|
||||||
|
class="btn btn-primary btn-sm w-100" disabled>
|
||||||
<i class="bi bi-link-45deg me-1"></i>Create Link
|
<i class="bi bi-link-45deg me-1"></i>Create Link
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -752,6 +787,130 @@ function buildCommentEl(c) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Link-ticket live search ───────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const CURRENT_TICKET_ID = {{ ticket.id }};
|
||||||
|
const searchEl = document.getElementById('link-ticket-search');
|
||||||
|
const resultsEl = document.getElementById('link-ticket-results');
|
||||||
|
const idField = document.getElementById('link-ticket-id');
|
||||||
|
const selCard = document.getElementById('link-selected-ticket');
|
||||||
|
const selNumber = document.getElementById('link-sel-number');
|
||||||
|
const selTitle = document.getElementById('link-sel-title');
|
||||||
|
const clearBtn = document.getElementById('link-clear-btn');
|
||||||
|
const submitBtn = document.getElementById('link-submit-btn');
|
||||||
|
|
||||||
|
if (!searchEl) return; // guard: only runs on detail page with IT staff sidebar
|
||||||
|
|
||||||
|
// Auto-focus the search field when the collapse panel opens
|
||||||
|
const collapseEl = document.getElementById('link-form-collapse');
|
||||||
|
if (collapseEl) {
|
||||||
|
collapseEl.addEventListener('shown.bs.collapse', () => searchEl.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounce = null;
|
||||||
|
|
||||||
|
searchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const q = searchEl.value.trim();
|
||||||
|
// Enable submit as soon as there is any text — server resolves ticket_number strings
|
||||||
|
submitBtn.disabled = (q.length === 0);
|
||||||
|
if (!idField.value) idField.value = ''; // clear stale selection if user retypes
|
||||||
|
if (q.length < 1) { resultsEl.style.display = 'none'; return; }
|
||||||
|
debounce = setTimeout(() => fetchTickets(q), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchEl.addEventListener('focus', () => {
|
||||||
|
if (searchEl.value.trim().length >= 1) fetchTickets(searchEl.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!e.target.closest('#link-ticket-search') && !e.target.closest('#link-ticket-results'))
|
||||||
|
resultsEl.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchTickets(q) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/tickets/search?q=${encodeURIComponent(q)}&exclude=${CURRENT_TICKET_ID}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
);
|
||||||
|
const data = await r.json();
|
||||||
|
renderResults(data.tickets || []);
|
||||||
|
} catch { resultsEl.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
open: 'var(--accent)', in_progress: 'var(--info)',
|
||||||
|
pending: 'var(--warning)', resolved: 'var(--success)', closed: 'var(--muted)'
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderResults(tickets) {
|
||||||
|
if (!tickets.length) {
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<div style="padding:10px 12px;font-size:12px;color:var(--muted);">No matching tickets found.</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsEl.innerHTML = tickets.map(t => `
|
||||||
|
<div class="link-ticket-option"
|
||||||
|
data-id="${t.id}" data-number="${t.ticket_number}" data-title="${t.title.replace(/"/g,'"')}"
|
||||||
|
style="padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border);
|
||||||
|
display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--accent3);font-weight:600;">${t.ticket_number}</span>
|
||||||
|
<span style="font-size:10px;background:var(--surface2);color:${STATUS_COLORS[t.status] || 'var(--muted)'};
|
||||||
|
padding:1px 6px;border-radius:4px;margin-left:5px;">${t.status.replace('_',' ')}</span>
|
||||||
|
<div style="font-size:12px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px;">
|
||||||
|
${t.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
resultsEl.querySelectorAll('.link-ticket-option').forEach(el => {
|
||||||
|
el.addEventListener('mouseenter', () => el.style.background = 'var(--surface2)');
|
||||||
|
el.addEventListener('mouseleave', () => el.style.background = '');
|
||||||
|
el.addEventListener('click', () => selectTicket(el.dataset));
|
||||||
|
});
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTicket(data) {
|
||||||
|
idField.value = data.id;
|
||||||
|
selNumber.textContent = data.number;
|
||||||
|
selTitle.textContent = data.title;
|
||||||
|
selCard.style.display = 'flex';
|
||||||
|
searchEl.style.display = 'none';
|
||||||
|
resultsEl.style.display = 'none';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
idField.value = '';
|
||||||
|
selCard.style.display = 'none';
|
||||||
|
searchEl.style.display = '';
|
||||||
|
searchEl.value = '';
|
||||||
|
searchEl.focus();
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user typed a full ticket number without clicking a result,
|
||||||
|
// populate the hidden field from the search text so the server can resolve it.
|
||||||
|
document.getElementById('link-ticket-form').addEventListener('submit', e => {
|
||||||
|
if (!idField.value) {
|
||||||
|
const typed = searchEl.value.trim();
|
||||||
|
if (typed) {
|
||||||
|
idField.value = typed; // server resolves by ticket_number string
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
searchEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
|
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
|
||||||
{% if ticket.status in ('resolved', 'closed') and
|
{% if ticket.status in ('resolved', 'closed') and
|
||||||
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}
|
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}
|
||||||
|
|||||||
Reference in New Issue
Block a user