2025-06-27 16:36:02 +03:00
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block content %}
< div class = "content-main" >
< h1 > {{ title }}< / h1 >
2025-06-27 17:08:32 +03:00
< div class = "alert alert-info" style = "margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;" >
< strong > Note:< / strong > This operation only affects the database and works even if servers are unreachable.
Server connectivity is not required for moving links between servers.
< / div >
2025-06-27 16:36:02 +03:00
{% if messages %}
{% for message in messages %}
< div class = "alert alert-{{ message.tags }}" style = "margin: 10px 0 ; padding: 10px ; border-radius: 4px ;
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
{% endif %}">
{{ message }}
< / div >
{% endfor %}
{% endif %}
< form method = "post" id = "move-clients-form" >
{% csrf_token %}
< div class = "form-row" style = "margin-bottom: 20px;" >
< div style = "display: flex; gap: 20px;" >
< div style = "flex: 1;" >
< label for = "source_server" > < strong > Source Server:< / strong > < / label >
< select id = "source_server" name = "source_server" style = "width: 100%; padding: 5px;" onchange = "updateLinksList()" >
< option value = "" > -- Select Source Server --< / option >
{% for server in servers %}
< option value = "{{ server.id }}" > {{ server.name }} ({{ server.server_type }})< / option >
{% endfor %}
< / select >
< / div >
< div style = "flex: 1;" >
< label for = "target_server" > < strong > Target Server:< / strong > < / label >
< select id = "target_server" name = "target_server" style = "width: 100%; padding: 5px;" onchange = "updateSubmitButton()" >
< option value = "" > -- Select Target Server --< / option >
{% for server in all_servers %}
< option value = "{{ server.id }}" > {{ server.name }} ({{ server.server_type }})< / option >
{% endfor %}
< / select >
< / div >
< / div >
< / div >
< div id = "links-list" style = "margin-bottom: 20px;" >
< h3 > Select Client Links to Move:< / h3 >
< div id = "links-container" >
< p style = "color: #666;" > Please select a source server first.< / p >
< / div >
< / div >
2025-06-27 17:08:32 +03:00
<!-- REGEX TRANSFORMATION SECTION -->
< div class = "form-row" style = "margin-bottom: 20px; border: 2px solid #007cba; border-radius: 8px; padding: 20px; background-color: #f8f9fa;" >
< h3 style = "margin-top: 0; color: #007cba;" > 📝 Comment Transformation (Optional)< / h3 >
< div style = "margin-bottom: 15px;" >
< label for = "comment_regex" > < strong > Regular Expression Pattern:< / strong > < / label >
< input type = "text" id = "comment_regex" name = "comment_regex"
style="width: 100%; padding: 10px; font-family: 'Courier New', monospace; font-size: 14px; border: 2px solid #ddd; border-radius: 4px;"
placeholder="Example: ^(.*)$ -> [OLD_SERVER] $1"
title="Use regex pattern -> replacement format">
< small style = "color: #666; display: block; margin-top: 5px;" >
Format: < code > pattern -> replacement< / code > (leave empty to keep original comments)
< / small >
< / div >
< div class = "regex-help" style = "background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #dee2e6;" >
< h4 style = "margin-top: 0; color: #007cba;" > 🔧 Regular Expression Help & Examples:< / h4 >
< div style = "font-size: 14px;" >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 10px;" >
< div >
< h5 style = "color: #28a745;" > ✨ Common Examples:< / h5 >
< ul style = "margin: 5px 0; padding-left: 20px; line-height: 1.6;" >
< li > < code > ^(.*)$ -> [ServerA] $1< / code > < br > < small style = "color: #666;" > Add prefix to all comments< / small > < / li >
< li > < code > ^(.*)$ -> $1 (moved)< / code > < br > < small style = "color: #666;" > Add suffix to all comments< / small > < / li >
< li > < code > ^$ -> Default Device< / code > < br > < small style = "color: #666;" > Replace empty comments< / small > < / li >
< li > < code > phone -> mobile< / code > < br > < small style = "color: #666;" > Replace specific word< / small > < / li >
< li > < code > ^(.*)(phone|mobile)(.*)$ -> $1device$3< / code > < br > < small style = "color: #666;" > Replace phone/mobile with device< / small > < / li >
< / ul >
< / div >
< div >
< h5 style = "color: #dc3545;" > 🚀 Advanced Examples:< / h5 >
< ul style = "margin: 5px 0; padding-left: 20px; line-height: 1.6;" >
< li > < code > ^(.+) - (.+)$ -> $2: $1< / code > < br > < small style = "color: #666;" > Swap parts separated by " - "< / small > < / li >
< li > < code > (\d{4})-(\d{2})-(\d{2}) -> $3/$2/$1< / code > < br > < small style = "color: #666;" > Change date format YYYY-MM-DD to DD/MM/YYYY< / small > < / li >
< li > < code > ^(.{1,20}).*$ -> $1...< / code > < br > < small style = "color: #666;" > Truncate long comments to 20 chars< / small > < / li >
< li > < code > ([a-zA-Z]+)\s+(\d+) -> $1_$2< / code > < br > < small style = "color: #666;" > Replace spaces with underscores in "word number"< / small > < / li >
< / ul >
< / div >
< / div >
< div style = "margin-top: 15px; padding: 12px; background-color: #fff3cd; border-radius: 5px; border-left: 4px solid #ffc107;" >
< strong > 💡 Tips:< / strong >
< ul style = "margin: 8px 0; padding-left: 20px; font-size: 13px; line-height: 1.5;" >
< li > Use < code > $1, $2, $3...< / code > to reference captured groups in parentheses< / li >
< li > Use < code > ^< / code > for start of string, < code > $< / code > for end of string< / li >
< li > Use < code > .*< / code > to match any characters, < code > .+< / code > to match one or more characters< / li >
< li > Test your regex carefully - invalid patterns will show an error< / li >
< li > Leave empty to keep original comments unchanged< / li >
< / ul >
< / div >
< / div >
< / div >
< / div >
2025-06-27 16:36:02 +03:00
< div class = "submit-row" >
< input type = "submit" value = "Move Selected Links" class = "default" id = "submit-btn" disabled >
< a href = "{% url 'admin:vpn_server_changelist' %}" class = "button cancel" > Cancel< / a >
< / div >
< / form >
< / div >
< script >
// Client links data for each server
var linksByServer = {
{% for server, links in links_by_server.items %}
"{{ server.id }}": [
{% for link in links %}
{
"id": {{ link.id }},
"link": "{{ link.link|escapejs }}",
"comment": "{{ link.comment|escapejs }}",
"username": "{{ link.acl.user.username|escapejs }}",
"user_comment": "{{ link.acl.user.comment|escapejs }}",
"created_at": "{{ link.acl.created_at|date:'Y-m-d H:i' }}",
"full_url": "{{ EXTERNAL_ADDRESS }}/ss/{{ link.link }}#{{ link.acl.server.name|escapejs }}"
},
{% endfor %}
],
{% endfor %}
};
function updateLinksList() {
var sourceServerId = document.getElementById('source_server').value;
var linksContainer = document.getElementById('links-container');
var submitBtn = document.getElementById('submit-btn');
if (!sourceServerId) {
linksContainer.innerHTML = '< p style = "color: #666;" > Please select a source server first.< / p > ';
submitBtn.disabled = true;
return;
}
var links = linksByServer[sourceServerId] || [];
if (links.length === 0) {
linksContainer.innerHTML = '< p style = "color: #666;" > No client links found for this server.< / p > ';
submitBtn.disabled = true;
return;
}
// Group links by user for better organization
var linksByUser = {};
links.forEach(function(link) {
if (!linksByUser[link.username]) {
linksByUser[link.username] = {
user_comment: link.user_comment,
created_at: link.created_at,
links: []
};
}
linksByUser[link.username].links.push(link);
});
var html = '< div style = "max-height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;" > ';
html += '< div style = "margin-bottom: 10px;" > ';
html += '< button type = "button" onclick = "toggleAllLinks()" style = "padding: 5px 10px; margin-right: 10px;" > Select All< / button > ';
html += '< button type = "button" onclick = "toggleAllLinks(false)" style = "padding: 5px 10px;" > Deselect All< / button > ';
html += '< / div > ';
html += '< table style = "width: 100%; border-collapse: collapse;" > ';
html += '< thead > < tr style = "background-color: #f5f5f5;" > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd; width: 50px;" > Select< / th > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd;" > Username< / th > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd;" > Link Comment< / th > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd;" > Link ID< / th > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd;" > User Comment< / th > ';
html += '< th style = "padding: 8px; border: 1px solid #ddd;" > Created< / th > ';
html += '< / tr > < / thead > < tbody > ';
// Sort users alphabetically
var sortedUsers = Object.keys(linksByUser).sort();
sortedUsers.forEach(function(username) {
var userData = linksByUser[username];
var userLinks = userData.links;
// Add user row header if user has multiple links
if (userLinks.length > 1) {
html += '< tr style = "background-color: #f9f9f9;" > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd; text-align: center;" > ';
html += '< input type = "checkbox" class = "user-checkbox" data-username = "' + username + '" onchange = "toggleUserLinks(\'' + username + '\')" > ';
html += '< / td > ';
html += '< td colspan = "5" style = "padding: 8px; border: 1px solid #ddd;" > < strong > ' + username + '< / strong > (' + userLinks.length + ' links)< / td > ';
html += '< / tr > ';
}
// Add individual link rows
userLinks.forEach(function(link) {
html += '< tr class = "link-row" data-username = "' + username + '" > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd; text-align: center;" > ';
2025-06-27 17:08:32 +03:00
html += '< input type = "checkbox" name = "selected_links" value = "' + link.id + '" class = "link-checkbox" data-username = "' + username + '" onchange = "updateSubmitButton(); updateUserCheckbox(\'' + username + '\'); updateRegexPreview()" > ';
2025-06-27 16:36:02 +03:00
html += '< / td > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd;" > ' + (userLinks.length === 1 ? '< strong > ' + username + '< / strong > ' : '↳') + '< / td > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd;" > ' + (link.comment || '< em > No comment< / em > ') + '< / td > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd; font-family: monospace; font-size: 12px;" > ' + link.link + '< / td > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd;" > ' + (userData.user_comment || '') + '< / td > ';
html += '< td style = "padding: 8px; border: 1px solid #ddd;" > ' + userData.created_at + '< / td > ';
html += '< / tr > ';
});
});
html += '< / tbody > < / table > < / div > ';
linksContainer.innerHTML = html;
updateSubmitButton();
}
function toggleAllLinks(selectAll = true) {
var checkboxes = document.getElementsByName('selected_links');
var userCheckboxes = document.getElementsByClassName('user-checkbox');
for (var i = 0; i < checkboxes.length ; i + + ) {
checkboxes[i].checked = selectAll;
}
for (var i = 0; i < userCheckboxes.length ; i + + ) {
userCheckboxes[i].checked = selectAll;
}
updateSubmitButton();
2025-06-27 17:08:32 +03:00
updateRegexPreview();
2025-06-27 16:36:02 +03:00
}
function toggleUserLinks(username) {
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
for (var i = 0; i < userLinkCheckboxes.length ; i + + ) {
userLinkCheckboxes[i].checked = userCheckbox.checked;
}
updateSubmitButton();
2025-06-27 17:08:32 +03:00
updateRegexPreview();
2025-06-27 16:36:02 +03:00
}
function updateUserCheckbox(username) {
var userLinkCheckboxes = document.querySelectorAll('.link-checkbox[data-username="' + username + '"]');
var userCheckbox = document.querySelector('.user-checkbox[data-username="' + username + '"]');
if (!userCheckbox) return; // No user checkbox for single-link users
var allChecked = true;
var noneChecked = true;
for (var i = 0; i < userLinkCheckboxes.length ; i + + ) {
if (userLinkCheckboxes[i].checked) {
noneChecked = false;
} else {
allChecked = false;
}
}
if (allChecked) {
userCheckbox.checked = true;
userCheckbox.indeterminate = false;
} else if (noneChecked) {
userCheckbox.checked = false;
userCheckbox.indeterminate = false;
} else {
userCheckbox.checked = false;
userCheckbox.indeterminate = true;
}
}
function updateSubmitButton() {
var checkboxes = document.getElementsByName('selected_links');
var sourceServer = document.getElementById('source_server').value;
var targetServer = document.getElementById('target_server').value;
var submitBtn = document.getElementById('submit-btn');
var hasSelected = false;
for (var i = 0; i < checkboxes.length ; i + + ) {
if (checkboxes[i].checked) {
hasSelected = true;
break;
}
}
submitBtn.disabled = !(hasSelected & & sourceServer & & targetServer & & sourceServer !== targetServer);
}
2025-06-27 17:08:32 +03:00
// Add regex preview functionality
document.getElementById('comment_regex').addEventListener('input', function() {
updateRegexPreview();
});
function updateRegexPreview() {
var regexInput = document.getElementById('comment_regex').value.trim();
// Remove any existing preview
var existingPreview = document.getElementById('regex-preview');
if (existingPreview) {
existingPreview.remove();
}
if (!regexInput) return;
// Parse pattern -> replacement format
var parts = regexInput.split(' -> ');
if (parts.length !== 2) {
showRegexError('Invalid format. Use: pattern -> replacement');
return;
}
var pattern = parts[0];
var replacement = parts[1];
try {
var regex = new RegExp(pattern, 'g');
// Test with sample comments from currently selected links
var sampleComments = getSampleComments();
if (sampleComments.length > 0) {
showRegexPreview(sampleComments, regex, replacement);
}
} catch (e) {
showRegexError('Invalid regex pattern: ' + e.message);
}
}
function getSampleComments() {
var comments = [];
var checkboxes = document.getElementsByName('selected_links');
for (var i = 0; i < checkboxes.length & & comments . length < 5 ; i + + ) {
if (checkboxes[i].checked) {
var row = checkboxes[i].closest('tr');
var commentCell = row.children[2]; // Link Comment column
var commentText = commentCell.textContent.trim();
if (commentText & & commentText !== 'No comment') {
comments.push(commentText);
} else {
comments.push(''); // Include empty comments
}
}
}
// Add some default samples if no selected links
if (comments.length === 0) {
comments = ['iPhone 13', 'Work Laptop', 'Home Router', '', 'Android Phone'];
}
return comments;
}
function showRegexPreview(samples, regex, replacement) {
var previewHtml = '< div id = "regex-preview" style = "margin-top: 15px; padding: 15px; background-color: #e8f5e8; border-radius: 5px; border-left: 4px solid #28a745;" > ';
previewHtml += '< h5 style = "margin-top: 0; color: #28a745;" > ✅ Preview (selected links):< / h5 > ';
previewHtml += '< table style = "width: 100%; font-size: 13px; border-collapse: collapse;" > ';
previewHtml += '< tr style = "background-color: rgba(40, 167, 69, 0.1);" > < th style = "text-align: left; padding: 8px; border: 1px solid #28a745;" > Original< / th > < th style = "text-align: left; padding: 8px; border: 1px solid #28a745;" > →< / th > < th style = "text-align: left; padding: 8px; border: 1px solid #28a745;" > Transformed< / th > < / tr > ';
samples.forEach(function(comment) {
var original = comment || '(empty)';
var transformed = comment.replace(regex, replacement);
var changed = original !== transformed;
previewHtml += '< tr > ';
previewHtml += '< td style = "padding: 8px; border: 1px solid #ddd; font-family: monospace;" > ' + original + '< / td > ';
previewHtml += '< td style = "padding: 8px; border: 1px solid #ddd; text-align: center;" > →< / td > ';
previewHtml += '< td style = "padding: 8px; border: 1px solid #ddd; font-family: monospace;' + (changed ? ' font-weight: bold; color: #28a745; background-color: rgba(40, 167, 69, 0.1);' : '') + '" > ' + transformed + '< / td > ';
previewHtml += '< / tr > ';
});
previewHtml += '< / table > < / div > ';
document.querySelector('.regex-help').insertAdjacentHTML('afterend', previewHtml);
}
function showRegexError(message) {
var errorHtml = '< div id = "regex-preview" style = "margin-top: 15px; padding: 15px; background-color: #f8d7da; border-radius: 5px; border-left: 4px solid #dc3545;" > ';
errorHtml += '< h5 style = "margin-top: 0; color: #dc3545;" > ❌ Error:< / h5 > ';
errorHtml += '< p style = "margin: 0; color: #721c24; font-weight: bold;" > ' + message + '< / p > ';
errorHtml += '< / div > ';
document.querySelector('.regex-help').insertAdjacentHTML('afterend', errorHtml);
}
2025-06-27 16:36:02 +03:00
// Form submission confirmation
document.getElementById('move-clients-form').addEventListener('submit', function(e) {
var checkboxes = document.getElementsByName('selected_links');
var selectedCount = 0;
var selectedUsers = new Set();
for (var i = 0; i < checkboxes.length ; i + + ) {
if (checkboxes[i].checked) {
selectedCount++;
selectedUsers.add(checkboxes[i].getAttribute('data-username'));
}
}
var sourceServerName = document.getElementById('source_server').selectedOptions[0].text;
var targetServerName = document.getElementById('target_server').selectedOptions[0].text;
2025-06-27 17:08:32 +03:00
var commentRegex = document.getElementById('comment_regex').value.trim();
2025-06-27 16:36:02 +03:00
var confirmMessage = 'Are you sure you want to move ' + selectedCount + ' link(s) for ' + selectedUsers.size + ' user(s) from "' + sourceServerName + '" to "' + targetServerName + '"?\n\n';
confirmMessage += 'This action will:\n';
confirmMessage += '- Transfer selected links to target server\n';
confirmMessage += '- Create ACLs for users who don\'t have access to target server\n';
confirmMessage += '- Remove empty ACLs from source server\n';
2025-06-27 17:08:32 +03:00
confirmMessage += '- Preserve all link settings and comments\n';
if (commentRegex) {
confirmMessage += '- Apply regex transformation to comments: "' + commentRegex + '"\n';
}
confirmMessage += '\nThis cannot be undone.';
2025-06-27 16:36:02 +03:00
if (!confirm(confirmMessage)) {
e.preventDefault();
}
});
< / script >
< style >
.alert {
margin: 10px 0;
padding: 10px;
border-radius: 4px;
}
.form-row {
margin-bottom: 20px;
}
.submit-row {
padding: 12px 14px;
margin: 0 0 20px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
.submit-row input[type="submit"] {
background: #417690;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.submit-row input[type="submit"]:disabled {
background: #ccc;
cursor: not-allowed;
}
.submit-row .cancel {
background: #6c757d;
color: white;
padding: 10px 15px;
border-radius: 4px;
text-decoration: none;
}
.submit-row .cancel:hover {
background: #5a6268;
color: white;
}
.link-row:hover {
background-color: #f8f9fa;
}
input[type="checkbox"]:indeterminate {
opacity: 0.5;
}
2025-06-27 17:08:32 +03:00
#comment_regex {
transition: border-color 0.3s ease;
}
#comment_regex:focus {
border-color: #007cba !important;
box-shadow: 0 0 5px rgba(0, 124, 186, 0.3);
}
2025-06-27 16:36:02 +03:00
< / style >
{% endblock %}