diff --git a/app/models.py b/app/models.py index bde4a7b..19e288a 100644 --- a/app/models.py +++ b/app/models.py @@ -117,6 +117,12 @@ class Ticket(db.Model): internal_notes = db.Column(db.Text) resolution_notes = db.Column(db.Text) + # When an IT staff member creates a ticket on behalf of an employee + # (e.g. from a phone call or email), this column records who filed it. + # NULL means the ticket was self-submitted by the employee. + created_by_staff_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + filed_by_staff = db.relationship('User', foreign_keys='Ticket.created_by_staff_id') + # Relationships comments = db.relationship('Comment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') attachments = db.relationship('Attachment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') diff --git a/app/routes/api.py b/app/routes/api.py index dba9c17..f614054 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -145,4 +145,46 @@ def on_join_ticket(data): def on_leave_ticket(data): if current_user.is_authenticated: ticket_id = data.get('ticket_id') - leave_room(f'ticket_{ticket_id}') \ No newline at end of file + leave_room(f'ticket_{ticket_id}') + +# ─── User Search API (IT Only) ──────────────────────────────────────────────── + +@api_bp.route('/users/search') +@login_required +def search_users(): + """Return active employees matching a search query. + + Used by the 'create on behalf' form to populate the employee selector. + Restricted to IT staff to prevent employees from enumerating all users. + + Query params + ------------ + q : str – search term matched against full_name, email, department + limit : int – max results (default 20, max 50) + """ + if not current_user.is_it_staff: + return jsonify({'error': 'Forbidden'}), 403 + + q = request.args.get('q', '').strip() + limit = min(request.args.get('limit', 20, type=int), 50) + + from app.models import User, UserRole + query = User.query.filter(User.is_active == True) + if q: + query = query.filter( + User.full_name.ilike(f'%{q}%') | + User.email.ilike(f'%{q}%') | + User.department.ilike(f'%{q}%') + ) + users = query.order_by(User.full_name).limit(limit).all() + + return jsonify({'users': [ + { + 'id' : u.id, + 'full_name' : u.full_name, + 'email' : u.email, + 'department': u.department or '', + 'role' : u.role, + } + for u in users + ]}) diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 4361feb..2a93d94 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -188,6 +188,109 @@ def create_ticket(): categories=_categories(), priorities=_priorities()) +# ─── Create Ticket on Behalf of Employee (IT Staff Only) ───────────────────── + +@tickets_bp.route('/tickets/behalf', methods=['GET', 'POST']) +@login_required +def create_ticket_behalf(): + """Allow IT staff to create a ticket on behalf of an employee. + + Use case: an employee calls or emails to report an issue and cannot or + does not create a ticket themselves. IT staff completes the form, + selecting the employee from a searchable dropdown. + + Data model + ---------- + ticket.created_by_id = employee's user ID (ticket shows as theirs) + ticket.created_by_staff_id = IT staff's user ID (audit trail) + + The employee sees this ticket in their own dashboard and receives the + same new-ticket confirmation notification they would if self-submitted. + """ + if not current_user.is_it_staff: + abort(403) + + employees = User.query.filter( + User.is_active == True, + ).order_by(User.full_name).all() + + if request.method == 'POST': + employee_id = request.form.get('employee_id', type=int) + title = request.form.get('title', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category', TicketCategory.OTHER) + priority = request.form.get('priority', TicketPriority.MEDIUM) + location = request.form.get('location', '').strip() + asset_tag = request.form.get('asset_tag', '').strip() + + # Validate employee selection + employee = db.session.get(User, employee_id) if employee_id else None + if not employee or not employee.is_active: + flash('Please select a valid active employee.', 'danger') + return render_template('tickets/create_behalf.html', + employees=employees, + categories=_categories(), + priorities=_priorities()) + + if not title or not description: + flash('Title and description are required.', 'danger') + return render_template('tickets/create_behalf.html', + employees=employees, + categories=_categories(), + priorities=_priorities(), + selected_employee_id=employee_id) + + ticket = Ticket( + title = title, + description = description, + category = category, + priority = priority, + location = location, + asset_tag = asset_tag, + created_by_id = employee.id, # ticket belongs to the employee + created_by_staff_id = current_user.id, # IT staff who filed it + status = TicketStatus.OPEN, + ) + ticket.ticket_number = ticket.generate_ticket_number() + db.session.add(ticket) + db.session.flush() # get ticket.id before attachments + + # Handle file uploads + for f in request.files.getlist('attachments'): + if f and f.filename: + file_error = validate_file(f, ALLOWED_EXT) + if file_error: + logger.warning( + f'[BEHALF UPLOAD REJECTED] {file_error} ' + f'filename="{f.filename}" staff_id={current_user.id}' + ) + continue + save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id) + + log_action( + current_user.id, 'ticket_create_behalf', 'ticket', ticket.id, + f'ticket_number={ticket.ticket_number} on_behalf_of=user_id:{employee.id} ' + f'({employee.full_name}) priority={priority} category={category}' + ) + db.session.commit() + logger.info( + f'[TICKET CREATE BEHALF] ticket_id={ticket.id} ' + f'number={ticket.ticket_number} ' + f'employee_id={employee.id} staff_id={current_user.id}' + ) + notify_new_ticket(ticket) + flash( + f'Ticket {ticket.ticket_number} created on behalf of {employee.full_name}.', + 'success' + ) + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id)) + + return render_template('tickets/create_behalf.html', + employees=employees, + categories=_categories(), + priorities=_priorities()) + + # ─── Ticket List ────────────────────────────────────────────────────────────── @tickets_bp.route('/tickets') diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html index 0906e5e..8f650b8 100644 --- a/app/templates/admin/tickets.html +++ b/app/templates/admin/tickets.html @@ -51,9 +51,14 @@
-
- Tickets - ({{ tickets.total }}) +
+
+ Tickets + ({{ tickets.total }}) +
+ + File on Behalf +
{% if tickets.items %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index 4608592..d366516 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -7,7 +7,13 @@