Upgrade allow adding image in ticket details page comment section
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+46
-1
@@ -308,4 +308,49 @@ class KBAttachment(db.Model):
|
|||||||
return f'/admin/kb/files/{self.stored_name}'
|
return f'/admin/kb/files/{self.stored_name}'
|
||||||
|
|
||||||
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}>'
|
||||||
+35
-1
@@ -741,4 +741,38 @@ 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
@@ -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()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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;"> · 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 = '×';
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
Reference in New Issue
Block a user