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 : 10 px 0 ;
padding : 10 px ;
border-radius : 4 px ;
}
. form-row {
margin-bottom : 20 px ;
}
. submit-row {
padding : 12 px 14 px ;
margin : 0 0 20 px ;
background : #f8f8f8 ;
border : 1 px solid #ddd ;
border-radius : 4 px ;
}
. submit-row input [ type = "submit" ] {
background : #417690 ;
color : white ;
border : none ;
padding : 10 px 15 px ;
border-radius : 4 px ;
cursor : pointer ;
margin-right : 10 px ;
}
. submit-row input [ type = "submit" ] : disabled {
background : #ccc ;
cursor : not-allowed ;
}
. submit-row . cancel {
background : #6c757d ;
color : white ;
padding : 10 px 15 px ;
border-radius : 4 px ;
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.3 s ease ;
}
# comment_regex : focus {
border-color : #007cba !important ;
box-shadow : 0 0 5 px rgba ( 0 , 124 , 186 , 0.3 ) ;
}
2025-06-27 16:36:02 +03:00
< / style >
{% endblock %}