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(): with app.app_context():
db.create_all() db.create_all()
_seed_admin(app) _seed_admin(app)
_seed_settings()
return app 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): def _seed_admin(app):
"""Create the default admin account if none exists.""" """Create the default admin account if none exists."""
from app.models import User, UserRole from app.models import User, UserRole
+45
View File
@@ -309,3 +309,48 @@ class KBAttachment(db.Model):
def __repr__(self): 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}>'
+34
View File
@@ -742,3 +742,37 @@ def activity_logs():
def _roles(): 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, 'can_delete' : current_user.is_it_staff or c.author_id == current_user.id,
'attachments': [ 'attachments': [
{ {
'id' : a.id, 'id' : a.id,
'filename': a.filename, 'filename' : a.filename,
'mime_type': a.mime_type or '',
'is_image' : (a.mime_type or '').startswith('image/'),
} }
for a in c.attachments.all() for a in c.attachments.all()
], ],
+6
View File
@@ -63,6 +63,12 @@ def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard')) 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': if request.method == 'POST':
email = request.form.get('email', '').strip().lower() email = request.form.get('email', '').strip().lower()
username = request.form.get('username', '').strip() 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}' f'user_id={current_user.id}'
) )
abort(403) abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER'] upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, att.stored_name, as_attachment=True, is_image = (att.mime_type or '').startswith('image/')
download_name=att.filename) 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 ──────────────────────────────────────────────────────────── # ─── Notifications ────────────────────────────────────────────────────────────
+10
View File
@@ -226,6 +226,16 @@ def notify_comment_added(comment):
'body' : comment.body, 'body' : comment.body,
'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'), 'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'),
'author_id' : comment.author_id, '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(): def _emit_comment():
socketio.emit( 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' }}"> <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 <i class="bi bi-list-ul"></i> Activity Logs
</a></li> </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 %} {% endif %}
</ul> </ul>
{% endif %} {% endif %}
+118 -18
View File
@@ -105,11 +105,21 @@
<div class="comment-body">{{ comment.body }}</div> <div class="comment-body">{{ comment.body }}</div>
{% set c_atts = comment.attachments.all() %} {% set c_atts = comment.attachments.all() %}
{% if c_atts %} {% if c_atts %}
<div class="mt-2 d-flex flex-wrap gap-2"> <div class="mt-2">
{% for att in c_atts %} {% for att in c_atts %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" class="btn btn-secondary btn-sm"> {% if att.mime_type and att.mime_type.startswith('image/') %}
<i class="bi bi-download me-1"></i>{{ att.filename }} <a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
</a> 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 %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -131,11 +141,15 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3"> <div class="mb-3">
<textarea id="comment-body" class="form-control" name="body" rows="4" required <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> </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"> <div class="mb-3">
<label class="form-label">Attachments</label> <label class="form-label">Attachments
<input type="file" class="form-control" name="attachments" multiple <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"/> accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div> </div>
{% if current_user.is_it_staff %} {% 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 ────────────────────────────────────────────────── // ── AJAX comment form submit ──────────────────────────────────────────────────
document.getElementById('comment-form').addEventListener('submit', async function(e) { document.getElementById('comment-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -194,7 +277,10 @@ document.getElementById('comment-form').addEventListener('submit', async functio
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Posting…'; 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); const fd = new FormData(this);
pastedFiles.forEach(f => fd.append('attachments', f, f.name));
try { try {
const resp = await fetch(window.location.pathname, { const resp = await fetch(window.location.pathname, {
@@ -204,15 +290,12 @@ document.getElementById('comment-form').addEventListener('submit', async functio
}); });
if (resp.redirected || resp.ok) { if (resp.redirected || resp.ok) {
// Success — clear the form
document.getElementById('comment-body').value = ''; document.getElementById('comment-body').value = '';
const fileInput = this.querySelector('input[type="file"]'); document.getElementById('attachment-input').value = '';
if (fileInput) fileInput.value = ''; pastedFiles = [];
rebuildPreviewStrip();
const internalCb = document.getElementById('is_internal'); const internalCb = document.getElementById('is_internal');
if (internalCb) internalCb.checked = false; 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(); await refreshComments();
} else { } else {
err.textContent = 'Failed to post comment (HTTP ' + resp.status + '). Please try again.'; err.textContent = 'Failed to post comment (HTTP ' + resp.status + '). Please try again.';
@@ -314,11 +397,16 @@ function buildCommentEl(c) {
</button> </button>
</form>` </form>`
: ''; : '';
const atts = (c.attachments || []).map(a => const atts = (c.attachments || []).map(a => {
`<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm"> if (a.is_image) {
<i class="bi bi-download me-1"></i>${a.filename} return `<a href="/attachments/${a.id}" target="_blank" class="comment-img-link">
</a>` <img src="/attachments/${a.id}" alt="${a.filename}" class="comment-img-thumb"/>
).join(''); </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 = ` wrap.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
@@ -344,6 +432,18 @@ function buildCommentEl(c) {
<style> <style>
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .6s linear infinite; display: inline-block; } .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> </style>
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
# A generic, single database configuration.
# This file is used by Alembic / Flask-Migrate for logging configuration.
# The actual database URL is injected at runtime by env.py via Flask's
# current_app — the sqlalchemy.url value here is intentionally a placeholder.
[alembic]
script_location = migrations
# Placeholder — overridden at runtime by env.py (run_migrations_online).
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+4 -1
View File
@@ -4,7 +4,10 @@ from alembic import context
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) try:
fileConfig(config.config_file_name)
except FileNotFoundError:
pass # alembic.ini absent or CWD mismatch — Flask-Migrate supplies logging elsewhere
target_metadata = current_app.extensions['migrate'].db.metadata target_metadata = current_app.extensions['migrate'].db.metadata
@@ -0,0 +1,53 @@
"""Add system_settings table for application-wide configuration flags.
Revision ID: 002_add_system_settings
Revises: 001_widen_ticket_history_values
Create Date: 2026-03-30
Rationale
---------
Introduces a generic key-value settings store so that runtime configuration
flags (such as registration_enabled) can be toggled by administrators through
the UI without requiring a code deployment or server restart.
Apply
-----
flask db upgrade
Rollback
--------
flask db downgrade
"""
from alembic import op
import sqlalchemy as sa
revision = '002_add_system_settings'
down_revision = '001_widen_ticket_history_values'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'system_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(100), nullable=False),
sa.Column('value', sa.String(500), nullable=False),
sa.Column('description', sa.String(256), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True)
# Seed the default: registration is open on fresh installs.
op.execute(
"INSERT INTO system_settings (key, value, description, updated_at) "
"VALUES ('registration_enabled', 'true', "
"'Allow new users to self-register via /auth/register', NOW())"
)
def downgrade():
op.drop_index('ix_system_settings_key', table_name='system_settings')
op.drop_table('system_settings')