diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cac063f --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,5 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..58b16a0 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,47 @@ +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context +from app.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..bbd3772 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base + +DATABASE_URL = "sqlite+aiosqlite:///./backup_tool.db" + +engine = create_async_engine(DATABASE_URL, echo=True) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +Base = declarative_base() + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..324ff9a --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,82 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON +from sqlalchemy.orm import relationship +from datetime import datetime +from .database import Base + +class Source(Base): + __tablename__ = "sources" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + type = Column(String, nullable=False) # local, ssh, database + config = Column(JSON, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan") + +class Job(Base): + __tablename__ = "jobs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + source_id = Column(Integer, ForeignKey("sources.id"), nullable=False) + strategy = Column(String, nullable=False, default="full") # full, incremental + destination_path = Column(String, nullable=False) + exclude_patterns = Column(JSON, default=list) + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + source = relationship("Source", back_populates="jobs") + schedule = relationship("Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan") + executions = relationship("JobExecution", back_populates="job", cascade="all, delete-orphan") + +class Schedule(Base): + __tablename__ = "schedules" + + id = Column(Integer, primary_key=True, index=True) + job_id = Column(Integer, ForeignKey("jobs.id"), unique=True, nullable=False) + cron_expression = Column(String, nullable=False) + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + + job = relationship("Job", back_populates="schedule") + +class JobExecution(Base): + __tablename__ = "job_executions" + + id = Column(Integer, primary_key=True, index=True) + job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False) + status = Column(String, nullable=False, default="pending") # pending, running, success, failed, cancelled + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + bytes_processed = Column(Integer, default=0) + bytes_backed_up = Column(Integer, default=0) + error_message = Column(Text, nullable=True) + triggered_by = Column(String, nullable=False) # manual, schedule + + job = relationship("Job", back_populates="executions") + backups = relationship("Backup", back_populates="execution", cascade="all, delete-orphan") + +class Backup(Base): + __tablename__ = "backups" + + id = Column(Integer, primary_key=True, index=True) + execution_id = Column(Integer, ForeignKey("job_executions.id"), nullable=False) + storage_path = Column(String, nullable=False) + size_bytes = Column(Integer, default=0) + checksum = Column(String, nullable=True) + type = Column(String, nullable=False) # full, incremental + parent_backup_id = Column(Integer, ForeignKey("backups.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + execution = relationship("JobExecution", back_populates="backups") + parent_backup = relationship("Backup", remote_side=[id]) + +class Setting(Base): + __tablename__ = "settings" + + key = Column(String, primary_key=True) + value = Column(Text, nullable=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)