diff --git a/horilla_backup/__init__.py b/horilla_backup/__init__.py new file mode 100644 index 000000000..ce8e07933 --- /dev/null +++ b/horilla_backup/__init__.py @@ -0,0 +1 @@ +default_app_config = 'horilla_backup.apps.backupConfig' \ No newline at end of file diff --git a/horilla_backup/admin.py b/horilla_backup/admin.py new file mode 100644 index 000000000..ab2135701 --- /dev/null +++ b/horilla_backup/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import * + +# Register your models here. + +admin.site.register(LocalBackup) +admin.site.register(GoogleDriveBackup) + + \ No newline at end of file diff --git a/horilla_backup/apps.py b/horilla_backup/apps.py new file mode 100644 index 000000000..776d88db9 --- /dev/null +++ b/horilla_backup/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + +class BackupConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'horilla_backup' + + def ready(self): + from django.urls import include, path + from horilla.urls import urlpatterns + + urlpatterns.append( + path("backup/", include("horilla_backup.urls")), + ) + super().ready() + diff --git a/horilla_backup/forms.py b/horilla_backup/forms.py new file mode 100644 index 000000000..6767c1cd4 --- /dev/null +++ b/horilla_backup/forms.py @@ -0,0 +1,155 @@ +from django import forms +from .models import * +from base.forms import ModelForm +from django.template.loader import render_to_string +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from pathlib import Path +from .gdrive import authenticate +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +import os + + +class LocalBackupSetupForm(ModelForm): + verbose_name = "Server Backup" + backup_db = forms.BooleanField(required=False, help_text="Enable to backup database to server.") + backup_media = forms.BooleanField(required=False, help_text="Enable to backup all media files to server.") + interval = forms.BooleanField(required=False, help_text="Enable to automate the backup in a period of seconds.") + fixed = forms.BooleanField(required=False, help_text="Enable to automate the backup in a fixed time.") + + class Meta: + model = LocalBackup + exclude = ['active'] + + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html + + + def clean(self): + cleaned_data = super().clean() + backup_db = cleaned_data.get('backup_db') + backup_media = cleaned_data.get('backup_media') + interval = cleaned_data.get('interval') + fixed = cleaned_data.get('fixed') + seconds = cleaned_data.get('seconds') + hour = cleaned_data.get('hour') + minute = cleaned_data.get('minute') + backup_path = cleaned_data.get('backup_path') + path = Path(backup_path) + if not path.exists(): + raise ValidationError({ + 'backup_path': _('The directory does not exist.') + }) + if backup_db == False and backup_media == False: + raise forms.ValidationError("Please select any backup option.") + if interval == False and fixed == False: + raise forms.ValidationError("Please select any backup automate option.") + if interval == True and seconds == None: + raise ValidationError({ + 'seconds': _('This field is required.') + }) + if fixed == True and hour == None: + raise ValidationError({ + 'hour': _('This field is required.') + }) + if seconds: + if seconds < 0: + raise ValidationError({ + 'seconds': _('Negative value is not accepatable.') + }) + if hour: + if hour < 0 or hour > 24: + raise ValidationError({ + 'hour': _('Enter a hour between 0 to 24.') + }) + if minute: + if minute < 0 or minute > 60: + raise ValidationError({ + 'minute': _('Enter a minute between 0 to 60.') + }) + return cleaned_data + + +class GdriveBackupSetupForm(ModelForm): + verbose_name = "Gdrive Backup" + backup_db = forms.BooleanField(required=False, label="Backup DB", help_text="Enable to backup database to Gdrive") + backup_media = forms.BooleanField(required=False, label="Backup Media", help_text="Enable to backup all media files to Gdrive") + interval = forms.BooleanField(required=False, help_text="Enable to automate the backup in a period of seconds.") + fixed = forms.BooleanField(required=False, help_text="Enable to automate the backup in a fixed time.") + + class Meta: + model = GoogleDriveBackup + exclude = ['active'] + + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html + + def clean(self): + cleaned_data = super().clean() + backup_db = cleaned_data.get('backup_db') + backup_media = cleaned_data.get('backup_media') + interval = cleaned_data.get('interval') + fixed = cleaned_data.get('fixed') + seconds = cleaned_data.get('seconds') + hour = cleaned_data.get('hour') + minute = cleaned_data.get('minute') + service_account_file = cleaned_data.get('service_account_file') + + try: + if GoogleDriveBackup.objects.exists(): + authenticate(service_account_file.path) + else: + file_data = service_account_file.read() + # Save the processed file to the desired location + file_name = service_account_file.name + new_file_name = file_name + # Save using Django's default storage system + relative_path = default_storage.save(new_file_name, ContentFile(file_data)) + # Get the full absolute path + full_path = default_storage.path(relative_path) + authenticate(full_path) + os.remove(full_path) + + except Exception as e: + raise forms.ValidationError("Please provide a valid service account file.") + if backup_db == False and backup_media == False: + raise forms.ValidationError("Please select any backup option.") + if interval == False and fixed == False: + raise forms.ValidationError("Please select any backup automate option.") + if interval == True and seconds == None: + raise ValidationError({ + 'seconds': _('This field is required.') + }) + if fixed == True and hour == None: + raise ValidationError({ + 'hour': _('This field is required.') + }) + if seconds: + if seconds < 0: + raise ValidationError({ + 'seconds': _('Negative value is not accepatable.') + }) + if hour: + if hour < 0 or hour > 24: + raise ValidationError({ + 'hour': _('Enter a hour between 0 to 24.') + }) + if minute: + if minute < 0 or minute > 60: + raise ValidationError({ + 'minute': _('Enter a minute between 0 to 60.') + }) + return cleaned_data \ No newline at end of file diff --git a/horilla_backup/gdrive.py b/horilla_backup/gdrive.py new file mode 100644 index 000000000..3a375fa81 --- /dev/null +++ b/horilla_backup/gdrive.py @@ -0,0 +1,29 @@ +from googleapiclient.discovery import build +from google.oauth2 import service_account +from googleapiclient.http import MediaFileUpload +import os + + +SCOPES = ['https://www.googleapis.com/auth/drive'] + + +def authenticate(service_account_file): + creds = service_account.Credentials.from_service_account_file(service_account_file, scopes=SCOPES) + return creds + + +def upload_file(file_path, service_account_file, parent_folder_id): + creds = authenticate(service_account_file) + service = build('drive', 'v3', credentials=creds) + parent_folder_id = parent_folder_id + + file_metadata = { + 'name' : os.path.basename(file_path), + 'parents' : [parent_folder_id] + } + media = MediaFileUpload(file_path, resumable=True) + file = service.files().create( + body=file_metadata, + media_body=media, + fields='id' + ).execute() diff --git a/horilla_backup/migrations/__init__.py b/horilla_backup/migrations/__init__.py new file mode 100644 index 000000000..63c57730a --- /dev/null +++ b/horilla_backup/migrations/__init__.py @@ -0,0 +1,18 @@ +import atexit + + +def shutdown_function(): + from horilla_backup.models import GoogleDriveBackup, LocalBackup + if GoogleDriveBackup.objects.exists(): + google_drive_backup = GoogleDriveBackup.objects.first() + google_drive_backup.active = False + google_drive_backup.save() + if LocalBackup.objects.exists(): + local_backup = LocalBackup.objects.first() + local_backup.active = False + local_backup.save() + +try: + atexit.register(shutdown_function) +except: + pass \ No newline at end of file diff --git a/horilla_backup/models.py b/horilla_backup/models.py new file mode 100644 index 000000000..42c88bee7 --- /dev/null +++ b/horilla_backup/models.py @@ -0,0 +1,77 @@ +from django.db import models + +#Create your models here. + +class LocalBackup(models.Model): + backup_path = models.CharField(max_length=255, help_text="Specify the path in the server were the backup files should keep") + backup_media = models.BooleanField(blank=True, null=True) + backup_db = models.BooleanField(blank=True, null=True) + interval = models.BooleanField(blank=True, null=True) + fixed = models.BooleanField(blank=True, null=True) + seconds = models.IntegerField(blank=True, null=True) + hour = models.IntegerField(blank=True, null=True) + minute = models.IntegerField(blank=True, null=True) + active = models.BooleanField(default=False) + + + def save(self, *args, **kwargs): + # Check if there's an existing instance + if self.interval == False: + self.seconds = None + if self.fixed == False: + self.hour = None + self.minute = None + if LocalBackup.objects.exists(): + # Get the existing instance + existing_instance = LocalBackup.objects.first() + # Update the fields of the existing instance with the new data + for field in self._meta.fields: + if field.name != 'id': # Avoid changing the primary key + setattr(existing_instance, field.name, getattr(self, field.name)) + # Save the updated instance + super(LocalBackup, existing_instance).save(*args, **kwargs) + return existing_instance + else: + # If no existing instance, proceed with regular save + super(LocalBackup, self).save(*args, **kwargs) + return self + + +class GoogleDriveBackup(models.Model): + service_account_file = models.FileField(upload_to="gdrive_service_account_file", + verbose_name="Service Account File", + help_text="Make sure your file is in JSON format and contains your Google Service Account credentials") + gdrive_folder_id = models.CharField(max_length=255, + verbose_name="Gdrive Folder ID", + help_text="Shared Gdrive folder Id with access granted to Gmail service account. Enable full permissions for seamless connection.") + backup_media = models.BooleanField(blank=True, null=True) + backup_db = models.BooleanField(blank=True, null=True) + interval = models.BooleanField(blank=True, null=True) + fixed = models.BooleanField(blank=True, null=True) + seconds = models.IntegerField(blank=True, null=True) + hour = models.IntegerField(blank=True, null=True) + minute = models.IntegerField(blank=True, null=True) + active = models.BooleanField(default=False) + + + def save(self, *args, **kwargs): + # Check if there's an existing instance + if self.interval == False: + self.seconds = None + if self.fixed == False: + self.hour = None + self.minute = None + if GoogleDriveBackup.objects.exists(): + # Get the existing instance + existing_instance = GoogleDriveBackup.objects.first() + # Update the fields of the existing instance with the new data + for field in self._meta.fields: + if field.name != 'id': # Avoid changing the primary key + setattr(existing_instance, field.name, getattr(self, field.name)) + # Save the updated instance + super(GoogleDriveBackup, existing_instance).save(*args, **kwargs) + return existing_instance + else: + # If no existing instance, proceed with regular save + super(GoogleDriveBackup, self).save(*args, **kwargs) + return self \ No newline at end of file diff --git a/horilla_backup/pgdump.py b/horilla_backup/pgdump.py new file mode 100644 index 000000000..a42346d8d --- /dev/null +++ b/horilla_backup/pgdump.py @@ -0,0 +1,30 @@ +import subprocess +import os + +def dump_postgres_db(db_name, username, output_file, password=None, host='localhost', port=5432): + # Set environment variable for the password if provided + if password: + os.environ['PGPASSWORD'] = password + + # Construct the pg_dump command + dump_command = [ + 'pg_dump', + '-h', host, + '-p', str(port), + '-U', username, + '-F', 'c', # Custom format + '-f', output_file, + db_name + ] + + try: + # Execute the pg_dump command + result = subprocess.run(dump_command, check=True, text=True, capture_output=True) + except subprocess.CalledProcessError as e: + pass + finally: + # Clean up the environment variable + if password: + del os.environ['PGPASSWORD'] + + diff --git a/horilla_backup/scheduler.py b/horilla_backup/scheduler.py new file mode 100644 index 000000000..fcdb345ce --- /dev/null +++ b/horilla_backup/scheduler.py @@ -0,0 +1,146 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from django.core.management import call_command +import os +# from horilla.settings import DBBACKUP_STORAGE_OPTIONS +from .models import * +from .gdrive import * +from .pgdump import * +from horilla import settings +from .zip import * + +scheduler = BackgroundScheduler() + +# def backup_database(): +# folder_path = DBBACKUP_STORAGE_OPTIONS['location'] +# local_backup = LocalBackup.objects.first() +# if folder_path and local_backup: +# DBBACKUP_STORAGE_OPTIONS['location'] = local_backup.backup_path +# folder_path = DBBACKUP_STORAGE_OPTIONS['location'] +# if local_backup.backup_db: +# call_command('dbbackup') +# if local_backup.backup_media: +# call_command("mediabackup") +# files = sorted(os.listdir(folder_path), key=lambda x: os.path.getctime(os.path.join(folder_path, x))) + +# # Remove all files except the last two +# if len(files) > 2: +# for file_name in files[:-2]: +# file_path = os.path.join(folder_path, file_name) +# if os.path.isfile(file_path): +# try: +# os.remove(file_path) +# except: +# pass + + +# def start_backup_job(): +# """ +# Start the backup job based on the LocalBackup configuration. +# """ +# # Check if any LocalBackup object exists +# if LocalBackup.objects.exists(): +# local_backup = LocalBackup.objects.first() + +# # Remove existing job if it exists +# try: +# scheduler.remove_job('backup_job') +# except: +# pass + +# # Add new job based on LocalBackup configuration +# if local_backup.interval: +# scheduler.add_job(backup_database, 'interval', seconds=local_backup.seconds, id='backup_job') +# else: +# scheduler.add_job(backup_database, trigger='cron', hour=local_backup.hour, minute=local_backup.minute, id='backup_job') +# # Start the scheduler if it's not already running +# if not scheduler.running: +# scheduler.start() +# else: +# stop_backup_job() + + +# def stop_backup_job(): +# """ +# Stop the backup job if it exists. +# """ +# try: +# scheduler.remove_job('backup_job') +# except: +# pass + + +# def restart_backup_job(): +# """ +# Restart the backup job by stopping it and starting it again. +# """ +# stop_backup_job() +# start_backup_job() + + +def google_drive_backup(): + if GoogleDriveBackup.objects.exists(): + google_drive = GoogleDriveBackup.objects.first() + service_account_file = google_drive.service_account_file.path + gdrive_folder_id = google_drive.gdrive_folder_id + if google_drive.backup_db: + db = settings.DATABASES['default'] + dump_postgres_db( + db_name=db['NAME'], + username=db['USER'], + output_file='backupdb.dump', + password=db['PASSWORD'] + ) + upload_file('backupdb.dump', service_account_file, gdrive_folder_id) + os.remove('backupdb.dump') + if google_drive.backup_media: + folder_to_zip = settings.MEDIA_ROOT + output_zip_file = "media.zip" + zip_folder(folder_to_zip, output_zip_file) + upload_file("media.zip", service_account_file, gdrive_folder_id) + os.remove("media.zip") + + +def start_gdrive_backup_job(): + """ + Start the backup job based on the LocalBackup configuration. + """ + # Check if any Gdrive Backup object exists + if GoogleDriveBackup.objects.exists(): + gdrive_backup = GoogleDriveBackup.objects.first() + + # Remove existing job if it exists + try: + scheduler.remove_job('backup_job') + except: + pass + # Add new job based on Gdrive Backup configuration + if gdrive_backup.interval: + scheduler.add_job(google_drive_backup, 'interval', seconds=gdrive_backup.seconds, id='gdrive_backup_job') + else: + scheduler.add_job(google_drive_backup, trigger='cron', hour=gdrive_backup.hour, minute=gdrive_backup.minute, id='gdrive_backup_job') + + # Start the scheduler if it's not already running + if not scheduler.running: + scheduler.start() + + else: + stop_gdrive_backup_job() + + +def stop_gdrive_backup_job(): + """ + Stop the backup job if it exists. + """ + try: + scheduler.remove_job('gdrive_backup_job') + except: + pass + + +# def restart_gdrive_backup_job(): +# """ +# Restart the backup job by stopping it and starting it again. +# """ +# stop_gdrive_backup_job() +# start_gdrive_backup_job() + diff --git a/horilla_backup/templates/backup/404.html b/horilla_backup/templates/backup/404.html new file mode 100644 index 000000000..97a40b704 --- /dev/null +++ b/horilla_backup/templates/backup/404.html @@ -0,0 +1,7 @@ +{% extends "settings.html" %} +{% block settings %} +
+