Spaces:
Sleeping
Sleeping
outshine84 commited on
Commit ·
fe66d5d
1
Parent(s): d78ae20
galleria tue foto
Browse files- api.py +55 -14
- data/user_plants.db +2 -2
- pwa-app/src/App.jsx +42 -11
- pwa-app/src/styles.css +17 -2
api.py
CHANGED
|
@@ -9,6 +9,7 @@ from datetime import datetime
|
|
| 9 |
from logging.handlers import TimedRotatingFileHandler
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
|
|
|
| 12 |
|
| 13 |
import cloudinary
|
| 14 |
import cloudinary.uploader
|
|
@@ -391,16 +392,48 @@ def ensure_user_plants_table(conn: sqlite3.Connection) -> None:
|
|
| 391 |
conn.commit()
|
| 392 |
except Exception:
|
| 393 |
pass # Column already exists
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
conn.commit()
|
| 395 |
|
| 396 |
|
| 397 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
return {
|
| 399 |
-
"id":
|
| 400 |
"plant_name": row["plant_name"],
|
| 401 |
"user_given_name": row["user_given_name"],
|
| 402 |
"user": row["user_email"] or row["user_id"],
|
| 403 |
-
"user_photo_url":
|
|
|
|
| 404 |
"created_at_iso": row["created_at"],
|
| 405 |
"created_at": _format_datetime_display(row["created_at"]),
|
| 406 |
}
|
|
@@ -439,8 +472,7 @@ def create_user_plant(plant_name: str, user_given_name: str, user: dict[str, Any
|
|
| 439 |
),
|
| 440 |
(cursor.lastrowid,),
|
| 441 |
).fetchone()
|
| 442 |
-
|
| 443 |
-
return _user_plant_row_to_payload(row)
|
| 444 |
|
| 445 |
|
| 446 |
def list_user_plants(user: dict[str, Any]) -> list[dict[str, Any]]:
|
|
@@ -457,8 +489,7 @@ def list_user_plants(user: dict[str, Any]) -> list[dict[str, Any]]:
|
|
| 457 |
),
|
| 458 |
(user_id,),
|
| 459 |
).fetchall()
|
| 460 |
-
|
| 461 |
-
return [_user_plant_row_to_payload(row) for row in rows]
|
| 462 |
|
| 463 |
|
| 464 |
def delete_user_plant_by_id(user: dict[str, Any], plant_id: int) -> bool:
|
|
@@ -476,6 +507,11 @@ def delete_user_plant_by_id(user: dict[str, Any], plant_id: int) -> bool:
|
|
| 476 |
if existing is None:
|
| 477 |
return False
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
conn.execute(
|
| 480 |
"DELETE FROM user_plants WHERE id = ? AND user_id = ?",
|
| 481 |
(plant_id, user_id),
|
|
@@ -513,10 +549,9 @@ def update_user_plant_created_at_by_id(user: dict[str, Any], plant_id: int, crea
|
|
| 513 |
),
|
| 514 |
(plant_id, user_id),
|
| 515 |
).fetchone()
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
return
|
| 519 |
-
return _user_plant_row_to_payload(row)
|
| 520 |
|
| 521 |
|
| 522 |
def _build_profile_context(profile: dict[str, Any] | None) -> str:
|
|
@@ -959,8 +994,8 @@ async def upload_user_plant_photo(
|
|
| 959 |
result = cloudinary.uploader.upload(
|
| 960 |
tmp_path,
|
| 961 |
folder="green-assistant/user-plants",
|
| 962 |
-
public_id=f"plant_{plant_id}_user_{user_id[:
|
| 963 |
-
overwrite=
|
| 964 |
resource_type="image",
|
| 965 |
transformation=[{"width": 1200, "crop": "limit", "quality": "auto:good"}],
|
| 966 |
)
|
|
@@ -974,6 +1009,11 @@ async def upload_user_plant_photo(
|
|
| 974 |
# Save URL to DB
|
| 975 |
with get_user_plants_db_connection() as conn:
|
| 976 |
ensure_user_plants_table(conn)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
conn.execute(
|
| 978 |
"UPDATE user_plants SET user_photo_url = ? WHERE id = ? AND user_id = ?",
|
| 979 |
(photo_url, plant_id, user_id),
|
|
@@ -984,9 +1024,10 @@ async def upload_user_plant_photo(
|
|
| 984 |
"FROM user_plants WHERE id = ?",
|
| 985 |
(plant_id,),
|
| 986 |
).fetchone()
|
|
|
|
| 987 |
|
| 988 |
_log_api("/user/plants/{plant_id}/photo", "uploaded", {"plant_id": plant_id})
|
| 989 |
-
return JSONResponse(content={"updated":
|
| 990 |
|
| 991 |
|
| 992 |
@app.get("/health")
|
|
|
|
| 9 |
from logging.handlers import TimedRotatingFileHandler
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
+
from uuid import uuid4
|
| 13 |
|
| 14 |
import cloudinary
|
| 15 |
import cloudinary.uploader
|
|
|
|
| 392 |
conn.commit()
|
| 393 |
except Exception:
|
| 394 |
pass # Column already exists
|
| 395 |
+
|
| 396 |
+
conn.execute(
|
| 397 |
+
"""
|
| 398 |
+
CREATE TABLE IF NOT EXISTS user_plant_photos (
|
| 399 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 400 |
+
plant_id INTEGER NOT NULL,
|
| 401 |
+
photo_url TEXT NOT NULL,
|
| 402 |
+
created_at TEXT NOT NULL,
|
| 403 |
+
FOREIGN KEY (plant_id) REFERENCES user_plants(id) ON DELETE CASCADE
|
| 404 |
+
)
|
| 405 |
+
"""
|
| 406 |
+
)
|
| 407 |
+
conn.execute(
|
| 408 |
+
"CREATE INDEX IF NOT EXISTS idx_user_plant_photos_plant_id ON user_plant_photos(plant_id)"
|
| 409 |
+
)
|
| 410 |
conn.commit()
|
| 411 |
|
| 412 |
|
| 413 |
+
def _get_user_plant_photo_urls(conn: sqlite3.Connection, plant_id: int, fallback_url: str | None) -> list[str]:
|
| 414 |
+
rows = conn.execute(
|
| 415 |
+
"SELECT photo_url FROM user_plant_photos WHERE plant_id = ? ORDER BY id DESC",
|
| 416 |
+
(plant_id,),
|
| 417 |
+
).fetchall()
|
| 418 |
+
urls = [str(r["photo_url"] or "").strip() for r in rows if str(r["photo_url"] or "").strip()]
|
| 419 |
+
if urls:
|
| 420 |
+
return urls
|
| 421 |
+
|
| 422 |
+
fallback = str(fallback_url or "").strip()
|
| 423 |
+
return [fallback] if fallback else []
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _user_plant_row_to_payload(conn: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
|
| 427 |
+
plant_id = int(row["id"])
|
| 428 |
+
fallback_photo = row["user_photo_url"] if "user_photo_url" in row.keys() else None
|
| 429 |
+
photo_urls = _get_user_plant_photo_urls(conn, plant_id, fallback_photo)
|
| 430 |
return {
|
| 431 |
+
"id": plant_id,
|
| 432 |
"plant_name": row["plant_name"],
|
| 433 |
"user_given_name": row["user_given_name"],
|
| 434 |
"user": row["user_email"] or row["user_id"],
|
| 435 |
+
"user_photo_url": (photo_urls[0] if photo_urls else None),
|
| 436 |
+
"user_photos": photo_urls,
|
| 437 |
"created_at_iso": row["created_at"],
|
| 438 |
"created_at": _format_datetime_display(row["created_at"]),
|
| 439 |
}
|
|
|
|
| 472 |
),
|
| 473 |
(cursor.lastrowid,),
|
| 474 |
).fetchone()
|
| 475 |
+
return _user_plant_row_to_payload(conn, row)
|
|
|
|
| 476 |
|
| 477 |
|
| 478 |
def list_user_plants(user: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
|
| 489 |
),
|
| 490 |
(user_id,),
|
| 491 |
).fetchall()
|
| 492 |
+
return [_user_plant_row_to_payload(conn, row) for row in rows]
|
|
|
|
| 493 |
|
| 494 |
|
| 495 |
def delete_user_plant_by_id(user: dict[str, Any], plant_id: int) -> bool:
|
|
|
|
| 507 |
if existing is None:
|
| 508 |
return False
|
| 509 |
|
| 510 |
+
conn.execute(
|
| 511 |
+
"DELETE FROM user_plant_photos WHERE plant_id = ?",
|
| 512 |
+
(plant_id,),
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
conn.execute(
|
| 516 |
"DELETE FROM user_plants WHERE id = ? AND user_id = ?",
|
| 517 |
(plant_id, user_id),
|
|
|
|
| 549 |
),
|
| 550 |
(plant_id, user_id),
|
| 551 |
).fetchone()
|
| 552 |
+
if row is None:
|
| 553 |
+
return None
|
| 554 |
+
return _user_plant_row_to_payload(conn, row)
|
|
|
|
| 555 |
|
| 556 |
|
| 557 |
def _build_profile_context(profile: dict[str, Any] | None) -> str:
|
|
|
|
| 994 |
result = cloudinary.uploader.upload(
|
| 995 |
tmp_path,
|
| 996 |
folder="green-assistant/user-plants",
|
| 997 |
+
public_id=f"plant_{plant_id}_user_{user_id[:12]}_{uuid4().hex[:10]}",
|
| 998 |
+
overwrite=False,
|
| 999 |
resource_type="image",
|
| 1000 |
transformation=[{"width": 1200, "crop": "limit", "quality": "auto:good"}],
|
| 1001 |
)
|
|
|
|
| 1009 |
# Save URL to DB
|
| 1010 |
with get_user_plants_db_connection() as conn:
|
| 1011 |
ensure_user_plants_table(conn)
|
| 1012 |
+
created_at = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
| 1013 |
+
conn.execute(
|
| 1014 |
+
"INSERT INTO user_plant_photos (plant_id, photo_url, created_at) VALUES (?, ?, ?)",
|
| 1015 |
+
(plant_id, photo_url, created_at),
|
| 1016 |
+
)
|
| 1017 |
conn.execute(
|
| 1018 |
"UPDATE user_plants SET user_photo_url = ? WHERE id = ? AND user_id = ?",
|
| 1019 |
(photo_url, plant_id, user_id),
|
|
|
|
| 1024 |
"FROM user_plants WHERE id = ?",
|
| 1025 |
(plant_id,),
|
| 1026 |
).fetchone()
|
| 1027 |
+
updated_payload = _user_plant_row_to_payload(conn, updated_row)
|
| 1028 |
|
| 1029 |
_log_api("/user/plants/{plant_id}/photo", "uploaded", {"plant_id": plant_id})
|
| 1030 |
+
return JSONResponse(content={"updated": updated_payload})
|
| 1031 |
|
| 1032 |
|
| 1033 |
@app.get("/health")
|
data/user_plants.db
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:17c8484b3683c813b0fa16716623911dc62f4498e0cf5d73b40f04f6552fbd4b
|
| 3 |
+
size 24576
|
pwa-app/src/App.jsx
CHANGED
|
@@ -177,6 +177,19 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 177 |
}, [plantCard]);
|
| 178 |
|
| 179 |
const activeImage = galleryImages.length ? galleryImages[imageIndex % galleryImages.length] : "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
const wateringMonthCalendar = useMemo(() => {
|
| 182 |
if (!wateringSchedule.length) {
|
|
@@ -1144,7 +1157,15 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1144 |
|
| 1145 |
{!!myPlants.length && (
|
| 1146 |
<div className="my-plants-list">
|
| 1147 |
-
{myPlants.map((item) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
<article
|
| 1149 |
key={item.id}
|
| 1150 |
className={`my-plant-item ${selectedMyPlant?.id === item.id ? "active" : ""}`}
|
|
@@ -1169,9 +1190,9 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1169 |
{deletingPlantId === item.id ? "Elimino..." : "Elimina"}
|
| 1170 |
</button>
|
| 1171 |
</div>
|
| 1172 |
-
{
|
| 1173 |
<img
|
| 1174 |
-
src={
|
| 1175 |
alt={`Foto di ${item.user_given_name}`}
|
| 1176 |
className="my-plant-item-photo"
|
| 1177 |
/>
|
|
@@ -1184,10 +1205,11 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1184 |
onClick={(event) => { event.stopPropagation(); openPlantPhotoDialog(item.id); }}
|
| 1185 |
disabled={uploadingPhotoId === item.id}
|
| 1186 |
>
|
| 1187 |
-
{uploadingPhotoId === item.id ? "Carico..." : (
|
| 1188 |
</button>
|
| 1189 |
</article>
|
| 1190 |
-
|
|
|
|
| 1191 |
</div>
|
| 1192 |
)}
|
| 1193 |
</section>
|
|
@@ -1204,12 +1226,21 @@ export default function App({ googleClientIdConfigured = false }) {
|
|
| 1204 |
{selectedMyPlant?.user_given_name && (
|
| 1205 |
<p>Il tuo nome: {selectedMyPlant.user_given_name}</p>
|
| 1206 |
)}
|
| 1207 |
-
{
|
| 1208 |
-
<
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1213 |
)}
|
| 1214 |
{myPlantCard.common_name && <p>Nome comune: {myPlantCard.common_name}</p>}
|
| 1215 |
|
|
|
|
| 177 |
}, [plantCard]);
|
| 178 |
|
| 179 |
const activeImage = galleryImages.length ? galleryImages[imageIndex % galleryImages.length] : "";
|
| 180 |
+
const myPlantUserPhotos = useMemo(() => {
|
| 181 |
+
if (!selectedMyPlant) {
|
| 182 |
+
return [];
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
const raw = Array.isArray(selectedMyPlant.user_photos) && selectedMyPlant.user_photos.length
|
| 186 |
+
? selectedMyPlant.user_photos
|
| 187 |
+
: (selectedMyPlant.user_photo_url ? [selectedMyPlant.user_photo_url] : []);
|
| 188 |
+
|
| 189 |
+
return raw
|
| 190 |
+
.map((url) => toAbsoluteImage(String(url || "").trim()))
|
| 191 |
+
.filter(Boolean);
|
| 192 |
+
}, [selectedMyPlant]);
|
| 193 |
|
| 194 |
const wateringMonthCalendar = useMemo(() => {
|
| 195 |
if (!wateringSchedule.length) {
|
|
|
|
| 1157 |
|
| 1158 |
{!!myPlants.length && (
|
| 1159 |
<div className="my-plants-list">
|
| 1160 |
+
{myPlants.map((item) => {
|
| 1161 |
+
const cardPhoto = Array.isArray(item.user_photos) && item.user_photos.length
|
| 1162 |
+
? item.user_photos[0]
|
| 1163 |
+
: item.user_photo_url;
|
| 1164 |
+
const photoCount = Array.isArray(item.user_photos)
|
| 1165 |
+
? item.user_photos.length
|
| 1166 |
+
: (item.user_photo_url ? 1 : 0);
|
| 1167 |
+
|
| 1168 |
+
return (
|
| 1169 |
<article
|
| 1170 |
key={item.id}
|
| 1171 |
className={`my-plant-item ${selectedMyPlant?.id === item.id ? "active" : ""}`}
|
|
|
|
| 1190 |
{deletingPlantId === item.id ? "Elimino..." : "Elimina"}
|
| 1191 |
</button>
|
| 1192 |
</div>
|
| 1193 |
+
{cardPhoto && (
|
| 1194 |
<img
|
| 1195 |
+
src={toAbsoluteImage(cardPhoto)}
|
| 1196 |
alt={`Foto di ${item.user_given_name}`}
|
| 1197 |
className="my-plant-item-photo"
|
| 1198 |
/>
|
|
|
|
| 1205 |
onClick={(event) => { event.stopPropagation(); openPlantPhotoDialog(item.id); }}
|
| 1206 |
disabled={uploadingPhotoId === item.id}
|
| 1207 |
>
|
| 1208 |
+
{uploadingPhotoId === item.id ? "Carico..." : (photoCount > 0 ? `📷 Aggiungi altra foto (${photoCount})` : "📷 Aggiungi foto")}
|
| 1209 |
</button>
|
| 1210 |
</article>
|
| 1211 |
+
);
|
| 1212 |
+
})}
|
| 1213 |
</div>
|
| 1214 |
)}
|
| 1215 |
</section>
|
|
|
|
| 1226 |
{selectedMyPlant?.user_given_name && (
|
| 1227 |
<p>Il tuo nome: {selectedMyPlant.user_given_name}</p>
|
| 1228 |
)}
|
| 1229 |
+
{!!myPlantUserPhotos.length && (
|
| 1230 |
+
<div className="my-plant-user-gallery">
|
| 1231 |
+
<p className="my-plant-user-gallery-title">Le tue foto</p>
|
| 1232 |
+
<div className="my-plant-user-gallery-grid">
|
| 1233 |
+
{myPlantUserPhotos.map((photoUrl, idx) => (
|
| 1234 |
+
<img
|
| 1235 |
+
key={`${photoUrl}-${idx}`}
|
| 1236 |
+
src={photoUrl}
|
| 1237 |
+
alt={`Foto ${idx + 1} di ${selectedMyPlant?.user_given_name || "questa pianta"}`}
|
| 1238 |
+
className="my-plant-detail-photo"
|
| 1239 |
+
loading="lazy"
|
| 1240 |
+
/>
|
| 1241 |
+
))}
|
| 1242 |
+
</div>
|
| 1243 |
+
</div>
|
| 1244 |
)}
|
| 1245 |
{myPlantCard.common_name && <p>Nome comune: {myPlantCard.common_name}</p>}
|
| 1246 |
|
pwa-app/src/styles.css
CHANGED
|
@@ -629,10 +629,25 @@ pre {
|
|
| 629 |
|
| 630 |
.my-plant-detail-photo {
|
| 631 |
width: 100%;
|
| 632 |
-
|
| 633 |
object-fit: cover;
|
| 634 |
border-radius: 8px;
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
}
|
| 637 |
|
| 638 |
.my-plant-item-head {
|
|
|
|
| 629 |
|
| 630 |
.my-plant-detail-photo {
|
| 631 |
width: 100%;
|
| 632 |
+
height: 180px;
|
| 633 |
object-fit: cover;
|
| 634 |
border-radius: 8px;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.my-plant-user-gallery {
|
| 638 |
+
margin: 0.55rem 0 0.7rem;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.my-plant-user-gallery-title {
|
| 642 |
+
margin: 0 0 0.35rem;
|
| 643 |
+
font-weight: 700;
|
| 644 |
+
color: #285943;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.my-plant-user-gallery-grid {
|
| 648 |
+
display: grid;
|
| 649 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 650 |
+
gap: 0.45rem;
|
| 651 |
}
|
| 652 |
|
| 653 |
.my-plant-item-head {
|