Upgrade allow adding image in ticket details page comment section

This commit is contained in:
2026-03-30 11:14:35 -04:00
parent 2beef382b7
commit bf29ccb287
13 changed files with 419 additions and 26 deletions
+13
View File
@@ -133,10 +133,23 @@ def create_app(config_name=None):
with app.app_context():
db.create_all()
_seed_admin(app)
_seed_settings()
return app
def _seed_settings():
"""Ensure all required system settings exist with safe defaults."""
from app.models import SystemSetting
defaults = [
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
]
for key, value, description in defaults:
if SystemSetting.get(key) is None:
SystemSetting.set(key, value, description)
db.session.commit()
def _seed_admin(app):
"""Create the default admin account if none exists."""
from app.models import User, UserRole
+46 -1
View File
@@ -308,4 +308,49 @@ class KBAttachment(db.Model):
return f'/admin/kb/files/{self.stored_name}'
def __repr__(self):
return f'<KBAttachment {self.filename}>'
return f'<KBAttachment {self.filename}>'
class SystemSetting(db.Model):
"""Key-value store for application-wide configuration flags.
Values are persisted as strings; helper class methods handle
typed access so callers never touch raw strings directly.
"""
__tablename__ = 'system_settings'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
value = db.Column(db.String(500), nullable=False)
description = db.Column(db.String(256))
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get(cls, key, default=None):
"""Return the raw string value for *key*, or *default* if absent."""
row = cls.query.filter_by(key=key).first()
return row.value if row else default
@classmethod
def get_bool(cls, key, default=True):
"""Return the value for *key* coerced to bool."""
raw = cls.get(key)
if raw is None:
return default
return raw.lower() in ('1', 'true', 'yes', 'on')
@classmethod
def set(cls, key, value, description=None):
"""Upsert *key* = *value*. Caller is responsible for committing."""
row = cls.query.filter_by(key=key).first()
if row:
row.value = str(value)
if description is not None:
row.description = description
else:
row = cls(key=key, value=str(value), description=description)
db.session.add(row)
return row
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
+35 -1
View File
@@ -741,4 +741,38 @@ def activity_logs():
def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
@admin_bp.route('/settings', methods=['GET', 'POST'])
@admin_required
def settings():
from app.models import SystemSetting
if request.method == 'POST':
new_value = '1' if request.form.get('registration_enabled') == '1' else '0'
old_value = SystemSetting.get('registration_enabled', 'true')
SystemSetting.set(
'registration_enabled',
new_value,
'Allow new users to self-register via /auth/register'
)
db.session.commit()
state_label = 'enabled' if new_value == '1' else 'disabled'
log_action(
current_user.id,
'setting_update',
'system_setting',
None,
f'registration_enabled changed from {old_value} to {new_value} by admin_id={current_user.id}'
)
logger.info(
f'[ADMIN SETTINGS] registration_enabled={new_value} '
f'by admin_id={current_user.id} email={current_user.email}'
)
flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings'))
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
return render_template('admin/settings.html', registration_enabled=registration_enabled)
+4 -2
View File
@@ -88,8 +88,10 @@ def get_comments(ticket_id):
'can_delete' : current_user.is_it_staff or c.author_id == current_user.id,
'attachments': [
{
'id' : a.id,
'filename': a.filename,
'id' : a.id,
'filename' : a.filename,
'mime_type': a.mime_type or '',
'is_image' : (a.mime_type or '').startswith('image/'),
}
for a in c.attachments.all()
],
+6
View File
@@ -63,6 +63,12 @@ def register():
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
from app.models import SystemSetting
if not SystemSetting.get_bool('registration_enabled', default=True):
logger.info(f'[AUTH REGISTER BLOCKED] Registration is disabled. ip={request.remote_addr}')
flash('Self-registration is currently disabled. Please contact your IT administrator.', 'warning')
return redirect(url_for('auth.login'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
username = request.form.get('username', '').strip()
+9 -3
View File
@@ -348,9 +348,15 @@ def download_attachment(att_id):
f'user_id={current_user.id}'
)
abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
download_name=att.filename)
upload_dir = current_app.config['UPLOAD_FOLDER']
is_image = (att.mime_type or '').startswith('image/')
return send_from_directory(
upload_dir,
att.stored_name,
as_attachment = not is_image, # images render inline; other files force-download
download_name = att.filename,
mimetype = att.mime_type or None,
)
# ─── Notifications ────────────────────────────────────────────────────────────
+10
View File
@@ -226,6 +226,16 @@ def notify_comment_added(comment):
'body' : comment.body,
'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'),
'author_id' : comment.author_id,
'can_delete' : True, # the author always can; recipient-side JS checks role too
'attachments': [
{
'id' : a.id,
'filename' : a.filename,
'mime_type': a.mime_type or '',
'is_image' : (a.mime_type or '').startswith('image/'),
}
for a in comment.attachments.all()
],
}
def _emit_comment():
socketio.emit(
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}System Settings — TechDesk{% endblock %}
{% block content %}
<div class="page-header" style="margin-bottom:28px;">
<div>
<h1 class="page-title">System Settings</h1>
<p class="page-subtitle">Manage application-wide configuration flags.</p>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<div class="card" style="max-width:640px;">
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
<h5 class="mb-0" style="font-size:15px;font-weight:600;">
<i class="bi bi-person-gear me-2"></i>User Registration
</h5>
</div>
<div class="card-body" style="padding:20px;">
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
When disabled, the <code>/auth/register</code> endpoint redirects all visitors to the login
page with an informational message. Existing accounts and admin-created accounts are
unaffected.
</p>
<form method="POST" action="{{ url_for('admin.settings') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="d-flex align-items-center justify-content-between p-3"
style="border:1px solid var(--border);border-radius:10px;background:var(--surface);">
<div>
<div style="font-weight:600;font-size:14px;">
<i class="bi bi-person-plus me-2"></i>Allow Self-Registration
</div>
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
Permits new employees to create their own accounts via the Register page.
</div>
</div>
<div class="d-flex align-items-center gap-3" style="flex-shrink:0;margin-left:16px;">
<span class="badge {% if registration_enabled %}bg-success{% else %}bg-secondary{% endif %}"
style="font-size:11px;padding:5px 10px;">
{{ 'Enabled' if registration_enabled else 'Disabled' }}
</span>
{% if registration_enabled %}
<button type="submit" name="registration_enabled" value="0"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Disable self-registration? New users will not be able to sign up independently.')">
<i class="bi bi-toggle-on me-1"></i>Disable
</button>
{% else %}
<button type="submit" name="registration_enabled" value="1"
class="btn btn-sm btn-outline-success">
<i class="bi bi-toggle-off me-1"></i>Enable
</button>
{% endif %}
</div>
</div>
</form>
</div>
</div>
{% endblock %}
+3
View File
@@ -323,6 +323,9 @@
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
<i class="bi bi-list-ul"></i> Activity Logs
</a></li>
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
<i class="bi bi-gear"></i> Settings
</a></li>
{% endif %}
</ul>
{% endif %}
+118 -18
View File
@@ -105,11 +105,21 @@
<div class="comment-body">{{ comment.body }}</div>
{% set c_atts = comment.attachments.all() %}
{% if c_atts %}
<div class="mt-2 d-flex flex-wrap gap-2">
<div class="mt-2">
{% for att in c_atts %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>{{ att.filename }}
</a>
{% if att.mime_type and att.mime_type.startswith('image/') %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
alt="{{ att.filename }}"
class="comment-img-thumb"/>
</a>
{% else %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
class="btn btn-secondary btn-sm me-1 mb-1">
<i class="bi bi-download me-1"></i>{{ att.filename }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
@@ -131,11 +141,15 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<textarea id="comment-body" class="form-control" name="body" rows="4" required
placeholder="Add your update, follow-up, or response here…"></textarea>
placeholder="Add your update, follow-up, or response here… (you can also paste images directly)"></textarea>
</div>
<!-- Image preview strip — populated by paste or file picker -->
<div id="img-preview-strip" style="display:none;flex-wrap:wrap;gap:8px;margin-bottom:12px;"></div>
<div class="mb-3">
<label class="form-label">Attachments</label>
<input type="file" class="form-control" name="attachments" multiple
<label class="form-label">Attachments
<span style="font-size:11px;color:var(--muted);font-weight:400;">&nbsp;· images, PDF, documents — or paste an image into the text box</span>
</label>
<input id="attachment-input" type="file" class="form-control" name="attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div>
{% if current_user.is_it_staff %}
@@ -181,6 +195,75 @@ if (typeof socket !== 'undefined') {
});
}
// ── Pasted-image accumulator ──────────────────────────────────────────────────
// Files added via clipboard paste are stored here and merged into the FormData
// on submit, since a paste event cannot modify a real <input type=file>.
let pastedFiles = [];
function rebuildPreviewStrip() {
// Collect all files: pasted images + files chosen via the picker
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
const allFiles = [...pastedFiles, ...pickerFiles];
const strip = document.getElementById('img-preview-strip');
if (allFiles.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; }
strip.style.display = 'flex';
strip.innerHTML = '';
allFiles.forEach((file, idx) => {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position:relative;display:inline-block;';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--border);cursor:pointer;';
img.title = file.name;
img.src = URL.createObjectURL(file);
img.onclick = () => window.open(img.src, '_blank');
wrapper.appendChild(img);
} else {
const label = document.createElement('div');
label.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);text-align:center;padding:4px;word-break:break-all;background:var(--surface);';
label.textContent = file.name;
wrapper.appendChild(label);
}
// Only pasted files (idx < pastedFiles.length) get a remove button
// Picker files are managed via the native file input
if (idx < pastedFiles.length) {
const rm = document.createElement('button');
rm.type = 'button';
rm.innerHTML = '&times;';
rm.style.cssText = 'position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:var(--danger);color:#fff;font-size:12px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;';
rm.onclick = () => { pastedFiles.splice(idx, 1); rebuildPreviewStrip(); };
wrapper.appendChild(rm);
}
strip.appendChild(wrapper);
});
}
// Paste images from clipboard into the textarea
document.getElementById('comment-body').addEventListener('paste', function(e) {
const items = (e.clipboardData || window.clipboardData).items;
let caught = false;
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
// Give the file a deterministic name
const ext = item.type.split('/')[1] || 'png';
const named = new File([file], `paste-${Date.now()}.${ext}`, { type: item.type });
pastedFiles.push(named);
caught = true;
}
}
}
if (caught) { e.preventDefault(); rebuildPreviewStrip(); }
});
// Keep preview in sync when user changes the file picker selection
document.getElementById('attachment-input').addEventListener('change', rebuildPreviewStrip);
// ── AJAX comment form submit ──────────────────────────────────────────────────
document.getElementById('comment-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -194,7 +277,10 @@ document.getElementById('comment-form').addEventListener('submit', async functio
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Posting…';
// Build FormData manually so we can append pasted files (which are not in
// the real <input type=file> and therefore not included automatically).
const fd = new FormData(this);
pastedFiles.forEach(f => fd.append('attachments', f, f.name));
try {
const resp = await fetch(window.location.pathname, {
@@ -204,15 +290,12 @@ document.getElementById('comment-form').addEventListener('submit', async functio
});
if (resp.redirected || resp.ok) {
// Success — clear the form
document.getElementById('comment-body').value = '';
const fileInput = this.querySelector('input[type="file"]');
if (fileInput) fileInput.value = '';
document.getElementById('attachment-input').value = '';
pastedFiles = [];
rebuildPreviewStrip();
const internalCb = document.getElementById('is_internal');
if (internalCb) internalCb.checked = false;
// The server will push the new comment via socket to all viewers including us.
// Fetch the latest comments to make sure we have it (handles the case where
// the socket push arrives before or after the AJAX response).
await refreshComments();
} else {
err.textContent = 'Failed to post comment (HTTP ' + resp.status + '). Please try again.';
@@ -314,11 +397,16 @@ function buildCommentEl(c) {
</button>
</form>`
: '';
const atts = (c.attachments || []).map(a =>
`<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>${a.filename}
</a>`
).join('');
const atts = (c.attachments || []).map(a => {
if (a.is_image) {
return `<a href="/attachments/${a.id}" target="_blank" class="comment-img-link">
<img src="/attachments/${a.id}" alt="${a.filename}" class="comment-img-thumb"/>
</a>`;
}
return `<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm me-1 mb-1">
<i class="bi bi-download me-1"></i>${a.filename}
</a>`;
}).join('');
wrap.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2">
@@ -344,6 +432,18 @@ function buildCommentEl(c) {
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .6s linear infinite; display: inline-block; }
.comment-img-thumb {
max-width: 220px;
max-height: 180px;
border-radius: 6px;
border: 1px solid var(--border);
cursor: pointer;
display: block;
margin: 4px 4px 0 0;
transition: opacity .15s;
}
.comment-img-thumb:hover { opacity: .85; }
.comment-img-link { display: inline-block; }
</style>
</div>