-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp.py
More file actions
1823 lines (1525 loc) · 68.7 KB
/
app.py
File metadata and controls
1823 lines (1525 loc) · 68.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# Developer Management Tool - Comprehensive developer workspace management
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session
from flask_login import LoginManager, login_required, current_user
import requests
import os
import psycopg2
import paramiko
from src.api_endpoints import api_bp
from src.database import db
import subprocess
import json
import re
import zipfile
import tempfile
import shutil
import glob
from datetime import datetime, timedelta
from werkzeug.utils import secure_filename
import logging
import importlib.metadata
import importlib.util
from models import Project, Task, TaskNote, ProjectServer, ProjectDatabase, Setting, User
from auth import check_subscription_status, subscription_required, premium_feature_required, get_subscription_portal_url
from src.portal_auth import get_portal_user_status, premium_required
from flask_sock import Sock
import threading
import queue
import pty
import select
import termios
import tty
import struct
import fcntl
import signal
import time
import uuid
# Try to import markdown safely
try:
import markdown
MARKDOWN_AVAILABLE = True
except (ImportError, AttributeError):
MARKDOWN_AVAILABLE = False
logging.warning("Markdown support is not available")
# Initialize Flask application
app = Flask(__name__,
template_folder='src/templates',
static_folder='src/static')
app.secret_key = os.urandom(24)
app.config['UPLOAD_FOLDER'] = '/tmp/odoo_dev_tools_uploads'
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max upload
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///dev_tools.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_pre_ping': True,
'pool_recycle': 300,
}
# Initialize database
db.init_app(app)
# Create database tables
with app.app_context():
try:
db.create_all()
print("Database tables created successfully")
except Exception as e:
print(f"Error creating database tables: {str(e)}")
raise
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'
# Initialize Flask-Sock
sock = Sock(app)
# Store active SSH sessions
active_sessions = {}
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Register API blueprint
app.register_blueprint(api_bp)
# Create upload folder if it doesn't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Add portal status function to template globals
@app.template_global()
def get_portal_user_status():
"""Make portal status available in templates"""
from src.portal_auth import get_portal_user_status as get_status
return get_status()
# Global settings
SSH_CONFIG_DIR = os.path.expanduser("~/.ssh/config.d")
FILESTORE_DIR = os.path.expanduser("~/.local/share/Odoo/filestore")
# Ensure necessary directories exist
os.makedirs(SSH_CONFIG_DIR, exist_ok=True)
os.makedirs(FILESTORE_DIR, exist_ok=True)
# === Helper Functions ===
def get_setting(key, default=None):
"""Get a setting value from the database"""
try:
setting = Setting.query.filter_by(key=key).first()
return setting.value if setting else default
except Exception as e:
logger.error(f"Error getting setting {key}: {str(e)}")
return default
def get_db_connection():
"""Create a connection to PostgreSQL using settings from the database"""
try:
# Get PostgreSQL connection settings from database
with app.app_context():
# Get settings with defaults if not set
user = get_setting('postgres_user', 'postgres')
password = get_setting('postgres_password', '')
host = get_setting('postgres_host', '127.0.0.1')
port = get_setting('postgres_port', '5432')
logger.info(f"Attempting to connect to PostgreSQL at {host}:{port} as user {user}")
# Build connection string based on whether password is provided
if password:
conn = psycopg2.connect(
dbname="postgres",
user=user,
password=password,
host=host,
port=port
)
else:
# Use peer authentication (no password)
conn = psycopg2.connect(
dbname="postgres",
user=user,
host=host,
port=port
)
conn.autocommit = True
logger.info("Successfully connected to PostgreSQL")
return conn
except Exception as e:
logger.error(f"Database connection error: {str(e)}")
flash(f'Could not connect to PostgreSQL: {str(e)}', 'danger')
return None
def format_size(size_bytes):
"""Format size in bytes to human-readable format"""
if size_bytes > 1073741824: # 1 GB
return f"{size_bytes / 1073741824:.2f} GB"
else:
return f"{size_bytes / 1048576:.2f} MB"
def get_dir_size(path):
"""Get the size of a directory in bytes"""
total_size = 0
for dirpath, dirnames, filenames in os.walk(path):
for filename in filenames:
file_path = os.path.join(dirpath, filename)
try:
total_size += os.path.getsize(file_path)
except (FileNotFoundError, PermissionError):
pass
return total_size
def get_ssh_servers():
"""Get list of SSH servers from config files"""
if not os.path.exists(SSH_CONFIG_DIR):
return []
# List all .conf files in the SSH config directory
config_files = [f for f in os.listdir(SSH_CONFIG_DIR) if f.endswith('.conf')]
servers = []
for conf_file in config_files:
file_path = os.path.join(SSH_CONFIG_DIR, conf_file)
with open(file_path, 'r') as f:
content = f.read()
# Parse the SSH config file
host = None
hostname = None
user = None
port = "22" # Default
key_file = None
for line in content.splitlines():
line = line.strip()
if line.startswith('Host '):
host = line.split(' ', 1)[1].strip()
elif line.startswith('HostName '):
hostname = line.split(' ', 1)[1].strip()
elif line.startswith('User '):
user = line.split(' ', 1)[1].strip()
elif line.startswith('Port '):
port = line.split(' ', 1)[1].strip()
elif line.startswith('IdentityFile '):
key_file = line.split(' ', 1)[1].strip()
if host and hostname:
servers.append({
'host': host,
'hostname': hostname,
'user': user or "",
'port': port,
'key_file': key_file or ""
})
return servers
def update_main_ssh_config():
"""Ensure the main SSH config includes the config.d directory"""
ssh_config_file = os.path.expanduser("~/.ssh/config")
# Create the main config file if it doesn't exist
if not os.path.exists(ssh_config_file):
os.makedirs(os.path.dirname(ssh_config_file), exist_ok=True)
with open(ssh_config_file, 'w') as f:
f.write(f"Include ~/.ssh/config.d/*.conf\n")
return
# Check if the Include line already exists
include_line = f"Include ~/.ssh/config.d/*.conf"
with open(ssh_config_file, 'r') as f:
if include_line in f.read():
return
# Append the Include line
with open(ssh_config_file, 'a') as f:
f.write(f"\n{include_line}\n")
def get_ssh_config(host):
"""Get SSH configuration for a host"""
config_file = os.path.join(SSH_CONFIG_DIR, f"{host}.conf")
if not os.path.exists(config_file):
return None
config = {}
with open(config_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
key, value = line.split(' ', 1)
config[key] = value.strip()
return config
def create_ssh_client(host):
"""Create and configure SSH client"""
config = get_ssh_config(host)
if not config:
return None
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# Get connection parameters
hostname = config.get('HostName', host)
username = config.get('User', os.getenv('USER'))
port = int(config.get('Port', 22))
# Connect using key or password
if 'IdentityFile' in config:
key_path = os.path.expanduser(config['IdentityFile'])
client.connect(
hostname=hostname,
username=username,
key_filename=key_path,
port=port
)
else:
# Try to get password from config or environment
password = os.getenv('SSH_PASSWORD', '')
client.connect(
hostname=hostname,
username=username,
password=password,
port=port
)
return client
except Exception as e:
print(f"SSH connection error: {str(e)}")
return None
# === Routes ===
@app.route('/')
def index():
"""Main dashboard page"""
return render_template('index.html')
# === SSH Server Routes ===
@app.route('/servers')
def ssh_servers():
"""Remote Server management page"""
servers = get_ssh_servers()
return render_template('ssh.html', servers=servers)
@app.route('/servers/add', methods=['GET', 'POST'])
def add_ssh_server():
"""Add a new Remote Server"""
if request.method == 'POST':
# Get form data
host = request.form.get('host').strip()
hostname = request.form.get('hostname').strip()
user = request.form.get('user').strip()
port = request.form.get('port').strip() or "22"
auth_method = request.form.get('auth_method', 'key')
key_file = request.form.get('key_file', '').strip() if auth_method == 'key' else ''
password = request.form.get('password', '').strip() if auth_method == 'password' else ''
# Validate inputs
if not host or not hostname:
flash('Host and IP/Domain are required fields', 'danger')
return redirect(url_for('add_ssh_server'))
# Create config file
config_file = os.path.join(SSH_CONFIG_DIR, f"{host}.conf")
# Check if file already exists
if os.path.exists(config_file):
if not request.form.get('confirm_overwrite'):
# Ask for confirmation
flash(f'SSH configuration for {host} already exists. Confirm overwrite?', 'warning')
session['overwrite_data'] = {
'host': host,
'hostname': hostname,
'user': user,
'port': port,
'auth_method': auth_method,
'key_file': key_file,
'password': password
}
return render_template('ssh_add.html', overwrite=True,
host=host, hostname=hostname,
user=user, port=port, auth_method=auth_method,
key_file=key_file, password=password)
# Write the configuration file
with open(config_file, 'w') as f:
f.write(f"Host {host}\n")
f.write(f" HostName {hostname}\n")
if user:
f.write(f" User {user}\n")
f.write(f" Port {port}\n")
# Authentication settings
if auth_method == 'key' and key_file:
f.write(f" IdentityFile {key_file}\n")
f.write(f" PreferredAuthentications publickey\n")
elif auth_method == 'password' and password:
f.write(f" PreferredAuthentications password\n")
f.write(f" PasswordAuthentication yes\n")
# Store password in a safer way in a real-world application
# For this demo, we'll add it as a comment (NOT recommended for production)
f.write(f" # Password: {password}\n")
# Update main SSH config if needed
update_main_ssh_config()
flash(f'SSH server "{host}" added successfully', 'success')
return redirect(url_for('ssh_servers'))
# Handle overwrite confirmation from session
overwrite_data = session.pop('overwrite_data', None)
if overwrite_data:
return render_template('ssh_add.html', overwrite=True,
host=overwrite_data.get('host', ''),
hostname=overwrite_data.get('hostname', ''),
user=overwrite_data.get('user', ''),
port=overwrite_data.get('port', '22'),
key_file=overwrite_data.get('key_file', ''))
return render_template('ssh_add.html')
@app.route('/servers/delete/<host>', methods=['GET', 'POST'])
def delete_ssh_server(host):
"""Delete a Remote Server configuration"""
try:
# Build the path to the config file
config_file = os.path.join(SSH_CONFIG_DIR, f"{host}.conf")
# Check if the file exists
if not os.path.exists(config_file):
flash(f"SSH configuration for {host} not found.", "danger")
return redirect(url_for('ssh_servers'))
# Get server details for display
servers = get_ssh_servers()
server = next((s for s in servers if s['host'] == host), None)
if not server:
flash(f"SSH server {host} not found.", "danger")
return redirect(url_for('ssh_servers'))
# If GET request, show confirmation page
if request.method == 'GET':
return render_template('delete_ssh_server.html', server=server)
# If POST request, process deletion
os.remove(config_file)
# Ensure the main SSH config includes the config.d directory
update_main_ssh_config()
flash(f"SSH server {host} has been deleted successfully.", "success")
return redirect(url_for('ssh_servers'))
except Exception as e:
logger.error(f"Error deleting SSH server: {str(e)}")
flash(f"Error deleting SSH server: {str(e)}", "danger")
return redirect(url_for('ssh_servers'))
@app.route('/servers/generate_command/<host>', methods=['GET'])
def generate_ssh_command(host):
"""Generate a Remote Server command for the client to execute"""
servers = get_ssh_servers()
# Find the server with matching host
for server in servers:
if server.get('host') == host:
# Build ssh command with appropriate flags
command = f"ssh {server.get('host')}"
# Add authentication info to command response
auth_type = 'key' if server.get('identity_file') else 'password'
response = {
'command': command,
'auth_type': auth_type,
'host': server.get('host'),
'user': server.get('user', ''),
'hostname': server.get('hostname', '')
}
return jsonify(response)
return jsonify({'error': 'Server not found'})
@app.route('/servers/connect/<host>', methods=['GET', 'POST'])
def connect_ssh(host):
"""Connect to SSH server using system ssh command only"""
import subprocess
import os
# Parse config as before
config_file = os.path.join(SSH_CONFIG_DIR, f"{host}.conf")
if not os.path.exists(config_file):
flash('Server configuration not found', 'error')
return redirect(url_for('ssh_servers'))
# Get SSH_AUTH_SOCK from environment or use default
ssh_auth_sock = os.environ.get('SSH_AUTH_SOCK', '/run/user/{}/keyring/ssh'.format(os.getuid()))
# Set up environment for SSH command
env = os.environ.copy()
env['SSH_AUTH_SOCK'] = ssh_auth_sock
# Optionally parse config for display, but not needed for connection
ssh_config = os.path.expanduser('~/.ssh/config')
ssh_cmd = [
'ssh',
'-F', ssh_config,
host,
'echo "Connection successful"'
]
try:
# Run SSH command with the correct environment
result = subprocess.run(
ssh_cmd,
capture_output=True,
text=True,
timeout=10,
env=env
)
if result.returncode == 0:
flash(f'Successfully connected to {host}', 'success')
else:
flash(f'Failed to connect: {result.stderr}', 'danger')
except Exception as e:
flash(f'Error connecting to SSH: {str(e)}', 'danger')
return redirect(url_for('ssh_servers'))
@app.route('/servers/<host>/details')
@premium_required
def ssh_server_details(host):
"""Remote Server details page for managing servers and installing Odoo"""
# Get portal user status for premium check
portal_status = get_portal_user_status()
is_premium = portal_status and portal_status.get('is_premium', False)
servers = get_ssh_servers()
# Find the specific server
server = None
for s in servers:
if s['host'] == host:
server = s
break
if not server:
flash(f'SSH server "{host}" not found', 'danger')
return redirect(url_for('ssh_servers'))
return render_template('ssh_server_details.html', server=server, is_premium=is_premium)
@app.route('/servers/<host>/edit', methods=['GET', 'POST'])
@login_required
@premium_required
def edit_ssh_server(host):
"""Edit an existing SSH server configuration"""
# Get portal user status for premium check
portal_status = get_portal_user_status()
is_premium = portal_status and portal_status.get('is_premium', False)
if not is_premium:
flash('This feature requires a premium subscription', 'warning')
return redirect(url_for('settings'))
# Get all servers
servers = get_ssh_servers()
# Find the specific server
server = None
for s in servers:
if s['host'] == host:
server = s
break
if not server:
flash(f'SSH server "{host}" not found', 'danger')
return redirect(url_for('ssh_servers'))
if request.method == 'POST':
# Get form data
new_host = request.form.get('host').strip()
hostname = request.form.get('hostname').strip()
user = request.form.get('user').strip()
port = request.form.get('port').strip() or "22"
auth_method = request.form.get('auth_method', 'key')
key_file = request.form.get('key_file', '').strip() if auth_method == 'key' else ''
password = request.form.get('password', '').strip() if auth_method == 'password' else ''
# Validate inputs
if not new_host or not hostname:
flash('Host and IP/Domain are required fields', 'danger')
return render_template('ssh_edit.html', server=server)
# Create config file
config_file = os.path.join(SSH_CONFIG_DIR, f"{host}.conf")
new_config_file = os.path.join(SSH_CONFIG_DIR, f"{new_host}.conf")
# Check if new hostname already exists (if changed)
if new_host != host and os.path.exists(new_config_file):
flash(f'SSH configuration for {new_host} already exists', 'danger')
return render_template('ssh_edit.html', server=server)
# Write the configuration file
with open(config_file, 'w') as f:
f.write(f"Host {new_host}\n")
f.write(f" HostName {hostname}\n")
if user:
f.write(f" User {user}\n")
f.write(f" Port {port}\n")
# Authentication settings
if auth_method == 'key' and key_file:
f.write(f" IdentityFile {key_file}\n")
f.write(f" PreferredAuthentications publickey\n")
elif auth_method == 'password' and password:
f.write(f" PreferredAuthentications password\n")
f.write(f" PasswordAuthentication yes\n")
# Store password in a safer way in a real-world application
# For this demo, we'll add it as a comment (NOT recommended for production)
f.write(f" # Password: {password}\n")
# Rename config file if hostname changed
if new_host != host:
os.rename(config_file, new_config_file)
# Update main SSH config if needed
update_main_ssh_config()
flash(f'SSH server "{new_host}" updated successfully', 'success')
return redirect(url_for('ssh_server_details', host=new_host))
return render_template('ssh_edit.html', server=server)
# === Database Routes ===
@app.route('/databases')
def list_databases():
"""List all Odoo databases"""
conn = get_db_connection()
if not conn:
return render_template('databases.html', databases=[])
try:
cursor = conn.cursor()
# Get all databases with size
cursor.execute("""
SELECT
d.datname AS database_name,
u.usename AS owner,
pg_database_size(d.datname) AS size_bytes
FROM
pg_database d
JOIN pg_user u ON d.datdba = u.usesysid
WHERE
d.datname NOT IN ('postgres', 'template0', 'template1')
ORDER BY
d.datname
""")
db_rows = cursor.fetchall()
databases = []
for db_name, owner, size_bytes in db_rows:
# Format database size
db_size = format_size(size_bytes)
# Check if it's an Odoo database and get version
odoo_version = "Not Odoo DB"
is_enterprise = False
expiration_date = None
try:
# Try to connect to the specific database
db_conn = psycopg2.connect(
dbname=db_name,
user=get_setting('postgres_user', 'postgres'),
password=get_setting('postgres_password', ''),
host=get_setting('postgres_host', '127.0.0.1'),
port=get_setting('postgres_port', '5432')
)
db_cursor = db_conn.cursor()
# Check if it's an Odoo database
db_cursor.execute("""
SELECT 1 FROM information_schema.tables
WHERE table_name = 'ir_module_module'
""")
if db_cursor.fetchone():
# Get Odoo version from base module
db_cursor.execute("""
SELECT latest_version FROM ir_module_module
WHERE name = 'base' LIMIT 1
""")
version_row = db_cursor.fetchone()
if version_row:
odoo_version = version_row[0]
# Check if it's Enterprise
db_cursor.execute("""
SELECT 1 FROM ir_module_module
WHERE name = 'web_enterprise' AND state = 'installed'
""")
is_enterprise = bool(db_cursor.fetchone())
# Try to get the expiration date for enterprise databases
if is_enterprise:
try:
db_cursor.execute("""
SELECT value FROM ir_config_parameter
WHERE key = 'database.expiration_date'
""")
date_row = db_cursor.fetchone()
if date_row:
expiration_date = date_row[0]
except Exception as e:
logger.error(f"Error getting expiration date for {db_name}: {str(e)}")
db_cursor.close()
db_conn.close()
except Exception as e:
logger.error(f"Error checking database {db_name}: {str(e)}")
# Continue with default values if we can't check the database
# Get filestore size
filestore_path = os.path.join(FILESTORE_DIR, db_name)
if os.path.exists(filestore_path):
filestore_bytes = get_dir_size(filestore_path)
filestore_size = format_size(filestore_bytes)
else:
filestore_size = "N/A"
# Add database to list
databases.append({
'name': db_name,
'owner': owner,
'version': odoo_version,
'expiration_date': expiration_date,
'size': db_size,
'filestore_size': filestore_size,
'is_enterprise': is_enterprise
})
cursor.close()
conn.close()
if not databases:
flash('No databases found. Please check your PostgreSQL connection settings.', 'warning')
return render_template('databases.html', databases=databases)
except Exception as e:
logger.error(f"Error listing databases: {str(e)}")
flash(f'Error listing databases: {str(e)}', 'danger')
return render_template('databases.html', databases=[])
@app.route('/databases/drop/<db_name>', methods=['GET', 'POST'])
def drop_database(db_name):
"""Drop a database and its filestore"""
if request.method == 'POST':
try:
# Connect to PostgreSQL
conn = get_db_connection()
if not conn:
flash('Could not connect to PostgreSQL', 'danger')
return redirect(url_for('list_databases'))
cursor = conn.cursor()
# First terminate all connections to the database
cursor.execute(f"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{db_name}'
""")
# Drop the database
cursor.execute(f"DROP DATABASE IF EXISTS \"{db_name}\"")
# Remove the filestore if it exists
filestore_path = os.path.join(FILESTORE_DIR, db_name)
if os.path.exists(filestore_path):
shutil.rmtree(filestore_path)
cursor.close()
conn.close()
flash(f'Database "{db_name}" and its filestore have been dropped', 'success')
except Exception as e:
flash(f'Error dropping database: {str(e)}', 'danger')
return redirect(url_for('list_databases'))
# Confirmation page
return render_template('drop_database.html', db_name=db_name)
@app.route('/databases/restore', methods=['GET', 'POST'])
def restore_database():
"""Restore a database from backup"""
if request.method == 'POST':
# Check if the post request has the file part
if 'backup_file' not in request.files:
flash('No file part', 'danger')
return redirect(request.url)
file = request.files['backup_file']
# If user does not select file, browser also submits an empty part without filename
if file.filename == '':
flash('No selected file', 'danger')
return redirect(request.url)
if file:
# Check if the file is a .dump file
is_dump_file = file.filename.lower().endswith('.dump')
# Get form data
db_name = request.form.get('db_name', '').strip()
# Validate database name
if not re.match(r'^[a-zA-Z0-9_]+$', db_name):
flash('Database name can only contain letters, numbers, and underscores', 'danger')
return redirect(request.url)
# Get options
deactivate_cron = 'deactivate_cron' in request.form
deactivate_mail = 'deactivate_mail' in request.form
reset_admin = 'reset_admin' in request.form
# Save the file
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = secure_filename(f"{timestamp}_{file.filename}")
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
try:
# Create temp directory for extraction
temp_dir = tempfile.mkdtemp()
# Handle direct .dump files or ZIP archives differently
if is_dump_file:
# Use the uploaded .dump file directly
dump_file = filepath
dump_found = True
has_filestore = False # Direct .dump files don't have filestore
else:
# Extract the backup zip
try:
with zipfile.ZipFile(filepath, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
except zipfile.BadZipFile:
flash('The uploaded file is not a valid ZIP archive. Please upload a proper ZIP file containing SQL dump or a direct .dump file.', 'danger')
shutil.rmtree(temp_dir)
os.remove(filepath)
return redirect(request.url)
# Look for the SQL dump file (both dump.sql and *.dump formats)
dump_file = os.path.join(temp_dir, "dump.sql")
dump_found = os.path.exists(dump_file)
# If dump.sql not found, look for *.dump files
if not dump_found:
dump_files = glob.glob(os.path.join(temp_dir, "*.dump"))
if dump_files:
dump_file = dump_files[0] # Use the first dump file found
dump_found = True
if not dump_found:
flash('Invalid backup: No SQL dump file (dump.sql or *.dump) found in the backup file', 'danger')
shutil.rmtree(temp_dir)
os.remove(filepath)
return redirect(request.url)
# Check for filestore directory or filestore.zip (for backward compatibility)
filestore_dir = os.path.join(temp_dir, "filestore")
filestore_zip = os.path.join(temp_dir, "filestore.zip")
has_filestore_dir = os.path.exists(filestore_dir) and os.path.isdir(filestore_dir)
has_filestore_zip = os.path.exists(filestore_zip)
has_filestore = has_filestore_dir or has_filestore_zip
# Connect to PostgreSQL
conn = get_db_connection()
if not conn:
flash('Could not connect to PostgreSQL', 'danger')
shutil.rmtree(temp_dir)
os.remove(filepath)
return redirect(request.url)
cursor = conn.cursor()
# Drop the database if it exists
cursor.execute(f"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{db_name}'
""")
cursor.execute(f"DROP DATABASE IF EXISTS \"{db_name}\"")
# Create new database
cursor.execute(f"CREATE DATABASE \"{db_name}\" TEMPLATE template0 ENCODING 'UTF8'")
cursor.close()
conn.close()
# Restore the SQL dump
subprocess.run(["psql", "-U", "postgres", "-d", db_name, "-f", dump_file])
# Handle filestore if it exists
if has_filestore:
filestore_path = os.path.join(FILESTORE_DIR, db_name)
# Remove existing filestore if it exists
if os.path.exists(filestore_path):
shutil.rmtree(filestore_path)
# Create the directory
os.makedirs(filestore_path, exist_ok=True)
# Copy filestore directory or extract filestore zip
if has_filestore_dir:
# Copy the filestore directory contents
for item in os.listdir(filestore_dir):
source = os.path.join(filestore_dir, item)
destination = os.path.join(filestore_path, item)
if os.path.isdir(source):
shutil.copytree(source, destination)
else:
shutil.copy2(source, destination)
elif has_filestore_zip:
# Extract the filestore zip
with zipfile.ZipFile(filestore_zip, 'r') as zip_ref:
zip_ref.extractall(filestore_path)
# Post-restore operations
# Connect to the restored database
conn = psycopg2.connect(dbname=db_name, user="postgres")
conn.autocommit = True
cursor = conn.cursor()
if deactivate_cron:
cursor.execute("UPDATE ir_cron SET active = false")
if deactivate_mail:
cursor.execute("UPDATE ir_mail_server SET active = false")
cursor.execute("UPDATE fetchmail_server SET active = false")
if reset_admin:
cursor.execute("""
UPDATE res_users
SET password = 'admin', login = 'admin'
WHERE id = 2
""")
cursor.close()
conn.close()
flash(f'Database "{db_name}" has been successfully restored', 'success')
except Exception as e:
flash(f'Error restoring database: {str(e)}', 'danger')
finally:
# Clean up
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
if os.path.exists(filepath):
os.remove(filepath)
return redirect(url_for('list_databases'))
return render_template('restore_database.html')
@app.route('/databases/extend_enterprise', methods=['GET', 'POST'])
@app.route('/databases/extend_enterprise/<db_name>', methods=['GET', 'POST'])
def extend_enterprise(db_name=None):
"""Extend Odoo Enterprise license expiration date for selected databases"""
# Get enterprise databases for display in the GET request
enterprise_dbs = []
# Connect to PostgreSQL
try:
# Get PostgreSQL connection settings directly to show in debug log
postgres_user = get_setting('postgres_user', 'postgres')
postgres_host = get_setting('postgres_host', '127.0.0.1')
postgres_port = get_setting('postgres_port', '5432')
conn = get_db_connection()
if not conn:
error_msg = 'Could not connect to PostgreSQL. Please check your database settings.'
flash(error_msg, 'danger')
logger.error(error_msg)
return render_template('extend_enterprise.html', enterprise_dbs=[])
except Exception as e:
error_msg = f'PostgreSQL connection error: {str(e)}'
flash(error_msg, 'danger')
logger.error(error_msg)
return render_template('extend_enterprise.html', enterprise_dbs=[])
cursor = conn.cursor()
# Get all databases
cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false")
databases = [row[0] for row in cursor.fetchall() if row[0] not in ('postgres', 'template0', 'template1')]
# Check each database for Odoo Enterprise
for dbs_name in databases:
try:
# Use complete connection settings from the database
postgres_user = get_setting('postgres_user', 'postgres')
postgres_password = get_setting('postgres_password', '')
postgres_host = get_setting('postgres_host', '127.0.0.1')
postgres_port = get_setting('postgres_port', '5432')
# Connect with appropriate parameters based on whether password is set
if postgres_password:
db_conn = psycopg2.connect(
dbname=dbs_name,
user=postgres_user,
password=postgres_password,
host=postgres_host,
port=postgres_port
)
else:
db_conn = psycopg2.connect(
dbname=dbs_name,
user=postgres_user,
host=postgres_host,
port=postgres_port