outshine84 commited on
Commit
aede81b
·
1 Parent(s): ae65ff2

salvataggio immagini

Browse files
Files changed (5) hide show
  1. api.py +92 -0
  2. pwa-app/src/App.jsx +66 -0
  3. pwa-app/src/api.js +10 -0
  4. pwa-app/src/styles.css +23 -0
  5. 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