mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6da6ee9ab | ||
|
|
8802d24789 | ||
|
|
d3954f079b | ||
|
|
0b68038739 | ||
|
|
d36568da54 | ||
|
|
473f126d3e | ||
|
|
222bb2ec64 | ||
|
|
22ec0e36b1 | ||
|
|
0122913052 | ||
|
|
7f2a4634c5 | ||
|
|
704c3d4cb7 | ||
|
|
ef7c026db9 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-python:
|
test-python:
|
||||||
@@ -43,8 +43,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -2,16 +2,11 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"shadcn": {
|
"shadcn": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["shadcn@latest", "mcp"]
|
||||||
"shadcn@latest",
|
|
||||||
"mcp"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["@playwright/mcp@latest"]
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,4 +1,48 @@
|
|||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.24 (2025/09/25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.23 (2025/09/24)
|
## 2025.9.23 (2025/09/24)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -28,3 +28,13 @@ enabled = true
|
|||||||
[filters]
|
[filters]
|
||||||
case_insensitive = ["salary", "utility"]
|
case_insensitive = ["salary", "utility"]
|
||||||
case_sensitive = ["SpecificStore"]
|
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
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ export default function AccountSettings() {
|
|||||||
alt={`${account.institution_id} logo`}
|
alt={`${account.institution_id} logo`}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
console.warn(
|
||||||
setFailedImages(prev => new Set([...prev, account.id]));
|
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||||
|
);
|
||||||
|
setFailedImages(
|
||||||
|
(prev) => new Set([...prev, account.id]),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
203
frontend/src/components/AddBankAccountDrawer.tsx
Normal file
203
frontend/src/components/AddBankAccountDrawer.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Plus, Building2, ExternalLink } from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "./ui/drawer";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "./ui/select";
|
||||||
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
|
|
||||||
|
export default function AddBankAccountDrawer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<string>("");
|
||||||
|
const [selectedBank, setSelectedBank] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: countries } = useQuery({
|
||||||
|
queryKey: ["supportedCountries"],
|
||||||
|
queryFn: apiClient.getSupportedCountries,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: banks, isLoading: banksLoading } = useQuery({
|
||||||
|
queryKey: ["bankInstitutions", selectedCountry],
|
||||||
|
queryFn: () => apiClient.getBankInstitutions(selectedCountry),
|
||||||
|
enabled: !!selectedCountry,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectBankMutation = useMutation({
|
||||||
|
mutationFn: (institutionId: string) =>
|
||||||
|
apiClient.createBankConnection(institutionId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Redirect to the bank's authorization link
|
||||||
|
if (data.link) {
|
||||||
|
window.open(data.link, "_blank");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to create bank connection:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCountryChange = (country: string) => {
|
||||||
|
setSelectedCountry(country);
|
||||||
|
setSelectedBank("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (selectedBank) {
|
||||||
|
connectBankMutation.mutate(selectedBank);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSelectedCountry("");
|
||||||
|
setSelectedBank("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add New Account
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[80vh]">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Connect Bank Account</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Select your country and bank to connect your account to Leggen
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Country Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<Select value={selectedCountry} onValueChange={handleCountryChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select your country" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{countries?.map((country) => (
|
||||||
|
<SelectItem key={country.code} value={country.code}>
|
||||||
|
{country.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bank Selection */}
|
||||||
|
{selectedCountry && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bank">Bank</Label>
|
||||||
|
{banksLoading ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
Loading banks...
|
||||||
|
</div>
|
||||||
|
) : banks && banks.length > 0 ? (
|
||||||
|
<Select value={selectedBank} onValueChange={setSelectedBank}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select your bank" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{banks.map((bank) => (
|
||||||
|
<SelectItem key={bank.id} value={bank.id}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{bank.logo ? (
|
||||||
|
<img
|
||||||
|
src={bank.logo}
|
||||||
|
alt={`${bank.name} logo`}
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{bank.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No banks available for the selected country.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
{selectedBank && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You'll be redirected to your bank's website to authorize the
|
||||||
|
connection. After approval, you'll return to Leggen and your
|
||||||
|
account will start syncing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{connectBankMutation.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Failed to create bank connection. Please try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={!selectedBank || connectBankMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{connectBankMutation.isPending
|
||||||
|
? "Connecting..."
|
||||||
|
: "Open Bank Authorization"}
|
||||||
|
</Button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={connectBankMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</div>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,10 @@ export default function DiscordConfigDrawer({
|
|||||||
apiClient.updateNotificationSettings({
|
apiClient.updateNotificationSettings({
|
||||||
...settings,
|
...settings,
|
||||||
discord: discordConfig,
|
discord: discordConfig,
|
||||||
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
filters: settings?.filters || {
|
||||||
|
case_insensitive: [],
|
||||||
|
case_sensitive: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
@@ -60,10 +63,12 @@ export default function DiscordConfigDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => apiClient.testNotification({
|
mutationFn: () =>
|
||||||
service: "discord",
|
apiClient.testNotification({
|
||||||
message: "Test notification from Leggen - Discord configuration is working!"
|
service: "discord",
|
||||||
}),
|
message:
|
||||||
|
"Test notification from Leggen - Discord configuration is working!",
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log("Test Discord notification sent successfully");
|
console.log("Test Discord notification sent successfully");
|
||||||
},
|
},
|
||||||
@@ -81,13 +86,13 @@ export default function DiscordConfigDrawer({
|
|||||||
testMutation.mutate();
|
testMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isConfigValid = config.webhook.trim().length > 0 && config.webhook.includes('discord.com/api/webhooks');
|
const isConfigValid =
|
||||||
|
config.webhook.trim().length > 0 &&
|
||||||
|
config.webhook.includes("discord.com/api/webhooks");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
@@ -103,7 +108,9 @@ export default function DiscordConfigDrawer({
|
|||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Enable/Disable Toggle */}
|
{/* Enable/Disable Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base font-medium">Enable Discord Notifications</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Enable Discord Notifications
|
||||||
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
@@ -118,11 +125,14 @@ export default function DiscordConfigDrawer({
|
|||||||
type="url"
|
type="url"
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
value={config.webhook}
|
value={config.webhook}
|
||||||
onChange={(e) => setConfig({ ...config, webhook: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, webhook: e.target.value })
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Create a webhook in your Discord server settings under Integrations → Webhooks
|
Create a webhook in your Discord server settings under
|
||||||
|
Integrations → Webhooks
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,9 +140,13 @@ export default function DiscordConfigDrawer({
|
|||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isConfigValid ? 'Configuration Valid' : 'Invalid Webhook URL'}
|
{isConfigValid
|
||||||
|
? "Configuration Valid"
|
||||||
|
: "Invalid Webhook URL"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!isConfigValid && config.webhook.trim().length > 0 && (
|
{!isConfigValid && config.webhook.trim().length > 0 && (
|
||||||
@@ -145,8 +159,13 @@ export default function DiscordConfigDrawer({
|
|||||||
|
|
||||||
<DrawerFooter className="px-0">
|
<DrawerFooter className="px-0">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
<Button
|
||||||
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || !config.enabled}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending
|
||||||
|
? "Saving..."
|
||||||
|
: "Save Configuration"}
|
||||||
</Button>
|
</Button>
|
||||||
{config.enabled && isConfigValid && (
|
{config.enabled && isConfigValid && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,20 +67,32 @@ export default function NotificationFiltersDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addCaseInsensitiveFilter = () => {
|
const addCaseInsensitiveFilter = () => {
|
||||||
if (newCaseInsensitive.trim() && !filters.case_insensitive.includes(newCaseInsensitive.trim())) {
|
if (
|
||||||
|
newCaseInsensitive.trim() &&
|
||||||
|
!filters.case_insensitive.includes(newCaseInsensitive.trim())
|
||||||
|
) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_insensitive: [...filters.case_insensitive, newCaseInsensitive.trim()],
|
case_insensitive: [
|
||||||
|
...filters.case_insensitive,
|
||||||
|
newCaseInsensitive.trim(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setNewCaseInsensitive("");
|
setNewCaseInsensitive("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCaseSensitiveFilter = () => {
|
const addCaseSensitiveFilter = () => {
|
||||||
if (newCaseSensitive.trim() && !filters.case_sensitive?.includes(newCaseSensitive.trim())) {
|
if (
|
||||||
|
newCaseSensitive.trim() &&
|
||||||
|
!filters.case_sensitive?.includes(newCaseSensitive.trim())
|
||||||
|
) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_sensitive: [...(filters.case_sensitive || []), newCaseSensitive.trim()],
|
case_sensitive: [
|
||||||
|
...(filters.case_sensitive || []),
|
||||||
|
newCaseSensitive.trim(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setNewCaseSensitive("");
|
setNewCaseSensitive("");
|
||||||
}
|
}
|
||||||
@@ -96,30 +108,33 @@ export default function NotificationFiltersDrawer({
|
|||||||
const removeCaseSensitiveFilter = (index: number) => {
|
const removeCaseSensitiveFilter = (index: number) => {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_sensitive: filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
case_sensitive:
|
||||||
|
filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>Notification Filters</DrawerTitle>
|
<DrawerTitle>Notification Filters</DrawerTitle>
|
||||||
<DrawerDescription>
|
<DrawerDescription>
|
||||||
Configure which transaction descriptions should trigger notifications
|
Configure which transaction descriptions should trigger
|
||||||
|
notifications
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Case Insensitive Filters */}
|
{/* Case Insensitive Filters */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-medium">Case Insensitive Filters</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Case Insensitive Filters
|
||||||
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters that match regardless of capitalization (e.g., "AMAZON" matches "amazon")
|
Filters that match regardless of capitalization (e.g., "AMAZON"
|
||||||
|
matches "amazon")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -134,7 +149,11 @@ export default function NotificationFiltersDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={addCaseInsensitiveFilter} size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addCaseInsensitiveFilter}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,16 +176,21 @@ export default function NotificationFiltersDrawer({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">No filters added</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No filters added
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Case Sensitive Filters */}
|
{/* Case Sensitive Filters */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-medium">Case Sensitive Filters</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Case Sensitive Filters
|
||||||
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters that match exactly as typed (e.g., "AMAZON" only matches "AMAZON")
|
Filters that match exactly as typed (e.g., "AMAZON" only matches
|
||||||
|
"AMAZON")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -181,7 +205,11 @@ export default function NotificationFiltersDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={addCaseSensitiveFilter} size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addCaseSensitiveFilter}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +232,9 @@ export default function NotificationFiltersDrawer({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">No filters added</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No filters added
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,14 +10,17 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Plus,
|
|
||||||
Bell,
|
Bell,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
Filter,
|
Filter,
|
||||||
|
Cloud,
|
||||||
|
Archive,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -35,11 +38,15 @@ import AccountsSkeleton from "./AccountsSkeleton";
|
|||||||
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||||
|
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
||||||
|
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Balance,
|
Balance,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
BackupSettings,
|
||||||
|
BackupInfo,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Helper function to get status indicator color and styles
|
// 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 [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||||
|
const [showBackups, setShowBackups] = useState(false);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -120,6 +128,33 @@ export default function Settings() {
|
|||||||
queryFn: apiClient.getNotificationServices,
|
queryFn: apiClient.getNotificationServices,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: bankConnections } = useQuery({
|
||||||
|
queryKey: ["bankConnections"],
|
||||||
|
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
|
// Account mutations
|
||||||
const updateAccountMutation = useMutation({
|
const updateAccountMutation = useMutation({
|
||||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||||
@@ -143,6 +178,36 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bank connection mutations
|
||||||
|
const deleteBankConnectionMutation = useMutation({
|
||||||
|
mutationFn: apiClient.deleteBankConnection,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bankConnections"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["balances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
// Account handlers
|
||||||
const handleEditStart = (account: Account) => {
|
const handleEditStart = (account: Account) => {
|
||||||
setEditingAccountId(account.id);
|
setEditingAccountId(account.id);
|
||||||
@@ -174,8 +239,27 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoading = accountsLoading || settingsLoading || servicesLoading;
|
// Backup handlers
|
||||||
const hasError = accountsError || settingsError || servicesError;
|
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) {
|
if (isLoading) {
|
||||||
return <AccountsSkeleton />;
|
return <AccountsSkeleton />;
|
||||||
@@ -196,6 +280,7 @@ export default function Settings() {
|
|||||||
refetchAccounts();
|
refetchAccounts();
|
||||||
refetchSettings();
|
refetchSettings();
|
||||||
refetchServices();
|
refetchServices();
|
||||||
|
refetchBackup();
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -211,7 +296,7 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Tabs defaultValue="accounts" 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">
|
<TabsTrigger value="accounts" className="flex items-center space-x-2">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
<span>Accounts</span>
|
<span>Accounts</span>
|
||||||
@@ -223,6 +308,10 @@ export default function Settings() {
|
|||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="backup" className="flex items-center space-x-2">
|
||||||
|
<Cloud className="h-4 w-4" />
|
||||||
|
<span>Backup</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="accounts" className="space-y-6">
|
<TabsContent value="accounts" className="space-y-6">
|
||||||
@@ -245,13 +334,6 @@ export default function Settings() {
|
|||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Connect your first bank account to get started with Leggen.
|
Connect your first bank account to get started with Leggen.
|
||||||
</p>
|
</p>
|
||||||
<Button disabled className="flex items-center space-x-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>Add Bank Account</span>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Coming soon: Add new bank connections
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
) : (
|
) : (
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -288,8 +370,12 @@ export default function Settings() {
|
|||||||
alt={`${account.institution_id} logo`}
|
alt={`${account.institution_id} logo`}
|
||||||
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
console.warn(
|
||||||
setFailedImages(prev => new Set([...prev, account.id]));
|
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||||
|
);
|
||||||
|
setFailedImages(
|
||||||
|
(prev) => new Set([...prev, account.id]),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -417,30 +503,110 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add Bank Section (Future Feature) */}
|
{/* Bank Connections Status */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add New Bank Account</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Connect additional bank accounts to track all your finances in
|
<CardTitle>Bank Connections</CardTitle>
|
||||||
one place
|
<CardDescription>
|
||||||
</CardDescription>
|
Status of all bank connection requests and their
|
||||||
</CardHeader>
|
authorization state
|
||||||
<CardContent className="p-6">
|
</CardDescription>
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<div className="p-4 bg-muted rounded-lg">
|
|
||||||
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Bank connection functionality is coming soon. Stay tuned for
|
|
||||||
updates!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button disabled variant="outline">
|
<AddBankAccountDrawer />
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Connect Bank Account
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
|
|
||||||
|
{!bankConnections || bankConnections.length === 0 ? (
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No bank connections found
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Bank connection requests will appear here after you connect
|
||||||
|
accounts.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{bankConnections.map((connection) => {
|
||||||
|
const statusColor =
|
||||||
|
connection.status.toLowerCase() === "ln"
|
||||||
|
? "bg-green-500"
|
||||||
|
: connection.status.toLowerCase() === "cr"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: connection.status.toLowerCase() === "ex"
|
||||||
|
? "bg-red-500"
|
||||||
|
: "bg-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={connection.requisition_id}
|
||||||
|
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4 min-w-0 flex-1">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h4 className="text-base font-medium text-foreground truncate">
|
||||||
|
{connection.bank_name}
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${statusColor}`}
|
||||||
|
title={connection.status_display}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{connection.status_display} •{" "}
|
||||||
|
{connection.accounts_count} account
|
||||||
|
{connection.accounts_count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
ID: {connection.requisition_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Created {formatDate(connection.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const isWorking =
|
||||||
|
connection.status.toLowerCase() === "ln";
|
||||||
|
const message = isWorking
|
||||||
|
? `Are you sure you want to disconnect "${connection.bank_name}"? This will stop syncing new transactions but keep your existing transaction history.`
|
||||||
|
: `Delete connection to ${connection.bank_name}?`;
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
deleteBankConnectionMutation.mutate(
|
||||||
|
connection.requisition_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteBankConnectionMutation.isPending}
|
||||||
|
className="p-1 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Delete connection"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -495,19 +661,21 @@ export default function Settings() {
|
|||||||
{service.name}
|
{service.name}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
<div
|
||||||
service.enabled && service.configured
|
className={`w-2 h-2 rounded-full ${
|
||||||
? 'bg-green-500'
|
service.enabled && service.configured
|
||||||
: service.enabled
|
? "bg-green-500"
|
||||||
? 'bg-amber-500'
|
: service.enabled
|
||||||
: 'bg-muted-foreground'
|
? "bg-amber-500"
|
||||||
}`} />
|
: "bg-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{service.enabled && service.configured
|
{service.enabled && service.configured
|
||||||
? 'Active'
|
? "Active"
|
||||||
: service.enabled
|
: service.enabled
|
||||||
? 'Needs Configuration'
|
? "Needs Configuration"
|
||||||
: 'Disabled'}
|
: "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,9 +684,15 @@ export default function Settings() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{service.name.toLowerCase().includes("discord") ? (
|
{service.name.toLowerCase().includes("discord") ? (
|
||||||
<DiscordConfigDrawer settings={notificationSettings} />
|
<DiscordConfigDrawer
|
||||||
) : service.name.toLowerCase().includes("telegram") ? (
|
settings={notificationSettings}
|
||||||
<TelegramConfigDrawer settings={notificationSettings} />
|
/>
|
||||||
|
) : service.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("telegram") ? (
|
||||||
|
<TelegramConfigDrawer
|
||||||
|
settings={notificationSettings}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -560,17 +734,22 @@ export default function Settings() {
|
|||||||
Case Insensitive Filters
|
Case Insensitive Filters
|
||||||
</Label>
|
</Label>
|
||||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
{notificationSettings.filters.case_insensitive.length > 0 ? (
|
{notificationSettings.filters.case_insensitive
|
||||||
notificationSettings.filters.case_insensitive.map((filter, index) => (
|
.length > 0 ? (
|
||||||
<span
|
notificationSettings.filters.case_insensitive.map(
|
||||||
key={index}
|
(filter, index) => (
|
||||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
<span
|
||||||
>
|
key={index}
|
||||||
{filter}
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
</span>
|
>
|
||||||
))
|
{filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">None</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
None
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,25 +759,31 @@ export default function Settings() {
|
|||||||
</Label>
|
</Label>
|
||||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
{notificationSettings.filters.case_sensitive &&
|
{notificationSettings.filters.case_sensitive &&
|
||||||
notificationSettings.filters.case_sensitive.length > 0 ? (
|
notificationSettings.filters.case_sensitive.length >
|
||||||
notificationSettings.filters.case_sensitive.map((filter, index) => (
|
0 ? (
|
||||||
<span
|
notificationSettings.filters.case_sensitive.map(
|
||||||
key={index}
|
(filter, index) => (
|
||||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
<span
|
||||||
>
|
key={index}
|
||||||
{filter}
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
</span>
|
>
|
||||||
))
|
{filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">None</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
None
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters determine which transaction descriptions will trigger notifications.
|
Filters determine which transaction descriptions will
|
||||||
Add terms to exclude transactions containing those words.
|
trigger notifications. Add terms to exclude transactions
|
||||||
|
containing those words.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -608,7 +793,8 @@ export default function Settings() {
|
|||||||
No notification filters configured
|
No notification filters configured
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Set up filters to control which transactions trigger notifications.
|
Set up filters to control which transactions trigger
|
||||||
|
notifications.
|
||||||
</p>
|
</p>
|
||||||
<NotificationFiltersDrawer settings={notificationSettings} />
|
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||||
</div>
|
</div>
|
||||||
@@ -616,6 +802,174 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export function SiteHeader() {
|
|||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
||||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ export default function System() {
|
|||||||
: "Sync Failed"}
|
: "Sync Failed"}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||||
|
operation.trigger_type.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||||
@@ -272,7 +273,8 @@ export default function System() {
|
|||||||
: "Sync Failed"}
|
: "Sync Failed"}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="outline" className="text-xs mt-1">
|
<Badge variant="outline" className="text-xs mt-1">
|
||||||
{operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)}
|
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||||
|
operation.trigger_type.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +288,9 @@ export default function System() {
|
|||||||
{startedAt.toLocaleDateString()}{" "}
|
{startedAt.toLocaleDateString()}{" "}
|
||||||
{startedAt.toLocaleTimeString()}
|
{startedAt.toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
{duration && <span className="ml-2">• {duration}</span>}
|
{duration && (
|
||||||
|
<span className="ml-2">• {duration}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
@@ -296,7 +300,9 @@ export default function System() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<TrendingUp className="h-3 w-3" />
|
<TrendingUp className="h-3 w-3" />
|
||||||
<span>{operation.transactions_added} new transactions</span>
|
<span>
|
||||||
|
{operation.transactions_added} new transactions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export default function TelegramConfigDrawer({
|
|||||||
apiClient.updateNotificationSettings({
|
apiClient.updateNotificationSettings({
|
||||||
...settings,
|
...settings,
|
||||||
telegram: telegramConfig,
|
telegram: telegramConfig,
|
||||||
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
filters: settings?.filters || {
|
||||||
|
case_insensitive: [],
|
||||||
|
case_sensitive: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
@@ -61,10 +64,12 @@ export default function TelegramConfigDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => apiClient.testNotification({
|
mutationFn: () =>
|
||||||
service: "telegram",
|
apiClient.testNotification({
|
||||||
message: "Test notification from Leggen - Telegram configuration is working!"
|
service: "telegram",
|
||||||
}),
|
message:
|
||||||
|
"Test notification from Leggen - Telegram configuration is working!",
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log("Test Telegram notification sent successfully");
|
console.log("Test Telegram notification sent successfully");
|
||||||
},
|
},
|
||||||
@@ -86,9 +91,7 @@ export default function TelegramConfigDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
@@ -104,7 +107,9 @@ export default function TelegramConfigDrawer({
|
|||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Enable/Disable Toggle */}
|
{/* Enable/Disable Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base font-medium">Enable Telegram Notifications</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Enable Telegram Notifications
|
||||||
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
@@ -119,7 +124,9 @@ export default function TelegramConfigDrawer({
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
value={config.token}
|
value={config.token}
|
||||||
onChange={(e) => setConfig({ ...config, token: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, token: e.target.value })
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -135,11 +142,18 @@ export default function TelegramConfigDrawer({
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="123456789"
|
placeholder="123456789"
|
||||||
value={config.chat_id || ""}
|
value={config.chat_id || ""}
|
||||||
onChange={(e) => setConfig({ ...config, chat_id: parseInt(e.target.value) || 0 })}
|
onChange={(e) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
chat_id: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Send a message to your bot and visit https://api.telegram.org/bot<token>/getUpdates to find your chat ID
|
Send a message to your bot and visit
|
||||||
|
https://api.telegram.org/bot<token>/getUpdates to find
|
||||||
|
your chat ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,23 +161,33 @@ export default function TelegramConfigDrawer({
|
|||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isConfigValid ? 'Configuration Valid' : 'Missing Token or Chat ID'}
|
{isConfigValid
|
||||||
|
? "Configuration Valid"
|
||||||
|
: "Missing Token or Chat ID"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!isConfigValid && (config.token.trim().length > 0 || config.chat_id !== 0) && (
|
{!isConfigValid &&
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
(config.token.trim().length > 0 || config.chat_id !== 0) && (
|
||||||
Both bot token and chat ID are required
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</p>
|
Both bot token and chat ID are required
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DrawerFooter className="px-0">
|
<DrawerFooter className="px-0">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
<Button
|
||||||
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || !config.enabled}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending
|
||||||
|
? "Saving..."
|
||||||
|
: "Save Configuration"}
|
||||||
</Button>
|
</Button>
|
||||||
{config.enabled && isConfigValid && (
|
{config.enabled && isConfigValid && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export default function TimePeriodFilter({
|
|||||||
className = "",
|
className = "",
|
||||||
}: TimePeriodFilterProps) {
|
}: TimePeriodFilterProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}>
|
<div
|
||||||
|
className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Calendar size={20} />
|
<Calendar size={20} />
|
||||||
<span className="font-medium">Time Period:</span>
|
<span className="font-medium">Time Period:</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Drawer = ({
|
const Drawer = ({
|
||||||
shouldScaleBackground = true,
|
shouldScaleBackground = true,
|
||||||
@@ -11,14 +11,14 @@ const Drawer = ({
|
|||||||
shouldScaleBackground={shouldScaleBackground}
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
Drawer.displayName = "Drawer"
|
Drawer.displayName = "Drawer";
|
||||||
|
|
||||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||||
|
|
||||||
const DrawerPortal = DrawerPrimitive.Portal
|
const DrawerPortal = DrawerPrimitive.Portal;
|
||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
const DrawerClose = DrawerPrimitive.Close;
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
const DrawerOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
@@ -29,8 +29,8 @@ const DrawerOverlay = React.forwardRef<
|
|||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
const DrawerContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
@@ -42,7 +42,7 @@ const DrawerContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -50,8 +50,8 @@ const DrawerContent = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
))
|
));
|
||||||
DrawerContent.displayName = "DrawerContent"
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
const DrawerHeader = ({
|
const DrawerHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -61,8 +61,8 @@ const DrawerHeader = ({
|
|||||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerHeader.displayName = "DrawerHeader"
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
const DrawerFooter = ({
|
const DrawerFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -72,8 +72,8 @@ const DrawerFooter = ({
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
const DrawerTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
@@ -83,12 +83,12 @@ const DrawerTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
const DrawerDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
@@ -99,8 +99,8 @@ const DrawerDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -113,4 +113,4 @@ export {
|
|||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ interface EditButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: "default" | "sm" | "lg" | "icon";
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ export function EditButton({
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const ScrollArea = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
|
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
@@ -10,18 +10,18 @@ const Switch = React.forwardRef<
|
|||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
))
|
));
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
export { Switch }
|
export { Switch };
|
||||||
|
|||||||
@@ -15,21 +15,23 @@ export function usePWA(): PWAUpdate {
|
|||||||
const forceReload = async (): Promise<void> => {
|
const forceReload = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Clear all caches
|
// Clear all caches
|
||||||
if ('caches' in window) {
|
if ("caches" in window) {
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
cacheNames.map(cacheName => caches.delete(cacheName))
|
cacheNames.map((cacheName) => caches.delete(cacheName)),
|
||||||
);
|
);
|
||||||
console.log("All caches cleared");
|
console.log("All caches cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unregister service worker
|
// Unregister service worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
await Promise.all(registrations.map(registration => registration.unregister()));
|
await Promise.all(
|
||||||
|
registrations.map((registration) => registration.unregister()),
|
||||||
|
);
|
||||||
console.log("All service workers unregistered");
|
console.log("All service workers unregistered");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reload
|
// Force reload
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { apiClient } from "../lib/api";
|
|||||||
const VERSION_STORAGE_KEY = "leggen_app_version";
|
const VERSION_STORAGE_KEY = "leggen_app_version";
|
||||||
|
|
||||||
export function useVersionCheck(forceReload: () => Promise<void>) {
|
export function useVersionCheck(forceReload: () => Promise<void>) {
|
||||||
const {
|
const { data: healthStatus, isSuccess: healthSuccess } = useQuery({
|
||||||
data: healthStatus,
|
|
||||||
isSuccess: healthSuccess,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["health"],
|
queryKey: ["health"],
|
||||||
queryFn: apiClient.getHealth,
|
queryFn: apiClient.getHealth,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
@@ -20,14 +17,16 @@ export function useVersionCheck(forceReload: () => Promise<void>) {
|
|||||||
if (healthSuccess && healthStatus?.version) {
|
if (healthSuccess && healthStatus?.version) {
|
||||||
const currentVersion = healthStatus.version;
|
const currentVersion = healthStatus.version;
|
||||||
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
||||||
|
|
||||||
if (storedVersion && storedVersion !== currentVersion) {
|
if (storedVersion && storedVersion !== currentVersion) {
|
||||||
console.log(`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`);
|
console.log(
|
||||||
|
`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`,
|
||||||
|
);
|
||||||
console.log("Clearing cache and reloading...");
|
console.log("Clearing cache and reloading...");
|
||||||
|
|
||||||
// Update stored version first
|
// Update stored version first
|
||||||
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
||||||
|
|
||||||
// Force reload to clear cache
|
// Force reload to clear cache
|
||||||
forceReload();
|
forceReload();
|
||||||
} else if (!storedVersion) {
|
} else if (!storedVersion) {
|
||||||
@@ -37,4 +36,4 @@ export function useVersionCheck(forceReload: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [healthSuccess, healthStatus?.version, forceReload]);
|
}, [healthSuccess, healthStatus?.version, forceReload]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import type {
|
|||||||
AccountUpdate,
|
AccountUpdate,
|
||||||
TransactionStats,
|
TransactionStats,
|
||||||
SyncOperationsResponse,
|
SyncOperationsResponse,
|
||||||
|
BankInstitution,
|
||||||
|
BankConnectionStatus,
|
||||||
|
BankRequisition,
|
||||||
|
Country,
|
||||||
|
BackupSettings,
|
||||||
|
BackupTest,
|
||||||
|
BackupInfo,
|
||||||
|
BackupOperation,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -168,8 +176,6 @@ export const apiClient = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Analytics endpoints
|
// Analytics endpoints
|
||||||
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
@@ -231,6 +237,79 @@ export const apiClient = {
|
|||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Bank management endpoints
|
||||||
|
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
|
||||||
|
const response = await api.get<ApiResponse<BankInstitution[]>>(
|
||||||
|
`/banks/institutions?country=${country}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
|
||||||
|
const response =
|
||||||
|
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createBankConnection: async (
|
||||||
|
institutionId: string,
|
||||||
|
redirectUrl?: string,
|
||||||
|
): Promise<BankRequisition> => {
|
||||||
|
// If no redirect URL provided, construct it from current location
|
||||||
|
const finalRedirectUrl =
|
||||||
|
redirectUrl || `${window.location.origin}/bank-connected`;
|
||||||
|
|
||||||
|
const response = await api.post<ApiResponse<BankRequisition>>(
|
||||||
|
"/banks/connect",
|
||||||
|
{
|
||||||
|
institution_id: institutionId,
|
||||||
|
redirect_url: finalRedirectUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteBankConnection: async (requisitionId: string): Promise<void> => {
|
||||||
|
await api.delete(`/banks/connections/${requisitionId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSupportedCountries: async (): Promise<Country[]> => {
|
||||||
|
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;
|
export default apiClient;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Route as TransactionsRouteImport } from './routes/transactions'
|
|||||||
import { Route as SystemRouteImport } from './routes/system'
|
import { Route as SystemRouteImport } from './routes/system'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||||
|
import { Route as BankConnectedRouteImport } from './routes/bank-connected'
|
||||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ const NotificationsRoute = NotificationsRouteImport.update({
|
|||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const BankConnectedRoute = BankConnectedRouteImport.update({
|
||||||
|
id: '/bank-connected',
|
||||||
|
path: '/bank-connected',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AnalyticsRoute = AnalyticsRouteImport.update({
|
const AnalyticsRoute = AnalyticsRouteImport.update({
|
||||||
id: '/analytics',
|
id: '/analytics',
|
||||||
path: '/analytics',
|
path: '/analytics',
|
||||||
@@ -50,6 +56,7 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -58,6 +65,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -67,6 +75,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -77,6 +86,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -85,6 +95,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -93,6 +104,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -102,6 +114,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AnalyticsRoute: typeof AnalyticsRoute
|
AnalyticsRoute: typeof AnalyticsRoute
|
||||||
|
BankConnectedRoute: typeof BankConnectedRoute
|
||||||
NotificationsRoute: typeof NotificationsRoute
|
NotificationsRoute: typeof NotificationsRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
SystemRoute: typeof SystemRoute
|
SystemRoute: typeof SystemRoute
|
||||||
@@ -138,6 +151,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof NotificationsRouteImport
|
preLoaderRoute: typeof NotificationsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/bank-connected': {
|
||||||
|
id: '/bank-connected'
|
||||||
|
path: '/bank-connected'
|
||||||
|
fullPath: '/bank-connected'
|
||||||
|
preLoaderRoute: typeof BankConnectedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/analytics': {
|
'/analytics': {
|
||||||
id: '/analytics'
|
id: '/analytics'
|
||||||
path: '/analytics'
|
path: '/analytics'
|
||||||
@@ -158,6 +178,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AnalyticsRoute: AnalyticsRoute,
|
AnalyticsRoute: AnalyticsRoute,
|
||||||
|
BankConnectedRoute: BankConnectedRoute,
|
||||||
NotificationsRoute: NotificationsRoute,
|
NotificationsRoute: NotificationsRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
SystemRoute: SystemRoute,
|
SystemRoute: SystemRoute,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
|||||||
import { usePWA } from "../hooks/usePWA";
|
import { usePWA } from "../hooks/usePWA";
|
||||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||||
|
import { Toaster } from "../components/ui/sonner";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { updateAvailable, updateSW, forceReload } = usePWA();
|
const { updateAvailable, updateSW, forceReload } = usePWA();
|
||||||
|
|
||||||
// Check for version mismatches and force reload if needed
|
// Check for version mismatches and force reload if needed
|
||||||
useVersionCheck(forceReload);
|
useVersionCheck(forceReload);
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@ function RootLayout() {
|
|||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
onUpdate={handlePWAUpdate}
|
onUpdate={handlePWAUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<Toaster />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
frontend/src/routes/bank-connected.tsx
Normal file
57
frontend/src/routes/bank-connected.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||||
|
import { CheckCircle, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../components/ui/card";
|
||||||
|
|
||||||
|
function BankConnected() {
|
||||||
|
const search = useSearch({ from: "/bank-connected" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="mx-auto mb-4">
|
||||||
|
<CheckCircle className="h-16 w-16 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Account Connected!</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your bank account has been successfully connected to Leggen. We'll
|
||||||
|
start syncing your transactions shortly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{search?.bank && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Connected to: <strong>{search.bank}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => (window.location.href = "/settings")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Go to Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/bank-connected")({
|
||||||
|
component: BankConnected,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
bank: (search.bank as string) || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -242,3 +242,69 @@ export interface SyncOperationsResponse {
|
|||||||
operations: SyncOperation[];
|
operations: SyncOperation[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bank-related types
|
||||||
|
export interface BankInstitution {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bic?: string;
|
||||||
|
transaction_total_days: number;
|
||||||
|
countries: string[];
|
||||||
|
logo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankRequisition {
|
||||||
|
id: string;
|
||||||
|
institution_id: string;
|
||||||
|
status: string;
|
||||||
|
status_display?: string;
|
||||||
|
created: string;
|
||||||
|
link: string;
|
||||||
|
accounts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankConnectionStatus {
|
||||||
|
bank_id: string;
|
||||||
|
bank_name: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
created_at: string;
|
||||||
|
requisition_id: string;
|
||||||
|
accounts_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -22,7 +23,11 @@ async def get_bank_institutions(
|
|||||||
"""Get available bank institutions for a country"""
|
"""Get available bank institutions for a country"""
|
||||||
try:
|
try:
|
||||||
institutions_response = await gocardless_service.get_institutions(country)
|
institutions_response = await gocardless_service.get_institutions(country)
|
||||||
institutions_data = institutions_response.get("results", [])
|
# Handle both list and dict responses
|
||||||
|
if isinstance(institutions_response, list):
|
||||||
|
institutions_data = institutions_response
|
||||||
|
else:
|
||||||
|
institutions_data = institutions_response.get("results", [])
|
||||||
|
|
||||||
institutions = [
|
institutions = [
|
||||||
BankInstitution(
|
BankInstitution(
|
||||||
@@ -122,13 +127,36 @@ async def get_bank_connections_status() -> APIResponse:
|
|||||||
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
||||||
"""Delete a bank connection"""
|
"""Delete a bank connection"""
|
||||||
try:
|
try:
|
||||||
# This would need to be implemented in GoCardlessService
|
# Delete the requisition from GoCardless
|
||||||
# For now, return success
|
result = await gocardless_service.delete_requisition(requisition_id)
|
||||||
|
|
||||||
|
# GoCardless returns different responses for successful deletes
|
||||||
|
# We should check if the operation was actually successful
|
||||||
|
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Bank connection {requisition_id} deleted successfully",
|
message=f"Bank connection {requisition_id} deleted successfully",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as http_err:
|
||||||
|
logger.error(
|
||||||
|
f"HTTP error deleting bank connection {requisition_id}: {http_err}"
|
||||||
|
)
|
||||||
|
if http_err.response.status_code == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Bank connection {requisition_id} not found"
|
||||||
|
) from http_err
|
||||||
|
elif http_err.response.status_code == 400:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid request to delete connection {requisition_id}",
|
||||||
|
) from http_err
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=http_err.response.status_code,
|
||||||
|
detail=f"GoCardless API error: {http_err}",
|
||||||
|
) from http_err
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from loguru import logger
|
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.background.scheduler import scheduler
|
||||||
from leggen.utils.config import config
|
from leggen.utils.config import config
|
||||||
from leggen.utils.paths import path_manager
|
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(transactions.router, prefix="/api/v1", tags=["transactions"])
|
||||||
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
|
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
|
||||||
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
|
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")
|
@app.get("/api/v1/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ class NotificationConfig(BaseModel):
|
|||||||
telegram: Optional[TelegramNotificationConfig] = None
|
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):
|
class FilterConfig(BaseModel):
|
||||||
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
||||||
case_sensitive: 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
|
notifications: Optional[NotificationConfig] = None
|
||||||
filters: Optional[FilterConfig] = None
|
filters: Optional[FilterConfig] = None
|
||||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
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
|
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"""
|
"""Log GoCardless API rate limit headers"""
|
||||||
limit = response.headers.get("http_x_ratelimit_limit")
|
limit = response.headers.get("http_x_ratelimit_limit")
|
||||||
remaining = response.headers.get("http_x_ratelimit_remaining")
|
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||||
reset = response.headers.get("http_x_ratelimit_reset")
|
reset = response.headers.get("http_x_ratelimit_reset")
|
||||||
|
|
||||||
if limit or remaining or reset:
|
account_limit = response.headers.get("http_x_ratelimit_account_success_limit")
|
||||||
logger.info(
|
account_remaining = response.headers.get(
|
||||||
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
|
"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:
|
class GoCardlessService:
|
||||||
@@ -37,7 +42,7 @@ class GoCardlessService:
|
|||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.request(method, url, headers=headers, **kwargs)
|
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 we get 401, clear token cache and retry once
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
@@ -45,7 +50,7 @@ class GoCardlessService:
|
|||||||
self._token = None
|
self._token = None
|
||||||
headers = await self._get_auth_headers()
|
headers = await self._get_auth_headers()
|
||||||
response = await client.request(method, url, headers=headers, **kwargs)
|
response = await client.request(method, url, headers=headers, **kwargs)
|
||||||
_log_rate_limits(response)
|
_log_rate_limits(response, method, url)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -76,7 +81,9 @@ class GoCardlessService:
|
|||||||
f"{self.base_url}/token/refresh/",
|
f"{self.base_url}/token/refresh/",
|
||||||
json={"refresh": auth["refresh"]},
|
json={"refresh": auth["refresh"]},
|
||||||
)
|
)
|
||||||
_log_rate_limits(response)
|
_log_rate_limits(
|
||||||
|
response, "POST", f"{self.base_url}/token/refresh/"
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
auth.update(response.json())
|
auth.update(response.json())
|
||||||
self._save_auth(auth)
|
self._save_auth(auth)
|
||||||
@@ -104,7 +111,7 @@ class GoCardlessService:
|
|||||||
"secret_key": self.config["secret"],
|
"secret_key": self.config["secret"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
_log_rate_limits(response)
|
_log_rate_limits(response, "POST", f"{self.base_url}/token/new/")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
auth = response.json()
|
auth = response.json()
|
||||||
self._save_auth(auth)
|
self._save_auth(auth)
|
||||||
@@ -144,6 +151,12 @@ class GoCardlessService:
|
|||||||
"GET", f"{self.base_url}/requisitions/"
|
"GET", f"{self.base_url}/requisitions/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def delete_requisition(self, requisition_id: str) -> Dict[str, Any]:
|
||||||
|
"""Delete a requisition"""
|
||||||
|
return await self._make_authenticated_request(
|
||||||
|
"DELETE", f"{self.base_url}/requisitions/{requisition_id}/"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||||
"""Get account details"""
|
"""Get account details"""
|
||||||
return await self._make_authenticated_request(
|
return await self._make_authenticated_request(
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class Config:
|
|||||||
}
|
}
|
||||||
return self.config.get("scheduler", default_schedule)
|
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):
|
def load_config(ctx: click.Context, _, filename):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.23"
|
version = "2025.9.26"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"tomli-w>=1.0.0,<2",
|
"tomli-w>=1.0.0,<2",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pydantic>=2.0.0,<3",
|
"pydantic>=2.0.0,<3",
|
||||||
|
"boto3>=1.35.0,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -88,5 +89,5 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["apscheduler.*", "discord_webhook.*"]
|
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ echo " > Version bumped to $NEXT_VERSION"
|
|||||||
echo "Updating CHANGELOG.md"
|
echo "Updating CHANGELOG.md"
|
||||||
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
|
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
|
||||||
|
|
||||||
|
echo "Locking dependencies"
|
||||||
|
uv lock
|
||||||
|
|
||||||
echo " > Commiting changes and adding git tag"
|
echo " > Commiting changes and adding git tag"
|
||||||
git add pyproject.toml CHANGELOG.md uv.lock
|
git add pyproject.toml CHANGELOG.md uv.lock
|
||||||
git commit -m "chore(ci): Bump version to $NEXT_VERSION"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.8.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.23"
|
version = "2025.9.26"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
{ name = "boto3" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "discord-webhook" },
|
{ name = "discord-webhook" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
@@ -253,6 +291,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "apscheduler", specifier = ">=3.10.0,<4" },
|
{ name = "apscheduler", specifier = ">=3.10.0,<4" },
|
||||||
|
{ name = "boto3", specifier = ">=1.35.0,<2" },
|
||||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||||
{ name = "discord-webhook", specifier = ">=1.3.1,<2" },
|
{ name = "discord-webhook", specifier = ">=1.3.1,<2" },
|
||||||
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.1.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user