Spaces:
Sleeping
Sleeping
outshine84 commited on
Commit ·
aede81b
1
Parent(s): ae65ff2
salvataggio immagini
Browse files- api.py +92 -0
- pwa-app/src/App.jsx +66 -0
- pwa-app/src/api.js +10 -0
- pwa-app/src/styles.css +23 -0
- requirements.txt +1 -0
api.py
CHANGED
|
@@ -10,6 +10,8 @@ from logging.handlers import TimedRotatingFileHandler
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
|
|
|
|
|
|
|
| 13 |
import chromadb
|
| 14 |
import httpx
|
| 15 |
from dotenv import load_dotenv
|
|
@@ -41,6 +43,19 @@ def _default_plants_db_path() -> str:
|
|
| 41 |
|
| 42 |
|
| 43 |
PLANTS_SQLITE_PATH = os.getenv("PLANTS_SQLITE_PATH", _default_plants_db_path())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
GOOGLE_CLIENT_IDS = [
|
| 45 |
value.strip()
|
| 46 |
for value in os.getenv("GOOGLE_CLIENT_ID", "").split(",")
|
|
@@ -272,10 +287,17 @@ def ensure_user_plants_table(conn: sqlite3.Connection) -> None:
|
|
| 272 |
user_given_name TEXT NOT NULL,
|
| 273 |
user_id TEXT NOT NULL,
|
| 274 |
user_email TEXT,
|
|
|
|
| 275 |
created_at TEXT NOT NULL
|
| 276 |
)
|
| 277 |
"""
|
| 278 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
conn.commit()
|
| 280 |
|
| 281 |
|
|
@@ -285,6 +307,7 @@ def _user_plant_row_to_payload(row: sqlite3.Row) -> dict[str, Any]:
|
|
| 285 |
"plant_name": row["plant_name"],
|
| 286 |
"user_given_name": row["user_given_name"],
|
| 287 |
"user": row["user_email"] or row["user_id"],
|
|
|
|
| 288 |
"created_at_iso": row["created_at"],
|
| 289 |
"created_at": _format_datetime_display(row["created_at"]),
|
| 290 |
}
|
|
@@ -803,6 +826,75 @@ def update_user_plant_first_watering_date(
|
|
| 803 |
return JSONResponse(content={"updated": updated})
|
| 804 |
|
| 805 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
@app.get("/health")
|
| 807 |
def health():
|
| 808 |
status = get_search_backend_status()
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
+
import cloudinary
|
| 14 |
+
import cloudinary.uploader
|
| 15 |
import chromadb
|
| 16 |
import httpx
|
| 17 |
from dotenv import load_dotenv
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
PLANTS_SQLITE_PATH = os.getenv("PLANTS_SQLITE_PATH", _default_plants_db_path())
|
| 46 |
+
|
| 47 |
+
# Cloudinary configuration (optional - photo upload disabled if not set)
|
| 48 |
+
CLOUDINARY_CLOUD_NAME = os.getenv("CLOUDINARY_CLOUD_NAME", "")
|
| 49 |
+
CLOUDINARY_API_KEY = os.getenv("CLOUDINARY_API_KEY", "")
|
| 50 |
+
CLOUDINARY_API_SECRET = os.getenv("CLOUDINARY_API_SECRET", "")
|
| 51 |
+
if CLOUDINARY_CLOUD_NAME and CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET:
|
| 52 |
+
cloudinary.config(
|
| 53 |
+
cloud_name=CLOUDINARY_CLOUD_NAME,
|
| 54 |
+
api_key=CLOUDINARY_API_KEY,
|
| 55 |
+
api_secret=CLOUDINARY_API_SECRET,
|
| 56 |
+
secure=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
GOOGLE_CLIENT_IDS = [
|
| 60 |
value.strip()
|
| 61 |
for value in os.getenv("GOOGLE_CLIENT_ID", "").split(",")
|
|
|
|
| 287 |
user_given_name TEXT NOT NULL,
|
| 288 |
user_id TEXT NOT NULL,
|
| 289 |
user_email TEXT,
|
| 290 |
+
user_photo_url TEXT,
|
| 291 |
created_at TEXT NOT NULL
|
| 292 |
)
|
| 293 |
"""
|
| 294 |
)
|
| 295 |
+
# Add user_photo_url column to existing databases (migration)
|
| 296 |
+
try:
|
| 297 |
+
conn.execute("ALTER TABLE user_plants ADD COLUMN user_photo_url TEXT")
|
| 298 |
+
conn.commit()
|
| 299 |
+
except Exception:
|
| 300 |
+
pass # Column already exists
|
| 301 |
conn.commit()
|
| 302 |
|
| 303 |
|
|
|
|
| 307 |
"plant_name": row["plant_name"],
|
| 308 |
"user_given_name": row["user_given_name"],
|
| 309 |
"user": row["user_email"] or row["user_id"],
|
| 310 |
+
"user_photo_url": row["user_photo_url"] if "user_photo_url" in row.keys() else None,
|
| 311 |
"created_at_iso": row["created_at"],
|
| 312 |
"created_at": _format_datetime_display(row["created_at"]),
|
| 313 |
}
|
|
|
|
| 826 |
return JSONResponse(content={"updated": updated})
|
| 827 |
|
| 828 |
|
| 829 |
+
@app.post("/user/plants/{plant_id}/photo")
|
| 830 |
+
async def upload_user_plant_photo(
|
| 831 |
+
plant_id: int,
|
| 832 |
+
file: UploadFile = File(...),
|
| 833 |
+
authorization: str | None = Header(default=None),
|
| 834 |
+
):
|
| 835 |
+
"""Upload a user photo for a saved plant, store it on Cloudinary."""
|
| 836 |
+
user = _get_google_user_from_authorization(authorization)
|
| 837 |
+
if not user:
|
| 838 |
+
raise HTTPException(status_code=401, detail="Accedi con Google per caricare una foto.")
|
| 839 |
+
|
| 840 |
+
if not (CLOUDINARY_CLOUD_NAME and CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET):
|
| 841 |
+
raise HTTPException(status_code=503, detail="Servizio foto non configurato.")
|
| 842 |
+
|
| 843 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 844 |
+
raise HTTPException(status_code=400, detail="Il file caricato non è un'immagine valida.")
|
| 845 |
+
|
| 846 |
+
user_id = str(user.get("sub") or "").strip()
|
| 847 |
+
if not user_id:
|
| 848 |
+
raise HTTPException(status_code=401, detail="Utente non valido.")
|
| 849 |
+
|
| 850 |
+
# Verify the plant belongs to this user
|
| 851 |
+
with get_plants_db_connection() as conn:
|
| 852 |
+
ensure_user_plants_table(conn)
|
| 853 |
+
row = conn.execute(
|
| 854 |
+
"SELECT id FROM user_plants WHERE id = ? AND user_id = ? LIMIT 1",
|
| 855 |
+
(plant_id, user_id),
|
| 856 |
+
).fetchone()
|
| 857 |
+
if row is None:
|
| 858 |
+
raise HTTPException(status_code=404, detail="Pianta non trovata.")
|
| 859 |
+
|
| 860 |
+
suffix = os.path.splitext(file.filename or "")[1] or ".jpg"
|
| 861 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 862 |
+
tmp.write(await file.read())
|
| 863 |
+
tmp_path = tmp.name
|
| 864 |
+
|
| 865 |
+
try:
|
| 866 |
+
result = cloudinary.uploader.upload(
|
| 867 |
+
tmp_path,
|
| 868 |
+
folder="green-assistant/user-plants",
|
| 869 |
+
public_id=f"plant_{plant_id}_user_{user_id[:16]}",
|
| 870 |
+
overwrite=True,
|
| 871 |
+
resource_type="image",
|
| 872 |
+
transformation=[{"width": 1200, "crop": "limit", "quality": "auto:good"}],
|
| 873 |
+
)
|
| 874 |
+
photo_url = result.get("secure_url", "")
|
| 875 |
+
except Exception as e:
|
| 876 |
+
raise HTTPException(status_code=500, detail=f"Errore upload foto: {e}")
|
| 877 |
+
finally:
|
| 878 |
+
if os.path.exists(tmp_path):
|
| 879 |
+
os.remove(tmp_path)
|
| 880 |
+
|
| 881 |
+
# Save URL to DB
|
| 882 |
+
with get_plants_db_connection() as conn:
|
| 883 |
+
conn.execute(
|
| 884 |
+
"UPDATE user_plants SET user_photo_url = ? WHERE id = ? AND user_id = ?",
|
| 885 |
+
(photo_url, plant_id, user_id),
|
| 886 |
+
)
|
| 887 |
+
conn.commit()
|
| 888 |
+
updated_row = conn.execute(
|
| 889 |
+
"SELECT id, plant_name, user_given_name, user_id, user_email, user_photo_url, created_at "
|
| 890 |
+
"FROM user_plants WHERE id = ?",
|
| 891 |
+
(plant_id,),
|
| 892 |
+
).fetchone()
|
| 893 |
+
|
| 894 |
+
_log_api("/user/plants/{plant_id}/photo", "uploaded", {"plant_id": plant_id})
|
| 895 |
+
return JSONResponse(content={"updated": _user_plant_row_to_payload(updated_row)})
|
| 896 |
+
|
| 897 |
+
|
| 898 |
@app.get("/health")
|
| 899 |
def health():
|
| 900 |
status = get_search_backend_status()
|
pwa-app/src/App.jsx
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
| 14 |
getSpeciesPreviews,
|
| 15 |
searchPlantImage,
|
| 16 |
updateMyPlantFirstWaterDate,
|
|
|
|
| 17 |
verifyGoogleToken,
|
| 18 |
toAbsoluteImage,
|
| 19 |
toOptimizedImage
|
|
@@ -145,6 +146,9 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 145 |
const [isEditingFirstWaterDate, setIsEditingFirstWaterDate] = useState(false);
|
| 146 |
const [firstWaterDateInput, setFirstWaterDateInput] = useState("");
|
| 147 |
const [deletingPlantId, setDeletingPlantId] = useState(null);
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
const [busy, setBusy] = useState({
|
| 150 |
search: false,
|
|
@@ -778,10 +782,50 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 778 |
setError("");
|
| 779 |
}
|
| 780 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
const googleCalendarUrl = buildGoogleCalendarRecurringUrl();
|
| 782 |
|
| 783 |
return (
|
| 784 |
<main className="page">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
<section className="hero">
|
| 786 |
<div className="hero-inner">
|
| 787 |
<div className="hero-brand">
|
|
@@ -1125,8 +1169,23 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1125 |
{deletingPlantId === item.id ? "Elimino..." : "Elimina"}
|
| 1126 |
</button>
|
| 1127 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1128 |
<p>Specie: {item.plant_name}</p>
|
| 1129 |
<p>Inserita: {item.created_at}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1130 |
</article>
|
| 1131 |
))}
|
| 1132 |
</div>
|
|
@@ -1145,6 +1204,13 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1145 |
{selectedMyPlant?.user_given_name && (
|
| 1146 |
<p>Il tuo nome: {selectedMyPlant.user_given_name}</p>
|
| 1147 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
{myPlantCard.common_name && <p>Nome comune: {myPlantCard.common_name}</p>}
|
| 1149 |
|
| 1150 |
{!!myPlantProfileEntries.length && (
|
|
|
|
| 14 |
getSpeciesPreviews,
|
| 15 |
searchPlantImage,
|
| 16 |
updateMyPlantFirstWaterDate,
|
| 17 |
+
uploadMyPlantPhoto,
|
| 18 |
verifyGoogleToken,
|
| 19 |
toAbsoluteImage,
|
| 20 |
toOptimizedImage
|
|
|
|
| 146 |
const [isEditingFirstWaterDate, setIsEditingFirstWaterDate] = useState(false);
|
| 147 |
const [firstWaterDateInput, setFirstWaterDateInput] = useState("");
|
| 148 |
const [deletingPlantId, setDeletingPlantId] = useState(null);
|
| 149 |
+
const [uploadingPhotoId, setUploadingPhotoId] = useState(null);
|
| 150 |
+
const plantPhotoInputRef = useRef(null);
|
| 151 |
+
const plantPhotoTargetIdRef = useRef(null);
|
| 152 |
|
| 153 |
const [busy, setBusy] = useState({
|
| 154 |
search: false,
|
|
|
|
| 782 |
setError("");
|
| 783 |
}
|
| 784 |
|
| 785 |
+
function openPlantPhotoDialog(plantId) {
|
| 786 |
+
plantPhotoTargetIdRef.current = plantId;
|
| 787 |
+
plantPhotoInputRef.current?.click();
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
async function handlePlantPhotoFileChange(event) {
|
| 791 |
+
const photoFile = event.target.files?.[0] || null;
|
| 792 |
+
event.target.value = "";
|
| 793 |
+
if (!photoFile || !plantPhotoTargetIdRef.current) {
|
| 794 |
+
return;
|
| 795 |
+
}
|
| 796 |
+
const targetId = plantPhotoTargetIdRef.current;
|
| 797 |
+
plantPhotoTargetIdRef.current = null;
|
| 798 |
+
setUploadingPhotoId(targetId);
|
| 799 |
+
setError("");
|
| 800 |
+
try {
|
| 801 |
+
const data = await uploadMyPlantPhoto(targetId, photoFile);
|
| 802 |
+
const updated = data.updated;
|
| 803 |
+
if (updated) {
|
| 804 |
+
setMyPlants((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
| 805 |
+
if (selectedMyPlant?.id === updated.id) {
|
| 806 |
+
setSelectedMyPlant(updated);
|
| 807 |
+
}
|
| 808 |
+
}
|
| 809 |
+
} catch (err) {
|
| 810 |
+
setError(err.message || "Errore durante l'upload della foto.");
|
| 811 |
+
} finally {
|
| 812 |
+
setUploadingPhotoId(null);
|
| 813 |
+
}
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
const googleCalendarUrl = buildGoogleCalendarRecurringUrl();
|
| 817 |
|
| 818 |
return (
|
| 819 |
<main className="page">
|
| 820 |
+
{/* Hidden input for user plant photo upload */}
|
| 821 |
+
<input
|
| 822 |
+
ref={plantPhotoInputRef}
|
| 823 |
+
type="file"
|
| 824 |
+
accept="image/*"
|
| 825 |
+
capture="environment"
|
| 826 |
+
style={{ display: "none" }}
|
| 827 |
+
onChange={handlePlantPhotoFileChange}
|
| 828 |
+
/>
|
| 829 |
<section className="hero">
|
| 830 |
<div className="hero-inner">
|
| 831 |
<div className="hero-brand">
|
|
|
|
| 1169 |
{deletingPlantId === item.id ? "Elimino..." : "Elimina"}
|
| 1170 |
</button>
|
| 1171 |
</div>
|
| 1172 |
+
{item.user_photo_url && (
|
| 1173 |
+
<img
|
| 1174 |
+
src={item.user_photo_url}
|
| 1175 |
+
alt={`Foto di ${item.user_given_name}`}
|
| 1176 |
+
className="my-plant-item-photo"
|
| 1177 |
+
/>
|
| 1178 |
+
)}
|
| 1179 |
<p>Specie: {item.plant_name}</p>
|
| 1180 |
<p>Inserita: {item.created_at}</p>
|
| 1181 |
+
<button
|
| 1182 |
+
type="button"
|
| 1183 |
+
className="btn-secondary btn-small btn-upload-photo"
|
| 1184 |
+
onClick={(event) => { event.stopPropagation(); openPlantPhotoDialog(item.id); }}
|
| 1185 |
+
disabled={uploadingPhotoId === item.id}
|
| 1186 |
+
>
|
| 1187 |
+
{uploadingPhotoId === item.id ? "Carico..." : (item.user_photo_url ? "📷 Cambia foto" : "📷 Aggiungi foto")}
|
| 1188 |
+
</button>
|
| 1189 |
</article>
|
| 1190 |
))}
|
| 1191 |
</div>
|
|
|
|
| 1204 |
{selectedMyPlant?.user_given_name && (
|
| 1205 |
<p>Il tuo nome: {selectedMyPlant.user_given_name}</p>
|
| 1206 |
)}
|
| 1207 |
+
{selectedMyPlant?.user_photo_url && (
|
| 1208 |
+
<img
|
| 1209 |
+
src={selectedMyPlant.user_photo_url}
|
| 1210 |
+
alt={`La tua foto di ${selectedMyPlant.user_given_name}`}
|
| 1211 |
+
className="my-plant-detail-photo"
|
| 1212 |
+
/>
|
| 1213 |
+
)}
|
| 1214 |
{myPlantCard.common_name && <p>Nome comune: {myPlantCard.common_name}</p>}
|
| 1215 |
|
| 1216 |
{!!myPlantProfileEntries.length && (
|
pwa-app/src/api.js
CHANGED
|
@@ -133,6 +133,16 @@ export async function updateMyPlantFirstWaterDate(plantId, firstWateringDate) {
|
|
| 133 |
return parseResponse(response);
|
| 134 |
}
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
export function toAbsoluteImage(urlOrPath) {
|
| 137 |
if (!urlOrPath) {
|
| 138 |
return "";
|
|
|
|
| 133 |
return parseResponse(response);
|
| 134 |
}
|
| 135 |
|
| 136 |
+
export async function uploadMyPlantPhoto(plantId, file) {
|
| 137 |
+
const formData = new FormData();
|
| 138 |
+
formData.append("file", file);
|
| 139 |
+
const response = await apiFetch(`/user/plants/${plantId}/photo`, {
|
| 140 |
+
method: "POST",
|
| 141 |
+
body: formData,
|
| 142 |
+
});
|
| 143 |
+
return parseResponse(response);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
export function toAbsoluteImage(urlOrPath) {
|
| 147 |
if (!urlOrPath) {
|
| 148 |
return "";
|
pwa-app/src/styles.css
CHANGED
|
@@ -612,6 +612,29 @@ pre {
|
|
| 612 |
margin: 0.2rem 0 0;
|
| 613 |
}
|
| 614 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
.my-plant-item-head {
|
| 616 |
display: flex;
|
| 617 |
align-items: center;
|
|
|
|
| 612 |
margin: 0.2rem 0 0;
|
| 613 |
}
|
| 614 |
|
| 615 |
+
.my-plant-item-photo {
|
| 616 |
+
width: 100%;
|
| 617 |
+
max-height: 180px;
|
| 618 |
+
object-fit: cover;
|
| 619 |
+
border-radius: 6px;
|
| 620 |
+
margin: 0.5rem 0 0.25rem;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.btn-upload-photo {
|
| 624 |
+
display: block;
|
| 625 |
+
width: 100%;
|
| 626 |
+
margin-top: 0.4rem;
|
| 627 |
+
text-align: center;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.my-plant-detail-photo {
|
| 631 |
+
width: 100%;
|
| 632 |
+
max-height: 260px;
|
| 633 |
+
object-fit: cover;
|
| 634 |
+
border-radius: 8px;
|
| 635 |
+
margin: 0.6rem 0;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
.my-plant-item-head {
|
| 639 |
display: flex;
|
| 640 |
align-items: center;
|
requirements.txt
CHANGED
|
@@ -14,3 +14,4 @@ python-dotenv
|
|
| 14 |
chromadb>=1.5.0
|
| 15 |
sentence-transformers>=5.0.0
|
| 16 |
requests>=2.28.0
|
|
|
|
|
|
| 14 |
chromadb>=1.5.0
|
| 15 |
sentence-transformers>=5.0.0
|
| 16 |
requests>=2.28.0
|
| 17 |
+
cloudinary
|