feat: add homepage
This commit is contained in:
7
data/.gitignore
vendored
Normal file
7
data/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Ignore database files but keep the directory
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.db
|
||||
|
||||
# Keep this .gitignore
|
||||
!.gitignore
|
||||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"vue": "^3.4.21"
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
@@ -1216,6 +1217,12 @@
|
||||
"@vue/shared": "3.5.26"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||
@@ -2554,6 +2561,21 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"vue": "^3.4.21",
|
||||
"pdfjs-dist": "^4.0.379"
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
|
||||
@@ -3,143 +3,60 @@
|
||||
<!-- Header compact -->
|
||||
<header class="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold text-white">
|
||||
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors">
|
||||
Plesna Gerance
|
||||
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
|
||||
</h1>
|
||||
</router-link>
|
||||
|
||||
<!-- Upload button in header when file is loaded -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label
|
||||
v-if="pdfFile"
|
||||
class="cursor-pointer px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||
<!-- Navigation -->
|
||||
<nav class="flex items-center gap-3">
|
||||
<router-link
|
||||
to="/"
|
||||
class="px-3 py-1.5 text-sm rounded transition-colors"
|
||||
:class="$route.path === '/' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
|
||||
>
|
||||
Changer de PDF
|
||||
Accueil
|
||||
</router-link>
|
||||
|
||||
<!-- Import button with file input -->
|
||||
<label
|
||||
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"
|
||||
:class="$route.path === '/extract' ? 'bg-blue-600 text-white' : ''"
|
||||
>
|
||||
Importer un PDF
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onNewFileSelect"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
<span v-if="pdfFile" class="text-gray-400 text-sm truncate max-w-[200px]">
|
||||
{{ pdfFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Upload zone (when no file) -->
|
||||
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8">
|
||||
<PdfUploader
|
||||
class="max-w-xl w-full"
|
||||
@file-selected="onFileSelected"
|
||||
@extraction-complete="onExtractionComplete"
|
||||
@extraction-error="onExtractionError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Split view when file is loaded -->
|
||||
<template v-else>
|
||||
<!-- Left panel: PDF Preview -->
|
||||
<div class="w-1/2 flex flex-col border-r border-gray-700">
|
||||
<PdfPreview
|
||||
:file="pdfFile"
|
||||
:file-name="pdfFile.name"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: JSON Viewer -->
|
||||
<div class="w-1/2 flex flex-col bg-gray-50">
|
||||
<!-- Extract button bar -->
|
||||
<div v-if="!extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
|
||||
<button
|
||||
@click="extractCurrentFile"
|
||||
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Extraire les donnees du PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<JsonViewer
|
||||
:data="extractedData"
|
||||
:is-loading="isExtracting"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import PdfUploader from './components/PdfUploader.vue'
|
||||
import PdfPreview from './components/PdfPreview.vue'
|
||||
import JsonViewer from './components/JsonViewer.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from './store'
|
||||
|
||||
const pdfFile = ref(null)
|
||||
const extractedData = ref(null)
|
||||
const isExtracting = ref(false)
|
||||
const router = useRouter()
|
||||
const fileInput = ref(null)
|
||||
|
||||
function onFileSelected(file) {
|
||||
pdfFile.value = file
|
||||
extractedData.value = null
|
||||
if (file) {
|
||||
// Auto-extract when file is selected
|
||||
extractCurrentFile()
|
||||
}
|
||||
}
|
||||
|
||||
function onNewFileSelect(e) {
|
||||
function onFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pdfFile.value = file
|
||||
extractedData.value = null
|
||||
extractCurrentFile()
|
||||
pendingFile.value = file
|
||||
router.push('/extract')
|
||||
}
|
||||
e.target.value = '' // Reset input
|
||||
}
|
||||
|
||||
async function extractCurrentFile() {
|
||||
if (!pdfFile.value) return
|
||||
|
||||
isExtracting.value = true
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', pdfFile.value)
|
||||
|
||||
const response = await fetch('/api/extract', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
extractedData.value = data
|
||||
} catch (err) {
|
||||
console.error('Extraction error:', err)
|
||||
alert('Erreur: ' + err.message)
|
||||
} finally {
|
||||
isExtracting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onExtractionComplete(data) {
|
||||
extractedData.value = data
|
||||
isExtracting.value = false
|
||||
}
|
||||
|
||||
function onExtractionError(error) {
|
||||
console.error('Extraction error:', error)
|
||||
isExtracting.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
224
frontend/src/pages/ExtractPage.vue
Normal file
224
frontend/src/pages/ExtractPage.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Upload or PDF Preview -->
|
||||
<div class="w-1/2 flex flex-col border-r border-gray-700">
|
||||
<!-- Upload zone when no file -->
|
||||
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-800">
|
||||
<div class="max-w-md w-full">
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
:class="{ 'border-blue-500 bg-blue-500/10': isDragging }"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-gray-400 mb-2">Glisser un PDF ici</p>
|
||||
<p class="text-sm text-gray-500">ou <span class="text-blue-400">cliquer pour parcourir</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF Preview when file loaded -->
|
||||
<PdfPreview
|
||||
v-else
|
||||
:file="pdfFile"
|
||||
:file-name="pdfFile.name"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: JSON Viewer -->
|
||||
<div class="w-1/2 flex flex-col bg-gray-50">
|
||||
<!-- Extract button bar -->
|
||||
<div v-if="pdfFile && !extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
|
||||
<button
|
||||
@click="extractCurrentFile"
|
||||
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Extraire les donnees du PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save button bar (when data is extracted) -->
|
||||
<div v-if="extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="saveToDatabase"
|
||||
:disabled="isSaving"
|
||||
class="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isSaving">Sauvegarde en cours...</span>
|
||||
<span v-else>Sauvegarder en base</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save feedback message -->
|
||||
<div v-if="saveMessage" class="mt-3">
|
||||
<div
|
||||
:class="[
|
||||
'px-4 py-3 rounded-lg text-sm',
|
||||
saveMessage.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : '',
|
||||
saveMessage.type === 'duplicate' ? 'bg-yellow-100 text-yellow-800 border border-yellow-200' : '',
|
||||
saveMessage.type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' : ''
|
||||
]"
|
||||
>
|
||||
<div class="font-medium">{{ saveMessage.title }}</div>
|
||||
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-80">{{ saveMessage.details }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder when no file -->
|
||||
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center text-gray-400">
|
||||
<p>Selectionnez un PDF pour commencer</p>
|
||||
</div>
|
||||
|
||||
<JsonViewer
|
||||
v-else
|
||||
:data="extractedData"
|
||||
:is-loading="isExtracting"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
import PdfPreview from '../components/PdfPreview.vue'
|
||||
import JsonViewer from '../components/JsonViewer.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const pdfFile = ref(null)
|
||||
const extractedData = ref(null)
|
||||
const isExtracting = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const saveMessage = ref(null)
|
||||
|
||||
// Check for pending file from store on mount
|
||||
onMounted(() => {
|
||||
if (pendingFile.value) {
|
||||
pdfFile.value = pendingFile.value
|
||||
pendingFile.value = null // Clear the store
|
||||
extractCurrentFile()
|
||||
}
|
||||
})
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pdfFile.value = file
|
||||
extractedData.value = null
|
||||
saveMessage.value = null
|
||||
extractCurrentFile()
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
isDragging.value = false
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pdfFile.value = file
|
||||
extractedData.value = null
|
||||
saveMessage.value = null
|
||||
extractCurrentFile()
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCurrentFile() {
|
||||
if (!pdfFile.value) return
|
||||
|
||||
isExtracting.value = true
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', pdfFile.value)
|
||||
|
||||
const response = await fetch('/api/extract', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
extractedData.value = data
|
||||
} catch (err) {
|
||||
console.error('Extraction error:', err)
|
||||
alert('Erreur: ' + err.message)
|
||||
} finally {
|
||||
isExtracting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToDatabase() {
|
||||
if (!extractedData.value) return
|
||||
|
||||
isSaving.value = true
|
||||
saveMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_file: extractedData.value.source_file,
|
||||
data: extractedData.value.data
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Erreur lors de la sauvegarde')
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Success - redirect to home
|
||||
router.push('/')
|
||||
} else {
|
||||
// Duplicate detected - stay on current view
|
||||
saveMessage.value = {
|
||||
type: 'duplicate',
|
||||
title: 'Document deja existant',
|
||||
details: `Reference: ${result.reference} | Date: ${result.date}`
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save error:', err)
|
||||
saveMessage.value = {
|
||||
type: 'error',
|
||||
title: 'Erreur lors de la sauvegarde',
|
||||
details: err.message
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
164
frontend/src/pages/HomePage.vue
Normal file
164
frontend/src/pages/HomePage.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-6 bg-gray-800">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
|
||||
<!-- Upload zone compacte -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-600 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
:class="{ 'border-blue-500 bg-blue-500/10': isDragging }"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
<div class="text-gray-400">
|
||||
<svg class="mx-auto h-10 w-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-sm">
|
||||
<span v-if="!isDragging">Glisser un PDF ici ou <span class="text-blue-400">cliquer pour importer</span></span>
|
||||
<span v-else class="text-blue-400 font-medium">Déposer le fichier ici</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats rapides -->
|
||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<div v-for="stat in statsDisplay" :key="stat.key" class="bg-gray-900 rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-white">{{ stats[stat.key] ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Derniers documents -->
|
||||
<div class="bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-700">
|
||||
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="p-8 text-center text-gray-500">
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
|
||||
Aucun document importe pour le moment.
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Reference</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-400 uppercase">Solde</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
<tr v-for="doc in documents" :key="doc.id" class="hover:bg-gray-800/50">
|
||||
<td class="px-4 py-3 text-sm text-white font-mono">{{ doc.reference }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-300">{{ formatDate(doc.date) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-300">
|
||||
<span class="text-gray-500">{{ doc.immeuble_code }}</span>
|
||||
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<span :class="doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'">
|
||||
{{ formatAmount(doc.solde_montant) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pendingFile } from '../store'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const stats = ref({})
|
||||
const documents = ref([])
|
||||
|
||||
const statsDisplay = [
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'immeubles', label: 'Immeubles' },
|
||||
{ key: 'lots', label: 'Lots' },
|
||||
{ key: 'locataires', label: 'Locataires' },
|
||||
{ key: 'revenus', label: 'Revenus' },
|
||||
{ key: 'depenses', label: 'Depenses' },
|
||||
]
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [statsRes, docsRes] = await Promise.all([
|
||||
fetch('/api/stats'),
|
||||
fetch('/api/documents?limit=10')
|
||||
])
|
||||
|
||||
if (statsRes.ok) {
|
||||
stats.value = await statsRes.json()
|
||||
}
|
||||
|
||||
if (docsRes.ok) {
|
||||
documents.value = await docsRes.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pendingFile.value = file
|
||||
router.push('/extract')
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
isDragging.value = false
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file && file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pendingFile.value = file
|
||||
router.push('/extract')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
23
frontend/src/router.js
Normal file
23
frontend/src/router.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomePage from './pages/HomePage.vue'
|
||||
import ExtractPage from './pages/ExtractPage.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomePage
|
||||
},
|
||||
{
|
||||
path: '/extract',
|
||||
name: 'extract',
|
||||
component: ExtractPage
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
4
frontend/src/store.js
Normal file
4
frontend/src/store.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Simple store for sharing state between components
|
||||
export const pendingFile = ref(null)
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.20.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"sqlalchemy>=2.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import __version__
|
||||
from ..extractor import extract_compte_rendu
|
||||
from ..database import init_db, get_session, DatabaseService
|
||||
from ..database.service import DuplicateDocumentError
|
||||
|
||||
app = FastAPI(
|
||||
title="Plesna Gérance API",
|
||||
@@ -20,6 +25,46 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
|
||||
# Initialize database on startup
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize database on application startup."""
|
||||
init_db()
|
||||
|
||||
|
||||
# Pydantic models for API
|
||||
class SaveRequest(BaseModel):
|
||||
"""Request body for saving extracted data."""
|
||||
|
||||
source_file: str | None = None
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class SaveResponse(BaseModel):
|
||||
"""Response for save operation."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
document_id: int | None = None
|
||||
reference: str | None = None
|
||||
date: str | None = None
|
||||
|
||||
|
||||
class DocumentSummary(BaseModel):
|
||||
"""Summary of a saved document."""
|
||||
|
||||
id: int
|
||||
reference: str
|
||||
date: str
|
||||
type: str | None
|
||||
source_file: str | None
|
||||
immeuble_code: str | None
|
||||
immeuble_adresse: str | None
|
||||
solde_montant: float | None
|
||||
solde_type: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
# Determine the frontend dist path
|
||||
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
|
||||
|
||||
@@ -105,6 +150,184 @@ async def extract_pdf(
|
||||
tmp_path.unlink()
|
||||
|
||||
|
||||
@app.post("/api/save", tags=["database"], response_model=SaveResponse)
|
||||
async def save_document(
|
||||
request: SaveRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SaveResponse:
|
||||
"""Sauvegarde les donnees extraites en base de donnees.
|
||||
|
||||
- **source_file**: Nom du fichier PDF original (optionnel)
|
||||
- **data**: Donnees extraites (format identique a la reponse de /api/extract)
|
||||
|
||||
Retourne un message de succes avec l'ID du document cree,
|
||||
ou une erreur si le document existe deja (doublon).
|
||||
"""
|
||||
try:
|
||||
db_service = DatabaseService(session)
|
||||
document = db_service.save_document(
|
||||
data=request.data, source_file=request.source_file
|
||||
)
|
||||
|
||||
return SaveResponse(
|
||||
success=True,
|
||||
message="Document sauvegarde avec succes",
|
||||
document_id=document.id,
|
||||
reference=document.reference,
|
||||
date=str(document.date),
|
||||
)
|
||||
|
||||
except DuplicateDocumentError as e:
|
||||
return SaveResponse(
|
||||
success=False,
|
||||
message=f"Document deja existant: reference={e.reference}, date={e.date}",
|
||||
reference=e.reference,
|
||||
date=str(e.date),
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/stats", tags=["database"])
|
||||
async def get_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retourne les statistiques globales de la base de donnees.
|
||||
|
||||
Compteurs pour chaque table principale.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
from ..database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
|
||||
return {
|
||||
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
|
||||
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
|
||||
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
|
||||
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
|
||||
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
|
||||
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/documents", tags=["database"])
|
||||
async def list_documents(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[DocumentSummary]:
|
||||
"""Liste les documents importes en base.
|
||||
|
||||
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
|
||||
- **offset**: Decalage pour la pagination (defaut: 0)
|
||||
|
||||
Retourne une liste de documents avec leurs metadonnees principales.
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
documents = db_service.list_documents(limit=limit, offset=offset)
|
||||
|
||||
return [
|
||||
DocumentSummary(
|
||||
id=doc.id,
|
||||
reference=doc.reference,
|
||||
date=str(doc.date),
|
||||
type=doc.type,
|
||||
source_file=doc.source_file,
|
||||
immeuble_code=doc.immeuble.code if doc.immeuble else None,
|
||||
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
|
||||
solde_montant=doc.solde_montant,
|
||||
solde_type=doc.solde_type,
|
||||
created_at=doc.created_at.isoformat() if doc.created_at else None,
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/documents/{document_id}", tags=["database"])
|
||||
async def get_document(
|
||||
document_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Recupere un document par son ID avec toutes ses donnees.
|
||||
|
||||
Retourne le document complet incluant le JSON original.
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
document = db_service.get_document_by_id(document_id)
|
||||
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document non trouve")
|
||||
|
||||
import json
|
||||
|
||||
return {
|
||||
"id": document.id,
|
||||
"reference": document.reference,
|
||||
"date": str(document.date),
|
||||
"type": document.type,
|
||||
"source_file": document.source_file,
|
||||
"immeuble": {
|
||||
"code": document.immeuble.code,
|
||||
"adresse": document.immeuble.adresse,
|
||||
"ville": document.immeuble.ville,
|
||||
"code_postal": document.immeuble.code_postal,
|
||||
}
|
||||
if document.immeuble
|
||||
else None,
|
||||
"solde": {
|
||||
"montant": document.solde_montant,
|
||||
"type": document.solde_type,
|
||||
"date_arrete": str(document.solde_date_arrete)
|
||||
if document.solde_date_arrete
|
||||
else None,
|
||||
},
|
||||
"editeur": {
|
||||
"nom": document.editeur_nom,
|
||||
"siret": document.editeur_siret,
|
||||
},
|
||||
"json_data": json.loads(document.json_data) if document.json_data else None,
|
||||
"created_at": document.created_at.isoformat() if document.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/check-duplicate", tags=["database"])
|
||||
async def check_duplicate(
|
||||
reference: str,
|
||||
date: str,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Verifie si un document existe deja en base.
|
||||
|
||||
- **reference**: Reference du document
|
||||
- **date**: Date du document (format YYYY-MM-DD)
|
||||
|
||||
Retourne {exists: true/false, document_id: ...}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
|
||||
)
|
||||
|
||||
db_service = DatabaseService(session)
|
||||
existing = db_service.check_duplicate(reference, doc_date)
|
||||
|
||||
return {
|
||||
"exists": existing is not None,
|
||||
"document_id": existing.id if existing else None,
|
||||
"reference": reference,
|
||||
"date": date,
|
||||
}
|
||||
|
||||
|
||||
# Mount static files for production (if dist exists)
|
||||
if FRONTEND_DIST.exists():
|
||||
# Serve static assets
|
||||
|
||||
@@ -153,5 +153,68 @@ def serve(host: str, port: int, reload: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--db-path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Chemin de la base de donnees (defaut: ~/.plesna_gerance/database.sqlite)",
|
||||
)
|
||||
def init_db(db_path: Path | None) -> None:
|
||||
"""Initialise la base de donnees SQLite.
|
||||
|
||||
Cree les tables necessaires pour stocker les documents,
|
||||
immeubles, lots, locataires, revenus et depenses.
|
||||
|
||||
Exemples:
|
||||
|
||||
plesna-gerance init-db
|
||||
|
||||
plesna-gerance init-db --db-path ./data/gerance.db
|
||||
"""
|
||||
from .database import init_db as do_init_db
|
||||
|
||||
result_path = do_init_db(db_path)
|
||||
click.echo(f"Base de donnees initialisee: {result_path}", err=True)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--db-path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Chemin de la base de donnees",
|
||||
)
|
||||
def db_info(db_path: Path | None) -> None:
|
||||
"""Affiche des informations sur la base de donnees.
|
||||
|
||||
Montre le nombre de documents, immeubles, lots, etc.
|
||||
"""
|
||||
from .database import init_db as do_init_db, get_session_factory
|
||||
from .database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
from sqlalchemy import func, select
|
||||
|
||||
# Initialize/connect to database
|
||||
result_path = do_init_db(db_path)
|
||||
SessionLocal = get_session_factory()
|
||||
|
||||
with SessionLocal() as session:
|
||||
# Count records in each table
|
||||
doc_count = session.execute(select(func.count(Document.id))).scalar()
|
||||
imm_count = session.execute(select(func.count(Immeuble.id))).scalar()
|
||||
lot_count = session.execute(select(func.count(Lot.id))).scalar()
|
||||
loc_count = session.execute(select(func.count(Locataire.id))).scalar()
|
||||
rev_count = session.execute(select(func.count(Revenu.id))).scalar()
|
||||
dep_count = session.execute(select(func.count(Depense.id))).scalar()
|
||||
|
||||
click.echo(f"Base de donnees: {result_path}")
|
||||
click.echo(f"")
|
||||
click.echo(f"Statistiques:")
|
||||
click.echo(f" - Documents: {doc_count}")
|
||||
click.echo(f" - Immeubles: {imm_count}")
|
||||
click.echo(f" - Lots: {lot_count}")
|
||||
click.echo(f" - Locataires: {loc_count}")
|
||||
click.echo(f" - Revenus: {rev_count}")
|
||||
click.echo(f" - Depenses: {dep_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
21
src/plesna_gerance/database/__init__.py
Normal file
21
src/plesna_gerance/database/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Database module for plesna-gerance."""
|
||||
|
||||
from .connection import get_engine, get_session, get_session_factory, init_db
|
||||
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
from .service import DatabaseService, DuplicateDocumentError
|
||||
|
||||
__all__ = [
|
||||
"get_engine",
|
||||
"get_session",
|
||||
"get_session_factory",
|
||||
"init_db",
|
||||
"Base",
|
||||
"Document",
|
||||
"Immeuble",
|
||||
"Lot",
|
||||
"Locataire",
|
||||
"Revenu",
|
||||
"Depense",
|
||||
"DatabaseService",
|
||||
"DuplicateDocumentError",
|
||||
]
|
||||
118
src/plesna_gerance/database/connection.py
Normal file
118
src/plesna_gerance/database/connection.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Database connection management for SQLite."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .models import Base
|
||||
|
||||
|
||||
# Default database path - relative to project root
|
||||
def _get_project_root() -> Path:
|
||||
"""Find project root by looking for pyproject.toml."""
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / "pyproject.toml").exists():
|
||||
return parent
|
||||
# Fallback to home directory if not found
|
||||
return Path.home() / ".plesna_gerance"
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = _get_project_root() / "data" / "database.sqlite"
|
||||
|
||||
# Global engine instance
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
"""Get database path from environment or default."""
|
||||
env_path = os.environ.get("PLESNA_DB_PATH")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
return DEFAULT_DB_PATH
|
||||
|
||||
|
||||
def get_engine(db_path: Path | None = None):
|
||||
"""Get or create SQLAlchemy engine (singleton pattern)."""
|
||||
global _engine
|
||||
|
||||
if _engine is None:
|
||||
if db_path is None:
|
||||
db_path = get_db_path()
|
||||
|
||||
# Create parent directory if needed
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create engine with SQLite
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
echo=False, # Set to True for SQL debugging
|
||||
connect_args={"check_same_thread": False}, # Required for FastAPI
|
||||
)
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory(engine=None):
|
||||
"""Get or create session factory."""
|
||||
global _SessionLocal
|
||||
|
||||
if _SessionLocal is None:
|
||||
if engine is None:
|
||||
engine = get_engine()
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""Dependency for FastAPI to get database session."""
|
||||
SessionLocal = get_session_factory()
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> Path:
|
||||
"""Initialize database: create all tables.
|
||||
|
||||
Returns the path to the database file.
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = get_db_path()
|
||||
|
||||
# Reset globals to use new path
|
||||
global _engine, _SessionLocal
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
# Create parent directory
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create engine and tables
|
||||
engine = create_engine(
|
||||
f"sqlite:///{db_path}", echo=False, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Update globals
|
||||
_engine = engine
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
return db_path
|
||||
|
||||
|
||||
def reset_connection():
|
||||
"""Reset global connection (useful for testing)."""
|
||||
global _engine, _SessionLocal
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
232
src/plesna_gerance/database/models.py
Normal file
232
src/plesna_gerance/database/models.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""SQLAlchemy models for plesna-gerance database."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Float,
|
||||
Date,
|
||||
DateTime,
|
||||
Text,
|
||||
ForeignKey,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Immeuble(Base):
|
||||
"""Table des immeubles gérés."""
|
||||
|
||||
__tablename__ = "immeubles"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
adresse = Column(String(255), nullable=True)
|
||||
ville = Column(String(100), nullable=True)
|
||||
code_postal = Column(String(10), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
lots = relationship("Lot", back_populates="immeuble", cascade="all, delete-orphan")
|
||||
documents = relationship("Document", back_populates="immeuble")
|
||||
depenses = relationship("Depense", back_populates="immeuble")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Immeuble(code={self.code}, adresse={self.adresse})>"
|
||||
|
||||
|
||||
class Lot(Base):
|
||||
"""Table des lots (appartements, locaux commerciaux, etc.)."""
|
||||
|
||||
__tablename__ = "lots"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
|
||||
numero = Column(String(10), nullable=False)
|
||||
type = Column(String(50), nullable=True) # "Loc. Commercial", "Appartement", etc.
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Contrainte unique: un numéro de lot par immeuble
|
||||
__table_args__ = (
|
||||
UniqueConstraint("immeuble_id", "numero", name="uq_lot_immeuble_numero"),
|
||||
Index("ix_lot_immeuble_numero", "immeuble_id", "numero"),
|
||||
)
|
||||
|
||||
# Relations
|
||||
immeuble = relationship("Immeuble", back_populates="lots")
|
||||
locataires = relationship(
|
||||
"Locataire", back_populates="lot", cascade="all, delete-orphan"
|
||||
)
|
||||
revenus = relationship("Revenu", back_populates="lot")
|
||||
depenses = relationship("Depense", back_populates="lot")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Lot(numero={self.numero}, type={self.type})>"
|
||||
|
||||
|
||||
class Locataire(Base):
|
||||
"""Table des locataires avec historique."""
|
||||
|
||||
__tablename__ = "locataires"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
|
||||
nom = Column(String(255), nullable=False)
|
||||
date_debut = Column(Date, nullable=True) # Date d'entrée dans le lot
|
||||
date_fin = Column(Date, nullable=True) # Date de sortie (NULL si actif)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Contrainte unique: un locataire par lot et période
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"lot_id", "nom", "date_debut", name="uq_locataire_lot_nom_debut"
|
||||
),
|
||||
Index("ix_locataire_nom", "nom"),
|
||||
)
|
||||
|
||||
# Relations
|
||||
lot = relationship("Lot", back_populates="locataires")
|
||||
revenus = relationship("Revenu", back_populates="locataire")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Locataire(nom={self.nom})>"
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""Table des documents PDF importés avec traçabilité."""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
reference = Column(String(50), nullable=False, index=True)
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
type = Column(String(100), nullable=True) # "COMPTE RENDU DE GESTION"
|
||||
source_file = Column(String(255), nullable=True) # Nom du fichier PDF original
|
||||
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
|
||||
|
||||
# JSON brut pour traçabilité complète
|
||||
json_data = Column(Text, nullable=False)
|
||||
|
||||
# Métadonnées éditeur
|
||||
editeur_nom = Column(String(255), nullable=True)
|
||||
editeur_siret = Column(String(20), nullable=True)
|
||||
|
||||
# Solde à la date du document
|
||||
solde_montant = Column(Float, nullable=True)
|
||||
solde_type = Column(String(20), nullable=True) # "crediteur" ou "debiteur"
|
||||
solde_date_arrete = Column(Date, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Clé unique: référence + date (évite les doublons)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("reference", "date", name="uq_document_reference_date"),
|
||||
)
|
||||
|
||||
# Relations
|
||||
immeuble = relationship("Immeuble", back_populates="documents")
|
||||
revenus = relationship(
|
||||
"Revenu", back_populates="document", cascade="all, delete-orphan"
|
||||
)
|
||||
depenses = relationship(
|
||||
"Depense", back_populates="document", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Document(reference={self.reference}, date={self.date})>"
|
||||
|
||||
|
||||
class Revenu(Base):
|
||||
"""Table des revenus locatifs (lignes détaillées)."""
|
||||
|
||||
__tablename__ = "revenus"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
|
||||
locataire_id = Column(Integer, ForeignKey("locataires.id"), nullable=False)
|
||||
|
||||
# Type de ligne: loyer, solde_anterieur, rappel_loyer, divers
|
||||
type_ligne = Column(String(50), nullable=False)
|
||||
|
||||
# Période concernée
|
||||
periode_debut = Column(Date, nullable=True)
|
||||
periode_fin = Column(Date, nullable=True)
|
||||
|
||||
# Montants détaillés
|
||||
loyers = Column(Float, default=0.0)
|
||||
taxes = Column(Float, default=0.0)
|
||||
provisions = Column(Float, default=0.0)
|
||||
divers_montant = Column(Float, default=0.0)
|
||||
divers_libelle = Column(String(255), nullable=True)
|
||||
total = Column(Float, default=0.0)
|
||||
regles = Column(Float, default=0.0) # Montant réglé
|
||||
impayes = Column(Float, default=0.0)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_revenu_document", "document_id"),
|
||||
Index("ix_revenu_lot", "lot_id"),
|
||||
Index("ix_revenu_periode", "periode_debut", "periode_fin"),
|
||||
)
|
||||
|
||||
# Relations
|
||||
document = relationship("Document", back_populates="revenus")
|
||||
lot = relationship("Lot", back_populates="revenus")
|
||||
locataire = relationship("Locataire", back_populates="revenus")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Revenu(type={self.type_ligne}, total={self.total})>"
|
||||
|
||||
|
||||
class Depense(Base):
|
||||
"""Table des dépenses/opérations."""
|
||||
|
||||
__tablename__ = "depenses"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
|
||||
lot_id = Column(
|
||||
Integer, ForeignKey("lots.id"), nullable=True
|
||||
) # NULL si dépense immeuble
|
||||
|
||||
# Catégorisation
|
||||
categorie = Column(String(100), nullable=True) # DEPENSES_LOCATIVES, etc.
|
||||
sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc.
|
||||
fournisseur = Column(String(255), nullable=True)
|
||||
description = Column(String(500), nullable=True)
|
||||
|
||||
# Montants
|
||||
debit = Column(Float, default=0.0)
|
||||
credit = Column(Float, default=0.0)
|
||||
tva = Column(Float, default=0.0)
|
||||
locatif = Column(Float, default=0.0) # Part locative
|
||||
deductible = Column(Float, default=0.0) # Part déductible
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_depense_document", "document_id"),
|
||||
Index("ix_depense_immeuble", "immeuble_id"),
|
||||
Index("ix_depense_categorie", "categorie"),
|
||||
)
|
||||
|
||||
# Relations
|
||||
document = relationship("Document", back_populates="depenses")
|
||||
immeuble = relationship("Immeuble", back_populates="depenses")
|
||||
lot = relationship("Lot", back_populates="depenses")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Depense(categorie={self.categorie}, debit={self.debit})>"
|
||||
314
src/plesna_gerance/database/service.py
Normal file
314
src/plesna_gerance/database/service.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Database service for saving and querying extracted data."""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
|
||||
|
||||
class DuplicateDocumentError(Exception):
|
||||
"""Raised when attempting to save a document that already exists."""
|
||||
|
||||
def __init__(self, reference: str, doc_date: date):
|
||||
self.reference = reference
|
||||
self.date = doc_date
|
||||
super().__init__(
|
||||
f"Document avec reference={reference} et date={doc_date} existe deja"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""Service for database operations."""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def check_duplicate(self, reference: str, doc_date: date) -> Document | None:
|
||||
"""Check if a document with this reference and date already exists."""
|
||||
stmt = select(Document).where(
|
||||
Document.reference == reference, Document.date == doc_date
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_or_create_immeuble(
|
||||
self, code: str, adresse: str = None, ville: str = None, code_postal: str = None
|
||||
) -> Immeuble:
|
||||
"""Get existing immeuble or create new one."""
|
||||
stmt = select(Immeuble).where(Immeuble.code == code)
|
||||
immeuble = self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if immeuble is None:
|
||||
immeuble = Immeuble(
|
||||
code=code, adresse=adresse, ville=ville, code_postal=code_postal
|
||||
)
|
||||
self.session.add(immeuble)
|
||||
self.session.flush() # Get the ID
|
||||
|
||||
return immeuble
|
||||
|
||||
def get_or_create_lot(
|
||||
self, immeuble_id: int, numero: str, lot_type: str = None
|
||||
) -> Lot:
|
||||
"""Get existing lot or create new one."""
|
||||
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
|
||||
lot = self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if lot is None:
|
||||
lot = Lot(immeuble_id=immeuble_id, numero=numero, type=lot_type)
|
||||
self.session.add(lot)
|
||||
self.session.flush()
|
||||
|
||||
return lot
|
||||
|
||||
def get_or_create_locataire(
|
||||
self, lot_id: int, nom: str, date_debut: date = None
|
||||
) -> Locataire:
|
||||
"""Get existing locataire or create new one."""
|
||||
stmt = select(Locataire).where(Locataire.lot_id == lot_id, Locataire.nom == nom)
|
||||
# If date_debut is provided, include it in the search
|
||||
if date_debut:
|
||||
stmt = stmt.where(Locataire.date_debut == date_debut)
|
||||
else:
|
||||
stmt = stmt.where(Locataire.date_debut.is_(None))
|
||||
|
||||
locataire = self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if locataire is None:
|
||||
locataire = Locataire(lot_id=lot_id, nom=nom, date_debut=date_debut)
|
||||
self.session.add(locataire)
|
||||
self.session.flush()
|
||||
|
||||
return locataire
|
||||
|
||||
def _parse_date(self, date_str: str | None) -> date | None:
|
||||
"""Parse ISO date string to date object."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def save_document(self, data: dict[str, Any], source_file: str = None) -> Document:
|
||||
"""Save extracted JSON data to database.
|
||||
|
||||
Args:
|
||||
data: The 'data' portion of the extracted JSON (contains metadata,
|
||||
situation_locataires, recapitulatif_operations)
|
||||
source_file: Original PDF filename
|
||||
|
||||
Returns:
|
||||
The created Document instance
|
||||
|
||||
Raises:
|
||||
DuplicateDocumentError: If document already exists
|
||||
"""
|
||||
metadata = data.get("metadata", {})
|
||||
doc_info = metadata.get("document", {})
|
||||
immeuble_info = metadata.get("immeuble", {})
|
||||
editeur_info = metadata.get("editeur", {})
|
||||
solde_info = metadata.get("solde", {})
|
||||
|
||||
# Extract key fields
|
||||
reference = doc_info.get("reference", "")
|
||||
doc_date = self._parse_date(doc_info.get("date"))
|
||||
|
||||
if not reference or not doc_date:
|
||||
raise ValueError("Document must have reference and date")
|
||||
|
||||
# Check for duplicates
|
||||
existing = self.check_duplicate(reference, doc_date)
|
||||
if existing:
|
||||
raise DuplicateDocumentError(reference, doc_date)
|
||||
|
||||
# Get or create immeuble
|
||||
immeuble = self.get_or_create_immeuble(
|
||||
code=immeuble_info.get("code", "UNKNOWN"),
|
||||
adresse=immeuble_info.get("adresse"),
|
||||
ville=immeuble_info.get("ville"),
|
||||
code_postal=immeuble_info.get("code_postal"),
|
||||
)
|
||||
|
||||
# Create document
|
||||
document = Document(
|
||||
reference=reference,
|
||||
date=doc_date,
|
||||
type=doc_info.get("type"),
|
||||
source_file=source_file,
|
||||
immeuble_id=immeuble.id,
|
||||
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
||||
editeur_nom=editeur_info.get("nom"),
|
||||
editeur_siret=editeur_info.get("siret"),
|
||||
solde_montant=solde_info.get("montant"),
|
||||
solde_type=solde_info.get("type"),
|
||||
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
||||
)
|
||||
self.session.add(document)
|
||||
self.session.flush()
|
||||
|
||||
# Process situation_locataires (revenus)
|
||||
for situation in data.get("situation_locataires", []):
|
||||
self._save_situation_locataire(document, immeuble, situation)
|
||||
|
||||
# Process recapitulatif_operations (depenses)
|
||||
for operation in data.get("recapitulatif_operations", []):
|
||||
self._save_operation(document, immeuble, operation)
|
||||
|
||||
self.session.commit()
|
||||
return document
|
||||
|
||||
def _save_situation_locataire(
|
||||
self, document: Document, immeuble: Immeuble, situation: dict
|
||||
) -> None:
|
||||
"""Save locataire situation (revenus lines)."""
|
||||
lot_info = situation.get("lot", {})
|
||||
locataire_info = situation.get("locataire", {})
|
||||
|
||||
# Get or create lot
|
||||
lot = self.get_or_create_lot(
|
||||
immeuble_id=immeuble.id,
|
||||
numero=lot_info.get("numero", "0000"),
|
||||
lot_type=lot_info.get("type"),
|
||||
)
|
||||
|
||||
# Get or create locataire
|
||||
locataire = self.get_or_create_locataire(
|
||||
lot_id=lot.id, nom=locataire_info.get("nom", "INCONNU")
|
||||
)
|
||||
|
||||
# Save each revenue line
|
||||
for ligne in situation.get("lignes", []):
|
||||
periode = ligne.get("periode", {})
|
||||
divers = ligne.get("divers", {})
|
||||
|
||||
revenu = Revenu(
|
||||
document_id=document.id,
|
||||
lot_id=lot.id,
|
||||
locataire_id=locataire.id,
|
||||
type_ligne=ligne.get("type", "loyer"),
|
||||
periode_debut=self._parse_date(periode.get("debut")),
|
||||
periode_fin=self._parse_date(periode.get("fin")),
|
||||
loyers=ligne.get("loyers", 0.0) or 0.0,
|
||||
taxes=ligne.get("taxes", 0.0) or 0.0,
|
||||
provisions=ligne.get("provisions", 0.0) or 0.0,
|
||||
divers_montant=divers.get("montant", 0.0) or 0.0 if divers else 0.0,
|
||||
divers_libelle=divers.get("libelle") if divers else None,
|
||||
total=ligne.get("total", 0.0) or 0.0,
|
||||
regles=ligne.get("regles", 0.0) or 0.0,
|
||||
impayes=ligne.get("impayes", 0.0) or 0.0,
|
||||
)
|
||||
self.session.add(revenu)
|
||||
|
||||
def _save_operation(
|
||||
self, document: Document, immeuble: Immeuble, operation: dict
|
||||
) -> None:
|
||||
"""Save operation (depense)."""
|
||||
montants = operation.get("montants", {})
|
||||
|
||||
# Check if operation is linked to a specific lot
|
||||
lot_id = None
|
||||
lot_numero = operation.get("lot_numero")
|
||||
if lot_numero:
|
||||
lot = self.get_or_create_lot(immeuble_id=immeuble.id, numero=lot_numero)
|
||||
lot_id = lot.id
|
||||
|
||||
depense = Depense(
|
||||
document_id=document.id,
|
||||
immeuble_id=immeuble.id,
|
||||
lot_id=lot_id, # Can be NULL for immeuble-level expenses
|
||||
categorie=operation.get("categorie"),
|
||||
sous_categorie=operation.get("sous_categorie"),
|
||||
fournisseur=operation.get("fournisseur"),
|
||||
description=operation.get("description"),
|
||||
debit=montants.get("debit", 0.0) or 0.0,
|
||||
credit=montants.get("credit", 0.0) or 0.0,
|
||||
tva=montants.get("tva", 0.0) or 0.0,
|
||||
locatif=montants.get("locatif", 0.0) or 0.0,
|
||||
deductible=montants.get("deductible", 0.0) or 0.0,
|
||||
)
|
||||
self.session.add(depense)
|
||||
|
||||
def list_documents(self, limit: int = 100, offset: int = 0) -> list[Document]:
|
||||
"""List all imported documents."""
|
||||
stmt = (
|
||||
select(Document)
|
||||
.order_by(Document.date.desc(), Document.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
def get_document_by_id(self, doc_id: int) -> Document | None:
|
||||
"""Get a document by ID."""
|
||||
return self.session.get(Document, doc_id)
|
||||
|
||||
def get_revenus_summary(
|
||||
self, immeuble_id: int = None, year: int = None
|
||||
) -> list[dict]:
|
||||
"""Get revenue summary grouped by period."""
|
||||
stmt = select(Revenu)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
if year:
|
||||
stmt = stmt.where(
|
||||
Revenu.periode_debut >= date(year, 1, 1),
|
||||
Revenu.periode_debut <= date(year, 12, 31),
|
||||
)
|
||||
|
||||
result = self.session.execute(stmt)
|
||||
revenus = result.scalars().all()
|
||||
|
||||
# Aggregate
|
||||
total_loyers = sum(r.loyers for r in revenus)
|
||||
total_regles = sum(r.regles for r in revenus)
|
||||
total_impayes = sum(r.impayes for r in revenus)
|
||||
|
||||
return {
|
||||
"total_loyers": total_loyers,
|
||||
"total_regles": total_regles,
|
||||
"total_impayes": total_impayes,
|
||||
"count": len(revenus),
|
||||
}
|
||||
|
||||
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
|
||||
"""Get expenses summary grouped by category."""
|
||||
stmt = select(Depense)
|
||||
|
||||
if immeuble_id:
|
||||
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
|
||||
if year:
|
||||
stmt = stmt.join(Document).where(
|
||||
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
|
||||
)
|
||||
|
||||
result = self.session.execute(stmt)
|
||||
depenses = result.scalars().all()
|
||||
|
||||
# Aggregate by category
|
||||
by_category = {}
|
||||
for d in depenses:
|
||||
cat = d.categorie or "AUTRE"
|
||||
if cat not in by_category:
|
||||
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
|
||||
by_category[cat]["debit"] += d.debit
|
||||
by_category[cat]["credit"] += d.credit
|
||||
by_category[cat]["count"] += 1
|
||||
|
||||
total_debit = sum(d.debit for d in depenses)
|
||||
total_credit = sum(d.credit for d in depenses)
|
||||
|
||||
return {
|
||||
"by_category": by_category,
|
||||
"total_debit": total_debit,
|
||||
"total_credit": total_credit,
|
||||
"count": len(depenses),
|
||||
}
|
||||
100
uv.lock
generated
100
uv.lock
generated
@@ -82,6 +82,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/6b/d4e73f5dfa888364bbf02efa85616c6714ae7c631c201349782e5b428925/greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082", size = 300740, upload-time = "2025-12-04T14:47:52.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -151,6 +200,7 @@ dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -159,6 +209,7 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0" },
|
||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" },
|
||||
]
|
||||
|
||||
@@ -377,6 +428,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.45"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/70/75b1387d72e2847220441166c5eb4e9846dd753895208c13e6d66523b2d9/sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85", size = 2154148, upload-time = "2025-12-10T20:03:21.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a4/7805e02323c49cb9d1ae5cd4913b28c97103079765f520043f914fca4cb3/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4", size = 3233051, upload-time = "2025-12-09T22:06:04.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ec/32ae09139f61bef3de3142e85c47abdee8db9a55af2bb438da54a4549263/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0", size = 3232781, upload-time = "2025-12-09T22:09:54.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/bd/bf7b869b6f5585eac34222e1cf4405f4ba8c3b85dd6b1af5d4ce8bca695f/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0", size = 3182096, upload-time = "2025-12-09T22:06:06.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/6a/c219720a241bb8f35c88815ccc27761f5af7fdef04b987b0e8a2c1a6dcaa/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826", size = 3205109, upload-time = "2025-12-09T22:09:55.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c4/6ccf31b2bc925d5d95fab403ffd50d20d7c82b858cf1a4855664ca054dce/sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a", size = 2114240, upload-time = "2025-12-09T21:29:54.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/29/a27a31fca07316def418db6f7c70ab14010506616a2decef1906050a0587/sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7", size = 2137615, upload-time = "2025-12-09T21:29:55.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.50.0"
|
||||
|
||||
Reference in New Issue
Block a user