From bf29ccb2876b1adb82895bdd460a685228569827 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 30 Mar 2026 11:14:35 -0400 Subject: [PATCH] Upgrade allow adding image in ticket details page comment section --- app/__init__.py | 13 ++ app/models.py | 47 +++++- app/routes/admin.py | 36 ++++- app/routes/api.py | 6 +- app/routes/auth.py | 6 + app/routes/tickets.py | 12 +- app/services/notification_service.py | 10 ++ app/templates/admin/settings.html | 69 +++++++++ app/templates/base.html | 3 + app/templates/tickets/detail.html | 136 +++++++++++++++--- migrations/alembic.ini | 49 +++++++ migrations/env.py | 5 +- .../versions/002_add_system_settings.py | 53 +++++++ 13 files changed, 419 insertions(+), 26 deletions(-) create mode 100644 app/templates/admin/settings.html create mode 100644 migrations/alembic.ini create mode 100644 migrations/versions/002_add_system_settings.py diff --git a/app/__init__.py b/app/__init__.py index 66f5629..8bafbd1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/models.py b/app/models.py index 70bfd7e..5756ec2 100644 --- a/app/models.py +++ b/app/models.py @@ -308,4 +308,49 @@ class KBAttachment(db.Model): return f'/admin/kb/files/{self.stored_name}' def __repr__(self): - return f'' \ No newline at end of file + return f'' + + +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'' \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py index 24a413d..01cb9db 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -741,4 +741,38 @@ def activity_logs(): def _roles(): - return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/routes/api.py b/app/routes/api.py index 2da07a6..9e5573f 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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() ], diff --git a/app/routes/auth.py b/app/routes/auth.py index 1c83207..b62452d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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() diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 22cb85a..91bdddf 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -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 ──────────────────────────────────────────────────────────── diff --git a/app/services/notification_service.py b/app/services/notification_service.py index 0f64395..97e854e 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -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( diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..cb47dcf --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}System Settings — TechDesk{% endblock %} + +{% block content %} + + +{% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} +{% endwith %} + +
+
+
+ User Registration +
+
+
+

+ When disabled, the /auth/register endpoint redirects all visitors to the login + page with an informational message. Existing accounts and admin-created accounts are + unaffected. +

+ +
+ + +
+
+
+ Allow Self-Registration +
+
+ Permits new employees to create their own accounts via the Register page. +
+
+
+ + {{ 'Enabled' if registration_enabled else 'Disabled' }} + + {% if registration_enabled %} + + {% else %} + + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index c0ddfca..568f96e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -323,6 +323,9 @@
  • Activity Logs
  • +
  • + Settings +
  • {% endif %} {% endif %} diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 401fb29..0e978eb 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -105,11 +105,21 @@
    {{ comment.body }}
    {% set c_atts = comment.attachments.all() %} {% if c_atts %} -
    +
    {% for att in c_atts %} - - {{ att.filename }} - + {% if att.mime_type and att.mime_type.startswith('image/') %} + + {{ att.filename }} + + {% else %} + + {{ att.filename }} + + {% endif %} {% endfor %}
    {% endif %} @@ -131,11 +141,15 @@
    + placeholder="Add your update, follow-up, or response here… (you can also paste images directly)">
    + +
    - - Attachments +  · images, PDF, documents — or paste an image into the text box + +
    {% 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 . +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 ────────────────────────────────────────────────── 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 = 'Posting…'; + // Build FormData manually so we can append pasted files (which are not in + // the real 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) { ` : ''; - const atts = (c.attachments || []).map(a => - ` - ${a.filename} - ` - ).join(''); + const atts = (c.attachments || []).map(a => { + if (a.is_image) { + return ` + ${a.filename} + `; + } + return ` + ${a.filename} + `; + }).join(''); wrap.innerHTML = `
    @@ -344,6 +432,18 @@ function buildCommentEl(c) {
    diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..c0a8d15 --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py index b025353..9298ed2 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -4,7 +4,10 @@ from alembic import context config = context.config 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 diff --git a/migrations/versions/002_add_system_settings.py b/migrations/versions/002_add_system_settings.py new file mode 100644 index 0000000..e6ba160 --- /dev/null +++ b/migrations/versions/002_add_system_settings.py @@ -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')