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():
|
||||
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
@@ -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
@@ -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
@@ -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()
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }}">
|
||||
<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 %}
|
||||
|
||||
@@ -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;"> · 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 = '×';
|
||||
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>
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -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