mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6da6ee9ab | ||
|
|
8802d24789 | ||
|
|
d3954f079b | ||
|
|
0b68038739 | ||
|
|
d36568da54 | ||
|
|
473f126d3e | ||
|
|
222bb2ec64 | ||
|
|
22ec0e36b1 | ||
|
|
0122913052 | ||
|
|
7f2a4634c5 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,4 +1,32 @@
|
||||
|
||||
## 2025.9.26 (2025/09/30)
|
||||
|
||||
### Debug
|
||||
|
||||
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
|
||||
|
||||
|
||||
|
||||
## 2025.9.25 (2025/09/30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Fix S3 backup path-style configuration and improve UX. ([22ec0e36](https://github.com/elisiariocouto/leggen/commit/22ec0e36b11e5b017075bee51de0423a53ec4648))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add S3 backup functionality to backend ([7f2a4634](https://github.com/elisiariocouto/leggen/commit/7f2a4634c51814b6785433a25ce42d20aea0558c))
|
||||
- **frontend:** Add S3 backup UI and complete backup functionality ([01229130](https://github.com/elisiariocouto/leggen/commit/0122913052793bcbf011cb557ef182be21c5de93))
|
||||
- **frontend:** Add ability to list backups and create a backup on demand. ([473f126d](https://github.com/elisiariocouto/leggen/commit/473f126d3e699521172539f2ca0bff0579ccee51))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Log more rate limit headers. ([d36568da](https://github.com/elisiariocouto/leggen/commit/d36568da540d4fb4ae1fa10b322a3fa77dcc5360))
|
||||
|
||||
|
||||
|
||||
## 2025.9.24 (2025/09/25)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -28,3 +28,13 @@ enabled = true
|
||||
[filters]
|
||||
case_insensitive = ["salary", "utility"]
|
||||
case_sensitive = ["SpecificStore"]
|
||||
|
||||
# Optional: S3 backup configuration
|
||||
[backup.s3]
|
||||
access_key_id = "your-s3-access-key"
|
||||
secret_access_key = "your-s3-secret-key"
|
||||
bucket_name = "your-bucket-name"
|
||||
region = "us-east-1"
|
||||
# endpoint_url = "https://custom-s3-endpoint.com" # Optional: for custom S3-compatible endpoints
|
||||
path_style = false # Set to true for path-style addressing
|
||||
enabled = true
|
||||
|
||||
273
frontend/src/components/S3BackupConfigDrawer.tsx
Normal file
273
frontend/src/components/S3BackupConfigDrawer.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Cloud, TestTube } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { EditButton } from "./ui/edit-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import type { BackupSettings, S3Config } from "../types/api";
|
||||
|
||||
interface S3BackupConfigDrawerProps {
|
||||
settings?: BackupSettings;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function S3BackupConfigDrawer({
|
||||
settings,
|
||||
trigger,
|
||||
}: S3BackupConfigDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<S3Config>({
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
bucket_name: "",
|
||||
region: "us-east-1",
|
||||
endpoint_url: "",
|
||||
path_style: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.s3) {
|
||||
setConfig({ ...settings.s3 });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (s3Config: S3Config) =>
|
||||
apiClient.updateBackupSettings({
|
||||
s3: s3Config,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
|
||||
setOpen(false);
|
||||
toast.success("S3 backup configuration saved successfully");
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to update S3 backup configuration:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"Failed to save S3 configuration. Please check your settings and try again.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.testBackupConnection({
|
||||
service: "s3",
|
||||
config: config,
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
console.log("S3 connection test successful");
|
||||
toast.success(
|
||||
"S3 connection test successful! Your configuration is working correctly.",
|
||||
);
|
||||
} else {
|
||||
console.error("S3 connection test failed:", response.message);
|
||||
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
|
||||
}
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to test S3 connection:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"S3 connection test failed. Please verify your credentials and settings.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(config);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate();
|
||||
};
|
||||
|
||||
const isConfigValid =
|
||||
config.access_key_id.trim().length > 0 &&
|
||||
config.secret_access_key.trim().length > 0 &&
|
||||
config.bucket_name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center space-x-2">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
<span>S3 Backup Configuration</span>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Configure S3 settings for automatic database backups
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-4 space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({ ...config, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enabled">Enable S3 backups</Label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access_key_id">Access Key ID</Label>
|
||||
<Input
|
||||
id="access_key_id"
|
||||
type="text"
|
||||
value={config.access_key_id}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, access_key_id: e.target.value })
|
||||
}
|
||||
placeholder="Your AWS Access Key ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secret_access_key">Secret Access Key</Label>
|
||||
<Input
|
||||
id="secret_access_key"
|
||||
type="password"
|
||||
value={config.secret_access_key}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
secret_access_key: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Your AWS Secret Access Key"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bucket_name">Bucket Name</Label>
|
||||
<Input
|
||||
id="bucket_name"
|
||||
type="text"
|
||||
value={config.bucket_name}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, bucket_name: e.target.value })
|
||||
}
|
||||
placeholder="my-backup-bucket"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="region">Region</Label>
|
||||
<Input
|
||||
id="region"
|
||||
type="text"
|
||||
value={config.region}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, region: e.target.value })
|
||||
}
|
||||
placeholder="us-east-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint_url">
|
||||
Custom Endpoint URL (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="endpoint_url"
|
||||
type="url"
|
||||
value={config.endpoint_url || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, endpoint_url: e.target.value })
|
||||
}
|
||||
placeholder="https://custom-s3-endpoint.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For S3-compatible services like MinIO or DigitalOcean Spaces
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="path_style"
|
||||
checked={config.path_style}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({ ...config, path_style: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="path_style">Use path-style addressing</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable for older S3 implementations or certain S3-compatible
|
||||
services
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DrawerFooter className="px-0">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !config.enabled}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Configuration"}
|
||||
</Button>
|
||||
{config.enabled && isConfigValid && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
Trash2,
|
||||
User,
|
||||
Filter,
|
||||
Cloud,
|
||||
Archive,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import {
|
||||
@@ -35,11 +39,14 @@ import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
||||
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
|
||||
import type {
|
||||
Account,
|
||||
Balance,
|
||||
NotificationSettings,
|
||||
NotificationService,
|
||||
BackupSettings,
|
||||
BackupInfo,
|
||||
} from "../types/api";
|
||||
|
||||
// Helper function to get status indicator color and styles
|
||||
@@ -80,6 +87,7 @@ export default function Settings() {
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
const [showBackups, setShowBackups] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -125,6 +133,28 @@ export default function Settings() {
|
||||
queryFn: apiClient.getBankConnectionsStatus,
|
||||
});
|
||||
|
||||
// Backup queries
|
||||
const {
|
||||
data: backupSettings,
|
||||
isLoading: backupLoading,
|
||||
error: backupError,
|
||||
refetch: refetchBackup,
|
||||
} = useQuery<BackupSettings>({
|
||||
queryKey: ["backupSettings"],
|
||||
queryFn: apiClient.getBackupSettings,
|
||||
});
|
||||
|
||||
const {
|
||||
data: backups,
|
||||
isLoading: backupsLoading,
|
||||
error: backupsError,
|
||||
refetch: refetchBackups,
|
||||
} = useQuery<BackupInfo[]>({
|
||||
queryKey: ["backups"],
|
||||
queryFn: apiClient.listBackups,
|
||||
enabled: showBackups,
|
||||
});
|
||||
|
||||
// Account mutations
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||
@@ -158,6 +188,26 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
// Backup mutations
|
||||
const createBackupMutation = useMutation({
|
||||
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message || "Backup created successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||
} else {
|
||||
toast.error(response.message || "Failed to create backup.");
|
||||
}
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to create backup:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"Failed to create backup. Please check your S3 configuration.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
// Account handlers
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
@@ -189,8 +239,27 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = accountsLoading || settingsLoading || servicesLoading;
|
||||
const hasError = accountsError || settingsError || servicesError;
|
||||
// Backup handlers
|
||||
const handleCreateBackup = () => {
|
||||
if (!backupSettings?.s3?.enabled) {
|
||||
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
|
||||
return;
|
||||
}
|
||||
createBackupMutation.mutate();
|
||||
};
|
||||
|
||||
const handleViewBackups = () => {
|
||||
if (!backupSettings?.s3?.enabled) {
|
||||
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
|
||||
return;
|
||||
}
|
||||
setShowBackups(true);
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
accountsLoading || settingsLoading || servicesLoading || backupLoading;
|
||||
const hasError =
|
||||
accountsError || settingsError || servicesError || backupError;
|
||||
|
||||
if (isLoading) {
|
||||
return <AccountsSkeleton />;
|
||||
@@ -211,6 +280,7 @@ export default function Settings() {
|
||||
refetchAccounts();
|
||||
refetchSettings();
|
||||
refetchServices();
|
||||
refetchBackup();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -226,7 +296,7 @@ export default function Settings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="accounts" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="accounts" className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Accounts</span>
|
||||
@@ -238,6 +308,10 @@ export default function Settings() {
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backup" className="flex items-center space-x-2">
|
||||
<Cloud className="h-4 w-4" />
|
||||
<span>Backup</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="accounts" className="space-y-6">
|
||||
@@ -728,6 +802,174 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-6">
|
||||
{/* S3 Backup Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
<span>S3 Backup Configuration</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure automatic database backups to Amazon S3 or
|
||||
S3-compatible storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{!backupSettings?.s3 ? (
|
||||
<div className="text-center py-8">
|
||||
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No S3 backup configured
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Set up S3 backup to automatically backup your database to
|
||||
the cloud.
|
||||
</p>
|
||||
<S3BackupConfigDrawer settings={backupSettings} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
<Cloud className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-foreground">
|
||||
S3 Backup
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
backupSettings.s3.enabled
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backupSettings.s3.enabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Bucket:</span>{" "}
|
||||
{backupSettings.s3.bucket_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Region:</span>{" "}
|
||||
{backupSettings.s3.region}
|
||||
</p>
|
||||
{backupSettings.s3.endpoint_url && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Endpoint:</span>{" "}
|
||||
{backupSettings.s3.endpoint_url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<S3BackupConfigDrawer settings={backupSettings} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h5 className="font-medium mb-2">Backup Information</h5>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Database backups are stored in the "leggen_backups/"
|
||||
folder in your S3 bucket. Backups include the complete
|
||||
SQLite database file.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateBackup}
|
||||
disabled={createBackupMutation.isPending}
|
||||
>
|
||||
{createBackupMutation.isPending ? (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Create Backup Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleViewBackups}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Backups
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup List Modal/View */}
|
||||
{showBackups && (
|
||||
<div className="mt-6 p-4 border rounded-lg bg-background">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h5 className="font-medium">Available Backups</h5>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowBackups(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{backupsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading backups...</p>
|
||||
) : backupsError ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">Failed to load backups</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetchBackups()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : !backups || backups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No backups found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup, index) => (
|
||||
<div
|
||||
key={backup.key || index}
|
||||
className="flex items-center justify-between p-3 border rounded bg-muted/50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{backup.key}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
|
||||
<span>Modified: {formatDate(backup.last_modified)}</span>
|
||||
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ import type {
|
||||
BankConnectionStatus,
|
||||
BankRequisition,
|
||||
Country,
|
||||
BackupSettings,
|
||||
BackupTest,
|
||||
BackupInfo,
|
||||
BackupOperation,
|
||||
} from "../types/api";
|
||||
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
@@ -274,6 +278,38 @@ export const apiClient = {
|
||||
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Backup endpoints
|
||||
getBackupSettings: async (): Promise<BackupSettings> => {
|
||||
const response =
|
||||
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updateBackupSettings: async (
|
||||
settings: BackupSettings,
|
||||
): Promise<BackupSettings> => {
|
||||
const response = await api.put<ApiResponse<BackupSettings>>(
|
||||
"/backup/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
|
||||
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listBackups: async (): Promise<BackupInfo[]> => {
|
||||
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
|
||||
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||
import { usePWA } from "../hooks/usePWA";
|
||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
|
||||
function RootLayout() {
|
||||
const { updateAvailable, updateSW, forceReload } = usePWA();
|
||||
@@ -48,6 +49,9 @@ function RootLayout() {
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={handlePWAUpdate}
|
||||
/>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<Toaster />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,3 +277,34 @@ export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Backup types
|
||||
export interface S3Config {
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
bucket_name: string;
|
||||
region: string;
|
||||
endpoint_url?: string;
|
||||
path_style: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
s3?: S3Config;
|
||||
}
|
||||
|
||||
export interface BackupTest {
|
||||
service: string;
|
||||
config: S3Config;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
key: string;
|
||||
last_modified: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BackupOperation {
|
||||
operation: string;
|
||||
backup_key?: string;
|
||||
}
|
||||
|
||||
49
leggen/api/models/backup.py
Normal file
49
leggen/api/models/backup.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""API models for backup endpoints."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class S3Config(BaseModel):
|
||||
"""S3 backup configuration model for API."""
|
||||
|
||||
access_key_id: str = Field(..., description="AWS S3 access key ID")
|
||||
secret_access_key: str = Field(..., description="AWS S3 secret access key")
|
||||
bucket_name: str = Field(..., description="S3 bucket name")
|
||||
region: str = Field(default="us-east-1", description="AWS S3 region")
|
||||
endpoint_url: Optional[str] = Field(
|
||||
default=None, description="Custom S3 endpoint URL"
|
||||
)
|
||||
path_style: bool = Field(default=False, description="Use path-style addressing")
|
||||
enabled: bool = Field(default=True, description="Enable S3 backups")
|
||||
|
||||
|
||||
class BackupSettings(BaseModel):
|
||||
"""Backup settings model for API."""
|
||||
|
||||
s3: Optional[S3Config] = None
|
||||
|
||||
|
||||
class BackupTest(BaseModel):
|
||||
"""Backup connection test request model."""
|
||||
|
||||
service: str = Field(..., description="Backup service type (s3)")
|
||||
config: S3Config = Field(..., description="S3 configuration to test")
|
||||
|
||||
|
||||
class BackupInfo(BaseModel):
|
||||
"""Backup file information model."""
|
||||
|
||||
key: str = Field(..., description="S3 object key")
|
||||
last_modified: str = Field(..., description="Last modified timestamp (ISO format)")
|
||||
size: int = Field(..., description="File size in bytes")
|
||||
|
||||
|
||||
class BackupOperation(BaseModel):
|
||||
"""Backup operation request model."""
|
||||
|
||||
operation: str = Field(..., description="Operation type (backup, restore)")
|
||||
backup_key: Optional[str] = Field(
|
||||
default=None, description="Backup key for restore operations"
|
||||
)
|
||||
264
leggen/api/routes/backup.py
Normal file
264
leggen/api/routes/backup.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""API routes for backup management."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.backup import (
|
||||
BackupOperation,
|
||||
BackupSettings,
|
||||
BackupTest,
|
||||
S3Config,
|
||||
)
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.models.config import S3BackupConfig
|
||||
from leggen.services.backup_service import BackupService
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/backup/settings", response_model=APIResponse)
|
||||
async def get_backup_settings() -> APIResponse:
|
||||
"""Get current backup settings."""
|
||||
try:
|
||||
backup_config = config.backup_config
|
||||
|
||||
# Build response safely without exposing secrets
|
||||
s3_config = backup_config.get("s3", {})
|
||||
|
||||
settings = BackupSettings(
|
||||
s3=S3Config(
|
||||
access_key_id="***" if s3_config.get("access_key_id") else "",
|
||||
secret_access_key="***" if s3_config.get("secret_access_key") else "",
|
||||
bucket_name=s3_config.get("bucket_name", ""),
|
||||
region=s3_config.get("region", "us-east-1"),
|
||||
endpoint_url=s3_config.get("endpoint_url"),
|
||||
path_style=s3_config.get("path_style", False),
|
||||
enabled=s3_config.get("enabled", True),
|
||||
)
|
||||
if s3_config.get("bucket_name")
|
||||
else None,
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=settings,
|
||||
message="Backup settings retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get backup settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get backup settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/backup/settings", response_model=APIResponse)
|
||||
async def update_backup_settings(settings: BackupSettings) -> APIResponse:
|
||||
"""Update backup settings."""
|
||||
try:
|
||||
# First test the connection if S3 config is provided
|
||||
if settings.s3:
|
||||
# Convert API model to config model
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id=settings.s3.access_key_id,
|
||||
secret_access_key=settings.s3.secret_access_key,
|
||||
bucket_name=settings.s3.bucket_name,
|
||||
region=settings.s3.region,
|
||||
endpoint_url=settings.s3.endpoint_url,
|
||||
path_style=settings.s3.path_style,
|
||||
enabled=settings.s3.enabled,
|
||||
)
|
||||
|
||||
# Test connection
|
||||
backup_service = BackupService()
|
||||
connection_success = await backup_service.test_connection(s3_config)
|
||||
|
||||
if not connection_success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="S3 connection test failed. Please check your configuration.",
|
||||
)
|
||||
|
||||
# Update backup config
|
||||
backup_config = {}
|
||||
|
||||
if settings.s3:
|
||||
backup_config["s3"] = {
|
||||
"access_key_id": settings.s3.access_key_id,
|
||||
"secret_access_key": settings.s3.secret_access_key,
|
||||
"bucket_name": settings.s3.bucket_name,
|
||||
"region": settings.s3.region,
|
||||
"endpoint_url": settings.s3.endpoint_url,
|
||||
"path_style": settings.s3.path_style,
|
||||
"enabled": settings.s3.enabled,
|
||||
}
|
||||
|
||||
# Save to config
|
||||
if backup_config:
|
||||
config.update_section("backup", backup_config)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"updated": True},
|
||||
message="Backup settings updated successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update backup settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update backup settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/backup/test", response_model=APIResponse)
|
||||
async def test_backup_connection(test_request: BackupTest) -> APIResponse:
|
||||
"""Test backup connection."""
|
||||
try:
|
||||
if test_request.service != "s3":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Only 's3' service is supported"
|
||||
)
|
||||
|
||||
# Convert API model to config model
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id=test_request.config.access_key_id,
|
||||
secret_access_key=test_request.config.secret_access_key,
|
||||
bucket_name=test_request.config.bucket_name,
|
||||
region=test_request.config.region,
|
||||
endpoint_url=test_request.config.endpoint_url,
|
||||
path_style=test_request.config.path_style,
|
||||
enabled=test_request.config.enabled,
|
||||
)
|
||||
|
||||
backup_service = BackupService()
|
||||
success = await backup_service.test_connection(s3_config)
|
||||
|
||||
if success:
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"connected": True},
|
||||
message="S3 connection test successful",
|
||||
)
|
||||
else:
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="S3 connection test failed",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test backup connection: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to test backup connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/backup/list", response_model=APIResponse)
|
||||
async def list_backups() -> APIResponse:
|
||||
"""List available backups."""
|
||||
try:
|
||||
backup_config = config.backup_config.get("s3", {})
|
||||
|
||||
if not backup_config.get("bucket_name"):
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=[],
|
||||
message="No S3 backup configuration found",
|
||||
)
|
||||
|
||||
# Convert config to model
|
||||
s3_config = S3BackupConfig(**backup_config)
|
||||
backup_service = BackupService(s3_config)
|
||||
|
||||
backups = await backup_service.list_backups()
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=backups,
|
||||
message=f"Found {len(backups)} backups",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list backups: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to list backups: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/backup/operation", response_model=APIResponse)
|
||||
async def backup_operation(operation_request: BackupOperation) -> APIResponse:
|
||||
"""Perform backup operation (backup or restore)."""
|
||||
try:
|
||||
backup_config = config.backup_config.get("s3", {})
|
||||
|
||||
if not backup_config.get("bucket_name"):
|
||||
raise HTTPException(status_code=400, detail="S3 backup is not configured")
|
||||
|
||||
# Convert config to model with validation
|
||||
try:
|
||||
s3_config = S3BackupConfig(**backup_config)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid S3 configuration: {str(e)}"
|
||||
) from e
|
||||
|
||||
backup_service = BackupService(s3_config)
|
||||
|
||||
if operation_request.operation == "backup":
|
||||
# Backup database
|
||||
database_path = path_manager.get_database_path()
|
||||
success = await backup_service.backup_database(database_path)
|
||||
|
||||
if success:
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"operation": "backup", "completed": True},
|
||||
message="Database backup completed successfully",
|
||||
)
|
||||
else:
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="Database backup failed",
|
||||
)
|
||||
|
||||
elif operation_request.operation == "restore":
|
||||
if not operation_request.backup_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="backup_key is required for restore operation",
|
||||
)
|
||||
|
||||
# Restore database
|
||||
database_path = path_manager.get_database_path()
|
||||
success = await backup_service.restore_database(
|
||||
operation_request.backup_key, database_path
|
||||
)
|
||||
|
||||
if success:
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"operation": "restore", "completed": True},
|
||||
message="Database restore completed successfully",
|
||||
)
|
||||
else:
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="Database restore failed",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to perform backup operation: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to perform backup operation: {str(e)}"
|
||||
) from e
|
||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.routes import accounts, banks, notifications, sync, transactions
|
||||
from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
@@ -81,6 +81,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(transactions.router, prefix="/api/v1", tags=["transactions"])
|
||||
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
|
||||
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
|
||||
app.include_router(backup.router, prefix="/api/v1", tags=["backup"])
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
|
||||
@@ -32,6 +32,22 @@ class NotificationConfig(BaseModel):
|
||||
telegram: Optional[TelegramNotificationConfig] = None
|
||||
|
||||
|
||||
class S3BackupConfig(BaseModel):
|
||||
access_key_id: str = Field(..., description="AWS S3 access key ID")
|
||||
secret_access_key: str = Field(..., description="AWS S3 secret access key")
|
||||
bucket_name: str = Field(..., description="S3 bucket name")
|
||||
region: str = Field(default="us-east-1", description="AWS S3 region")
|
||||
endpoint_url: Optional[str] = Field(
|
||||
default=None, description="Custom S3 endpoint URL"
|
||||
)
|
||||
path_style: bool = Field(default=False, description="Use path-style addressing")
|
||||
enabled: bool = Field(default=True, description="Enable S3 backups")
|
||||
|
||||
|
||||
class BackupConfig(BaseModel):
|
||||
s3: Optional[S3BackupConfig] = None
|
||||
|
||||
|
||||
class FilterConfig(BaseModel):
|
||||
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
case_sensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
@@ -56,3 +72,4 @@ class Config(BaseModel):
|
||||
notifications: Optional[NotificationConfig] = None
|
||||
filters: Optional[FilterConfig] = None
|
||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||
backup: Optional[BackupConfig] = None
|
||||
|
||||
192
leggen/services/backup_service.py
Normal file
192
leggen/services/backup_service.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Backup service for S3 storage."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from loguru import logger
|
||||
|
||||
from leggen.models.config import S3BackupConfig
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""Service for managing S3 backups."""
|
||||
|
||||
def __init__(self, s3_config: Optional[S3BackupConfig] = None):
|
||||
"""Initialize backup service with S3 configuration."""
|
||||
self.s3_config = s3_config
|
||||
self._s3_client = None
|
||||
|
||||
def _get_s3_client(self, config: Optional[S3BackupConfig] = None):
|
||||
"""Get or create S3 client with current configuration."""
|
||||
current_config = config or self.s3_config
|
||||
if not current_config:
|
||||
raise ValueError("S3 configuration is required")
|
||||
|
||||
# Create S3 client with configuration
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=current_config.access_key_id,
|
||||
aws_secret_access_key=current_config.secret_access_key,
|
||||
region_name=current_config.region,
|
||||
)
|
||||
|
||||
s3_kwargs = {}
|
||||
if current_config.endpoint_url:
|
||||
s3_kwargs["endpoint_url"] = current_config.endpoint_url
|
||||
|
||||
if current_config.path_style:
|
||||
from botocore.config import Config
|
||||
|
||||
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||
|
||||
return session.client("s3", **s3_kwargs)
|
||||
|
||||
async def test_connection(self, config: S3BackupConfig) -> bool:
|
||||
"""Test S3 connection with provided configuration.
|
||||
|
||||
Args:
|
||||
config: S3 configuration to test
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
s3_client = self._get_s3_client(config)
|
||||
|
||||
# Try to list objects in the bucket (limited to 1 to minimize cost)
|
||||
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
|
||||
|
||||
logger.info(
|
||||
f"S3 connection test successful for bucket: {config.bucket_name}"
|
||||
)
|
||||
return True
|
||||
|
||||
except NoCredentialsError:
|
||||
logger.error("S3 credentials not found or invalid")
|
||||
return False
|
||||
except ClientError as e:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
logger.error(
|
||||
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
|
||||
return False
|
||||
|
||||
async def backup_database(self, database_path: Path) -> bool:
|
||||
"""Backup database file to S3.
|
||||
|
||||
Args:
|
||||
database_path: Path to the SQLite database file
|
||||
|
||||
Returns:
|
||||
True if backup successful, False otherwise
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return False
|
||||
|
||||
if not database_path.exists():
|
||||
logger.error(f"Database file not found: {database_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# Generate backup filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
|
||||
|
||||
# Upload database file
|
||||
logger.info(f"Starting database backup to S3: {backup_key}")
|
||||
s3_client.upload_file(
|
||||
str(database_path), self.s3_config.bucket_name, backup_key
|
||||
)
|
||||
|
||||
logger.info(f"Database backup completed successfully: {backup_key}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database backup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def list_backups(self) -> list[dict]:
|
||||
"""List available backups in S3.
|
||||
|
||||
Returns:
|
||||
List of backup metadata dictionaries
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return []
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# List objects with backup prefix
|
||||
response = s3_client.list_objects_v2(
|
||||
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
|
||||
)
|
||||
|
||||
backups = []
|
||||
for obj in response.get("Contents", []):
|
||||
backups.append(
|
||||
{
|
||||
"key": obj["Key"],
|
||||
"last_modified": obj["LastModified"].isoformat(),
|
||||
"size": obj["Size"],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by last modified (newest first)
|
||||
backups.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list backups: {str(e)}")
|
||||
return []
|
||||
|
||||
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
|
||||
"""Restore database from S3 backup.
|
||||
|
||||
Args:
|
||||
backup_key: S3 key of the backup to restore
|
||||
restore_path: Path where to restore the database
|
||||
|
||||
Returns:
|
||||
True if restore successful, False otherwise
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# Download backup file
|
||||
logger.info(f"Starting database restore from S3: {backup_key}")
|
||||
|
||||
# Create parent directory if it doesn't exist
|
||||
restore_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temporary file first, then move to final location
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
s3_client.download_file(
|
||||
self.s3_config.bucket_name, backup_key, temp_file.name
|
||||
)
|
||||
|
||||
# Move temp file to final location
|
||||
temp_path = Path(temp_file.name)
|
||||
temp_path.replace(restore_path)
|
||||
|
||||
logger.info(f"Database restore completed successfully: {restore_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database restore failed: {str(e)}")
|
||||
return False
|
||||
@@ -9,16 +9,21 @@ from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
def _log_rate_limits(response):
|
||||
def _log_rate_limits(response, method, url):
|
||||
"""Log GoCardless API rate limit headers"""
|
||||
limit = response.headers.get("http_x_ratelimit_limit")
|
||||
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||
reset = response.headers.get("http_x_ratelimit_reset")
|
||||
|
||||
if limit or remaining or reset:
|
||||
logger.info(
|
||||
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
|
||||
)
|
||||
account_limit = response.headers.get("http_x_ratelimit_account_success_limit")
|
||||
account_remaining = response.headers.get(
|
||||
"http_x_ratelimit_account_success_remaining"
|
||||
)
|
||||
account_reset = response.headers.get("http_x_ratelimit_account_success_reset")
|
||||
|
||||
logger.debug(
|
||||
f"{method} {url} Limit/Remaining/Reset (Global: {limit}/{remaining}/{reset}s) (Account: {account_limit}/{account_remaining}/{account_reset}s)"
|
||||
)
|
||||
|
||||
|
||||
class GoCardlessService:
|
||||
@@ -37,7 +42,7 @@ class GoCardlessService:
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.request(method, url, headers=headers, **kwargs)
|
||||
_log_rate_limits(response)
|
||||
_log_rate_limits(response, method, url)
|
||||
|
||||
# If we get 401, clear token cache and retry once
|
||||
if response.status_code == 401:
|
||||
@@ -45,7 +50,7 @@ class GoCardlessService:
|
||||
self._token = None
|
||||
headers = await self._get_auth_headers()
|
||||
response = await client.request(method, url, headers=headers, **kwargs)
|
||||
_log_rate_limits(response)
|
||||
_log_rate_limits(response, method, url)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -76,7 +81,9 @@ class GoCardlessService:
|
||||
f"{self.base_url}/token/refresh/",
|
||||
json={"refresh": auth["refresh"]},
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
_log_rate_limits(
|
||||
response, "POST", f"{self.base_url}/token/refresh/"
|
||||
)
|
||||
response.raise_for_status()
|
||||
auth.update(response.json())
|
||||
self._save_auth(auth)
|
||||
@@ -104,7 +111,7 @@ class GoCardlessService:
|
||||
"secret_key": self.config["secret"],
|
||||
},
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
_log_rate_limits(response, "POST", f"{self.base_url}/token/new/")
|
||||
response.raise_for_status()
|
||||
auth = response.json()
|
||||
self._save_auth(auth)
|
||||
|
||||
@@ -162,6 +162,11 @@ class Config:
|
||||
}
|
||||
return self.config.get("scheduler", default_schedule)
|
||||
|
||||
@property
|
||||
def backup_config(self) -> Dict[str, Any]:
|
||||
"""Get backup configuration"""
|
||||
return self.config.get("backup", {})
|
||||
|
||||
|
||||
def load_config(ctx: click.Context, _, filename):
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.24"
|
||||
version = "2025.9.26"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
@@ -35,6 +35,7 @@ dependencies = [
|
||||
"tomli-w>=1.0.0,<2",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.0.0,<3",
|
||||
"boto3>=1.35.0,<2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -88,5 +89,5 @@ markers = [
|
||||
]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["apscheduler.*", "discord_webhook.*"]
|
||||
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -42,6 +42,9 @@ echo " > Version bumped to $NEXT_VERSION"
|
||||
echo "Updating CHANGELOG.md"
|
||||
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
|
||||
|
||||
echo "Locking dependencies"
|
||||
uv lock
|
||||
|
||||
echo " > Commiting changes and adding git tag"
|
||||
git add pyproject.toml CHANGELOG.md uv.lock
|
||||
git commit -m "chore(ci): Bump version to $NEXT_VERSION"
|
||||
|
||||
303
tests/unit/test_api_backup.py
Normal file
303
tests/unit/test_api_backup.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""Tests for backup API endpoints."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
class TestBackupAPI:
|
||||
"""Test backup-related API endpoints."""
|
||||
|
||||
def test_get_backup_settings_no_config(self, api_client, mock_config):
|
||||
"""Test getting backup settings with no configuration."""
|
||||
# Mock empty backup config by updating the config dict
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["s3"] is None
|
||||
|
||||
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
|
||||
"""Test getting backup settings with S3 configuration."""
|
||||
# Mock S3 backup config (with masked credentials)
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["s3"] is not None
|
||||
|
||||
s3_config = data["data"]["s3"]
|
||||
assert s3_config["access_key_id"] == "***" # Masked
|
||||
assert s3_config["secret_access_key"] == "***" # Masked
|
||||
assert s3_config["bucket_name"] == "test-bucket"
|
||||
assert s3_config["region"] == "us-east-1"
|
||||
assert s3_config["enabled"] is True
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_update_backup_settings_success(
|
||||
self, mock_test_connection, api_client, mock_config
|
||||
):
|
||||
"""Test successful backup settings update."""
|
||||
mock_test_connection.return_value = True
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["updated"] is True
|
||||
|
||||
# Verify connection test was called
|
||||
mock_test_connection.assert_called_once()
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_update_backup_settings_connection_failure(
|
||||
self, mock_test_connection, api_client, mock_config
|
||||
):
|
||||
"""Test backup settings update with connection test failure."""
|
||||
mock_test_connection.return_value = False
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "invalid-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "S3 connection test failed" in data["detail"]
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_test_backup_connection_success(self, mock_test_connection, api_client):
|
||||
"""Test successful backup connection test."""
|
||||
mock_test_connection.return_value = True
|
||||
|
||||
request_data = {
|
||||
"service": "s3",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["connected"] is True
|
||||
|
||||
# Verify connection test was called
|
||||
mock_test_connection.assert_called_once()
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
|
||||
"""Test failed backup connection test."""
|
||||
mock_test_connection.return_value = False
|
||||
|
||||
request_data = {
|
||||
"service": "s3",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "invalid-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_test_backup_connection_invalid_service(self, api_client):
|
||||
"""Test backup connection test with invalid service."""
|
||||
request_data = {
|
||||
"service": "invalid",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Only 's3' service is supported" in data["detail"]
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.list_backups")
|
||||
def test_list_backups_success(self, mock_list_backups, api_client, mock_config):
|
||||
"""Test successful backup listing."""
|
||||
mock_list_backups.return_value = [
|
||||
{
|
||||
"key": "leggen_backups/database_backup_20250101_120000.db",
|
||||
"last_modified": "2025-01-01T12:00:00",
|
||||
"size": 1024,
|
||||
},
|
||||
{
|
||||
"key": "leggen_backups/database_backup_20250101_110000.db",
|
||||
"last_modified": "2025-01-01T11:00:00",
|
||||
"size": 512,
|
||||
},
|
||||
]
|
||||
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 2
|
||||
assert (
|
||||
data["data"][0]["key"]
|
||||
== "leggen_backups/database_backup_20250101_120000.db"
|
||||
)
|
||||
|
||||
def test_list_backups_no_config(self, api_client, mock_config):
|
||||
"""Test backup listing with no configuration."""
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"] == []
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.backup_database")
|
||||
@patch("leggen.utils.paths.path_manager.get_database_path")
|
||||
def test_backup_operation_success(
|
||||
self, mock_get_db_path, mock_backup_db, api_client, mock_config
|
||||
):
|
||||
"""Test successful backup operation."""
|
||||
from pathlib import Path
|
||||
|
||||
mock_get_db_path.return_value = Path("/test/database.db")
|
||||
mock_backup_db.return_value = True
|
||||
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
request_data = {"operation": "backup"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["operation"] == "backup"
|
||||
assert data["data"]["completed"] is True
|
||||
|
||||
# Verify backup was called
|
||||
mock_backup_db.assert_called_once()
|
||||
|
||||
def test_backup_operation_no_config(self, api_client, mock_config):
|
||||
"""Test backup operation with no configuration."""
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {"operation": "backup"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "S3 backup is not configured" in data["detail"]
|
||||
|
||||
def test_backup_operation_invalid_operation(self, api_client, mock_config):
|
||||
"""Test backup operation with invalid operation type."""
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
request_data = {"operation": "invalid"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Invalid operation" in data["detail"]
|
||||
226
tests/unit/test_backup_service.py
Normal file
226
tests/unit/test_backup_service.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Tests for backup service functionality."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
|
||||
from leggen.models.config import S3BackupConfig
|
||||
from leggen.services.backup_service import BackupService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBackupService:
|
||||
"""Test backup service functionality."""
|
||||
|
||||
def test_backup_service_initialization(self):
|
||||
"""Test backup service can be initialized."""
|
||||
service = BackupService()
|
||||
assert service.s3_config is None
|
||||
assert service._s3_client is None
|
||||
|
||||
def test_backup_service_with_config(self):
|
||||
"""Test backup service initialization with config."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
assert service.s3_config == s3_config
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_success(self):
|
||||
"""Test successful S3 connection test."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
# Mock successful list_objects_v2 call
|
||||
mock_client.list_objects_v2.return_value = {"Contents": []}
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is True
|
||||
|
||||
# Verify the client was called correctly
|
||||
mock_client.list_objects_v2.assert_called_once_with(
|
||||
Bucket="test-bucket", MaxKeys=1
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_no_credentials(self):
|
||||
"""Test S3 connection test with no credentials."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client to raise NoCredentialsError
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
mock_client.list_objects_v2.side_effect = NoCredentialsError()
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_client_error(self):
|
||||
"""Test S3 connection test with client error."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client to raise ClientError
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
error_response = {
|
||||
"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}
|
||||
}
|
||||
mock_client.list_objects_v2.side_effect = ClientError(
|
||||
error_response, "ListObjectsV2"
|
||||
)
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_no_config(self):
|
||||
"""Test backup database with no configuration."""
|
||||
service = BackupService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_disabled(self):
|
||||
"""Test backup database with disabled configuration."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
enabled=False,
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_file_not_found(self):
|
||||
"""Test backup database with non-existent file."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
non_existent_path = Path("/non/existent/path.db")
|
||||
result = await service.backup_database(non_existent_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_success(self):
|
||||
"""Test successful database backup."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
# Mock S3 client
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is True
|
||||
|
||||
# Verify upload_file was called
|
||||
mock_client.upload_file.assert_called_once()
|
||||
args = mock_client.upload_file.call_args[0]
|
||||
assert args[0] == str(db_path) # source file
|
||||
assert args[1] == "test-bucket" # bucket name
|
||||
assert args[2].startswith("leggen_backups/database_backup_") # key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_backups_success(self):
|
||||
"""Test successful backup listing."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
# Mock S3 client response
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
mock_response = {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": "leggen_backups/database_backup_20250101_120000.db",
|
||||
"LastModified": datetime(2025, 1, 1, 12, 0, 0),
|
||||
"Size": 1024,
|
||||
},
|
||||
{
|
||||
"Key": "leggen_backups/database_backup_20250101_130000.db",
|
||||
"LastModified": datetime(2025, 1, 1, 13, 0, 0),
|
||||
"Size": 2048,
|
||||
},
|
||||
]
|
||||
}
|
||||
mock_client.list_objects_v2.return_value = mock_response
|
||||
|
||||
backups = await service.list_backups()
|
||||
assert len(backups) == 2
|
||||
|
||||
# Check that backups are sorted by last modified (newest first)
|
||||
assert backups[0]["last_modified"] > backups[1]["last_modified"]
|
||||
assert backups[0]["size"] == 2048
|
||||
assert backups[1]["size"] == 1024
|
||||
74
uv.lock
generated
74
uv.lock
generated
@@ -36,6 +36,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.40.36"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/21/7bc857b155e8264c92b6fa8e0860a67dc01a19cbe6ba4342500299f2ae5b/boto3-1.40.36.tar.gz", hash = "sha256:bfc1f3d5c4f5d12b8458406b8972f8794ac57e2da1ee441469e143bc0440a5c3", size = 111552, upload-time = "2025-09-22T19:26:17.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4c/428b728d5cf9003f83f735d10dd522945ab20c7d67e6c987909f29be12a0/boto3-1.40.36-py3-none-any.whl", hash = "sha256:d7c1fe033f491f560cd26022a9dcf28baf877ae854f33bc64fffd0df3b9c98be", size = 139345, upload-time = "2025-09-22T19:26:15.194Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.40.36"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/30/75fdc75933d3bc1c8dd7fbaee771438328b518936906b411075b1eacac93/botocore-1.40.36.tar.gz", hash = "sha256:93386a8dc54173267ddfc6cd8636c9171e021f7c032aa1df3af7de816e3df616", size = 14349583, upload-time = "2025-09-22T19:26:05.957Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/51/95c0324ac20b5bbafad4c89dd610c8e0dd6cbadbb2c8ca66dc95ccde98b8/botocore-1.40.36-py3-none-any.whl", hash = "sha256:d6edf75875e4013cb7078875a1d6c289afb4cc6675d99d80700c692d8d8e0b72", size = 14020478, upload-time = "2025-09-22T19:26:02.054Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
@@ -218,12 +246,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leggen"
|
||||
version = "2025.9.24"
|
||||
version = "2025.9.26"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
{ name = "boto3" },
|
||||
{ name = "click" },
|
||||
{ name = "discord-webhook" },
|
||||
{ name = "fastapi" },
|
||||
@@ -253,6 +291,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "apscheduler", specifier = ">=3.10.0,<4" },
|
||||
{ name = "boto3", specifier = ">=1.35.0,<2" },
|
||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||
{ name = "discord-webhook", specifier = ">=1.3.1,<2" },
|
||||
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
||||
@@ -474,6 +513,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b3/7fefc43fb706380144bcd293cc6e446e6f637ddfa8b83f48d1734156b529/pytest_mock-3.15.0-py3-none-any.whl", hash = "sha256:ef2219485fb1bd256b00e7ad7466ce26729b30eadfc7cbcdb4fa9a92ca68db6f", size = 10050, upload-time = "2025-09-04T20:57:47.274Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
@@ -565,6 +616,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
||||
Reference in New Issue
Block a user