upload from file and url

This commit is contained in:
Nicola Malizia
2025-10-10 20:50:46 +02:00
parent 82a342ca25
commit a3a0bfc971
16 changed files with 466 additions and 35 deletions

View File

@@ -1,8 +1,13 @@
from sqlalchemy.orm import Session
import os
from sqlalchemy.orm import Session, joinedload
from . import models, schemas
def get_celebrity(db: Session, celebrity_id: int):
return db.query(models.Celebrity).filter(models.Celebrity.id == celebrity_id).first()
# Usiamo joinedload per caricare in anticipo le relazioni e evitare query N+1
return db.query(models.Celebrity).options(
joinedload(models.Celebrity.profile_image),
joinedload(models.Celebrity.images)
).filter(models.Celebrity.id == celebrity_id).first()
def get_celebrities(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.Celebrity).offset(skip).limit(limit).all()
@@ -35,3 +40,48 @@ def delete_celebrity(db: Session, celebrity_id: int):
db.delete(db_celebrity)
db.commit()
return db_celebrity
# --- Funzioni CRUD per le Immagini ---
def get_image(db: Session, image_id: int):
return db.query(models.Image).filter(models.Image.id == image_id).first()
def create_celebrity_image(db: Session, celebrity_id: int, file_path: str):
db_image = models.Image(celebrity_id=celebrity_id, file_path=file_path)
db.add(db_image)
db.commit()
db.refresh(db_image)
return db_image
def set_profile_image(db: Session, celebrity_id: int, image_id: int):
db_celebrity = get_celebrity(db, celebrity_id)
db_image = get_image(db, image_id)
if not db_celebrity or not db_image or db_image.celebrity_id != celebrity_id:
return None
db_celebrity.profile_image_id = image_id
db.commit()
db.refresh(db_celebrity)
return get_celebrity(db, celebrity_id) # Ritorna con le relazioni aggiornate
def delete_image(db: Session, image_id: int):
db_image = get_image(db, image_id)
if not db_image:
return None
db_celebrity = get_celebrity(db, db_image.celebrity_id)
if db_celebrity and db_celebrity.profile_image_id == image_id:
db_celebrity.profile_image_id = None
db.add(db_celebrity)
try:
file_on_disk = os.path.join("uploads", db_image.file_path)
if os.path.exists(file_on_disk):
os.remove(file_on_disk)
except OSError as e:
print(f"Error deleting file {file_on_disk}: {e}")
db.delete(db_image)
db.commit()
return db_image

View File

@@ -1,8 +1,13 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from .routers import celebrities # Importa il nuovo router
app = FastAPI()
# Monta la directory 'uploads' per servire i file statici (immagini caricate)
# Ora saranno accessibili tramite l'URL /api/uploads/<nome_file>
app.mount("/api/uploads", StaticFiles(directory="uploads"), name="uploads")
# Includi il router delle celebrities nell'app principale
app.include_router(celebrities.router)

View File

@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from sqlalchemy.orm import Session
from typing import List
import uuid
import shutil
from .. import crud, models, schemas
from ..database import get_db
@@ -13,7 +15,7 @@ router = APIRouter(
@router.post("/", response_model=schemas.Celebrity, status_code=201)
def create_celebrity(celebrity: schemas.CelebrityCreate, db: Session = Depends(get_db)):
# Qui potresti aggiungere un check per vedere se una celebrità con lo stesso nome esiste già
# Qui potresti aggiungere un check per vedere se una celebrità con lo stesso nome esiste già
return crud.create_celebrity(db=db, celebrity=celebrity)
@router.get("/", response_model=List[schemas.Celebrity])
@@ -41,3 +43,79 @@ def delete_celebrity(celebrity_id: int, db: Session = Depends(get_db)):
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
return db_celebrity
# --- NUOVI ENDPOINT PER LE IMMAGINI ---
@router.post("/{celebrity_id}/images", response_model=schemas.Image, status_code=201)
def upload_celebrity_image(celebrity_id: int, file: UploadFile = File(...), db: Session = Depends(get_db)):
db_celebrity = crud.get_celebrity(db, celebrity_id=celebrity_id)
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
file_extension = file.filename.split('.')[-1]
unique_filename = f"{uuid.uuid4()}.{file_extension}"
file_location = f"uploads/{unique_filename}"
with open(file_location, "wb+") as file_object:
shutil.copyfileobj(file.file, file_object)
return crud.create_celebrity_image(db=db, celebrity_id=celebrity_id, file_path=unique_filename)
@router.post("/{celebrity_id}/images/from-url", response_model=schemas.Image, status_code=201)
async def add_image_from_url(celebrity_id: int, request: schemas.ImageUrlRequest, db: Session = Depends(get_db)):
db_celebrity = crud.get_celebrity(db, celebrity_id=celebrity_id)
if not db_celebrity:
raise HTTPException(status_code=404, detail="Celebrity not found")
url = str(request.url)
try:
async with httpx.AsyncClient() as client:
# Eseguiamo prima una richiesta HEAD per controllare il tipo e la dimensione
head_response = await client.head(url, follow_redirects=True, timeout=10)
head_response.raise_for_status()
content_type = head_response.headers.get('content-type')
if not content_type or not content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="URL does not point to a valid image.")
# Limite di dimensione (es. 10MB)
content_length = int(head_response.headers.get('content-length', 0))
if content_length > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Image size exceeds 10MB limit.")
# Scarica l'immagine
get_response = await client.get(url, follow_redirects=True, timeout=30)
get_response.raise_for_status()
# Determina l'estensione del file
file_extension = content_type.split('/')[-1]
if file_extension == 'jpeg': file_extension = 'jpg'
unique_filename = f"{uuid.uuid4()}.{file_extension}"
file_location = f"uploads/{unique_filename}"
with open(file_location, "wb") as file_object:
file_object.write(get_response.content)
return crud.create_celebrity_image(db=db, celebrity_id=celebrity_id, file_path=unique_filename)
except httpx.RequestError as exc:
raise HTTPException(status_code=400, detail=f"Failed to fetch image from URL: {exc}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"An internal error occurred: {e}")
@router.put("/{celebrity_id}/profile-image", response_model=schemas.Celebrity)
def set_celebrity_profile_image(celebrity_id: int, request: schemas.SetProfileImageRequest, db: Session = Depends(get_db)):
updated_celebrity = crud.set_profile_image(db, celebrity_id=celebrity_id, image_id=request.image_id)
if updated_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity or Image not found, or image does not belong to celebrity.")
return updated_celebrity
# NOTA: Un approccio pi├╣ RESTful sarebbe /api/images/{image_id} in un router dedicato.
# Per semplicità, lo inseriamo qui.
@router.delete("/images/{image_id}", response_model=schemas.Image)
def delete_image(image_id: int, db: Session = Depends(get_db)):
db_image = crud.delete_image(db, image_id=image_id)
if db_image is None:
raise HTTPException(status_code=404, detail="Image not found")
return db_image

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, HttpUrl
from typing import Optional, List
from datetime import date, datetime
from .models import GenderType, ShoeSystemType, BraSystemType, SurgeryType
@@ -74,6 +74,12 @@ class Image(ImageBase):
uploaded_at: datetime
class Config: from_attributes = True
# --- Richiesta per impostare l'immagine del profilo ---
class SetProfileImageRequest(BaseModel):
image_id: int
class ImageUrlRequest(BaseModel):
url: HttpUrl # Pydantic valida automaticamente che sia un URL valido
# =============================================================================
# SCHEMI PER CELEBRITY
@@ -107,7 +113,7 @@ class CelebrityBase(BaseModel):
official_website: Optional[str] = None
profile_image_id: Optional[int] = None
# Schema per la creazione di una nuova celebrità (eredita da Base)
# Schema per la creazione di una nuova celebrità (eredita da Base)
class CelebrityCreate(CelebrityBase):
pass
@@ -146,11 +152,12 @@ class Celebrity(CelebrityBase):
updated_at: datetime
# Campi relazionali che verranno popolati automaticamente da SQLAlchemy
profile_image: Optional[Image] = None
images: List[Image] = []
tattoos: List[Tattoo] = []
aliases: List[CelebrityAlias] = []
# Per semplicità, possiamo usare qui gli schemi di base,
# Per semplicità, possiamo usare qui gli schemi di base,
# ma in un'app reale potresti volere schemi specifici per la "lettura".
professions: List[Profession] = []
studios: List[Studio] = []

View File

@@ -1,6 +1,7 @@
# FastAPI e server
fastapi
uvicorn[standard]
python-multipart
# Pydantic per la validazione e le impostazioni
pydantic
@@ -10,3 +11,6 @@ pydantic-settings
sqlalchemy
psycopg2-binary
alembic
# Per richieste HTTP (scaricare immagini da URL)
httpx

View File

@@ -18,12 +18,13 @@
text-align: center;
}
.profile-sidebar img {
.profile-sidebar .profile-main-image {
border-radius: var(--border-radius);
margin-bottom: 1rem;
width: 100%;
height: auto;
height: 400px; /* Altezza fissa per l'immagine profilo */
object-fit: cover;
background-color: var(--pico-muted-background-color); /* Sfondo per immagini trasparenti o in caricamento */
}
.profile-sidebar .editable-field-container {
@@ -79,3 +80,100 @@
.error-message {
color: var(--pico-color-red-500);
}
/* --- Stili per Galleria e Upload --- */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.gallery-item {
position: relative;
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--pico-card-border-color);
}
.gallery-item img {
display: block;
width: 100%;
height: 150px;
object-fit: cover;
}
.gallery-item-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 0.25rem;
display: flex;
justify-content: space-around;
transform: translateY(100%);
transition: transform 0.2s ease-in-out;
}
.gallery-item:hover .gallery-item-actions {
transform: translateY(0);
}
.gallery-item-actions button {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
margin: 0;
--pico-font-weight: 500;
}
.gallery-item-actions button:disabled {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-color: var(--pico-primary-background);
opacity: 1;
}
.upload-section {
margin-top: 1.5rem;
padding: 1rem;
border: 1px solid var(--pico-card-border-color);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: 1.5rem; /* Aumentato lo spazio */
}
.upload-section h4 {
margin: 0 0 0.5rem 0;
}
.upload-section .grid {
grid-template-columns: 1fr auto;
gap: 1rem;
margin: 0;
align-items: center; /* Allinea verticalmente */
}
.upload-section input {
margin: 0;
}
.upload-section button {
margin: 0;
}
.drop-zone {
padding: 2rem;
border: 2px dashed var(--pico-muted-border-color);
border-radius: var(--border-radius);
text-align: center;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
background-color: var(--pico-card-background-color);
}
.drop-zone:hover {
border-color: var(--pico-primary-hover-border);
}
.drop-zone.dragging-over {
background-color: var(--pico-primary-background);
border-color: var(--pico-primary);
color: var(--pico-primary-inverse);
}
.drop-zone p {
margin: 0;
color: var(--pico-secondary);
}
.drop-zone strong {
color: var(--pico-primary);
}

View File

@@ -1,8 +1,8 @@
// packages/frontend/src/components/CelebrityProfile.jsx
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { getCelebrityById, updateCelebrity, deleteCelebrity } from '../services/api';
import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImage, setProfileImage, deleteImage, addImageFromUrl } from '../services/api';
import EditableField from './EditableField';
import './CelebrityProfile.css';
@@ -13,22 +13,42 @@ function CelebrityProfile() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (id) {
setLoading(true);
// Stati per la gestione upload
const [selectedFile, setSelectedFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [isFetchingFromUrl, setIsFetchingFromUrl] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const fileInputRef = useRef(null);
const fetchProfile = useCallback(() => {
getCelebrityById(id)
.then((data) => {
if (data.birth_date) {
data.birth_date = data.birth_date.split('T')[0];
}
setCelebrity(data);
setError(null);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (id) {
setLoading(true);
fetchProfile();
} else {
navigate('/');
}
}, [id, navigate]);
}, [id, navigate, fetchProfile]);
// Effetto per caricare il file quando `selectedFile` cambia
useEffect(() => {
if (selectedFile) {
handleUpload();
}
}, [selectedFile]);
const handleFieldSave = async (fieldName, newValue) => {
let valueToSend = newValue === '' ? null : newValue;
@@ -39,8 +59,7 @@ function CelebrityProfile() {
try {
const updatedCelebrity = await updateCelebrity(id, payload);
setCelebrity((prev) => ({ ...prev, [fieldName]: newValue }));
console.log('Salvataggio riuscito:', updatedCelebrity);
setCelebrity(prev => ({ ...prev, ...updatedCelebrity })); // Aggiornamento pi├╣ robusto
} catch (err) {
console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err);
throw err;
@@ -48,10 +67,10 @@ function CelebrityProfile() {
};
const handleDelete = async () => {
if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione è irreversibile.`)) {
if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione è irreversibile.`)) {
try {
await deleteCelebrity(id);
alert(`${celebrity.name} è stato eliminato con successo.`);
alert(`${celebrity.name} è stato eliminato con successo.`);
navigate('/');
} catch (err) {
setError(`Errore durante l'eliminazione: ${err.message}`);
@@ -59,20 +78,93 @@ function CelebrityProfile() {
}
};
// --- Gestori per le Immagini ---
const handleFileChange = (e) => setSelectedFile(e.target.files[0]);
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
setError(null);
try {
await uploadImage(id, selectedFile);
setSelectedFile(null);
document.querySelector('input[type="file"]').value = ""; // Reset del campo file
fetchProfile(); // Ricarica i dati per vedere la nuova immagine
} catch (err) {
setError(`Upload fallito: ${err.message}`);
} finally {
setIsUploading(false);
}
};
const handleFetchFromUrl = async () => {
if (!imageUrl) return;
setIsFetchingFromUrl(true);
setError(null);
try {
await addImageFromUrl(id, imageUrl);
setImageUrl(''); // Pulisce l'input
fetchProfile();
} catch (err) {
setError(`Fetch da URL fallito: ${err.message}`);
} finally {
setIsFetchingFromUrl(false);
}
};
const handleSetProfileImage = async (imageId) => {
try {
const updatedCelebrity = await setProfileImage(id, imageId);
setCelebrity(updatedCelebrity); // Aggiorna lo stato con i dati freschi dal server
} catch (err) {
setError(`Errore nell'impostare l'immagine: ${err.message}`);
}
};
const handleDeleteImage = async (imageId) => {
if (window.confirm("Sei sicuro di voler eliminare questa immagine?")) {
try {
await deleteImage(imageId);
fetchProfile(); // Ricarica per aggiornare la galleria
} catch (err) {
setError(`Errore nell'eliminazione dell'immagine: ${err.message}`);
}
}
};
// --- Gestori per Drag & Drop ---
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(true); };
const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); };
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); }; // Necessario per permettere il drop
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
setSelectedFile(files[0]);
}
};
if (loading) return <article aria-busy="true">Caricamento profilo...</article>;
if (error && !celebrity) return <article><p className="error-message">Errore: {error}</p></article>;
if (!celebrity) return <p>Nessuna celebrità trovata.</p>;
if (!celebrity) return <p>Nessuna celebritá trovata.</p>;
const profileImageUrl = celebrity.profile_image
? `/api/uploads/${celebrity.profile_image.file_path}`
: 'https://via.placeholder.com/400x550.png?text=Nessuna+Immagine';
const genderOptions = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }, { value: 'other', label: 'Other' }];
const braSystemOptions = [{ value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }, { value: 'EU', label: 'EU' }, { value: 'FR', label: 'FR' }, { value: 'AU', label: 'AU' }, { value: 'IT', label: 'IT' }, { value: 'JP', label: 'JP' }];
const shoeSystemOptions = [{ value: 'EU', label: 'EU' }, { value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }];
const booleanOptions = [{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }];
const booleanOptions = [{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }];
return (
<div className="profile-container">
<aside className="profile-sidebar">
<figure>
<img src={`https://i.pravatar.cc/400?u=${id}`} alt={celebrity.name} />
<img src={profileImageUrl} alt={celebrity.name} className="profile-main-image" />
<figcaption>
<EditableField label="Nome" name="name" value={celebrity.name} onSave={handleFieldSave} />
</figcaption>
@@ -84,16 +176,68 @@ function CelebrityProfile() {
</aside>
<main className="profile-content">
{error && <p className="error-message">Errore: {error}</p>}
{error && <p className="error-message"><strong>Errore:</strong> {error}</p>}
<details open>
<summary><h3>Galleria e Upload</h3></summary>
<div className="gallery-grid">
{celebrity.images.map(img => (
<div key={img.id} className="gallery-item">
<img src={`/api/uploads/${img.file_path}`} alt={`Immagine di ${celebrity.name}`} />
<div className="gallery-item-actions">
<button onClick={() => handleSetProfileImage(img.id)} disabled={celebrity.profile_image_id === img.id} title="Imposta come immagine profilo">
Profilo
</button>
<button onClick={() => handleDeleteImage(img.id)} className="secondary" title="Elimina immagine">
X
</button>
</div>
</div>
))}
</div>
<div className="upload-section">
<div>
<h4>Carica da File</h4>
<div
className={`drop-zone ${isDraggingOver ? 'dragging-over' : ''}`}
onClick={() => fileInputRef.current.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
aria-busy={isUploading}
>
<input type="file" ref={fileInputRef} onChange={handleFileChange} hidden />
{isUploading ? <p>Caricamento...</p> : <p>Trascina un file qui, o <strong>clicca per selezionare</strong>.</p>}
</div>
</div>
<div>
<h4>Aggiungi da URL</h4>
<div className="grid">
<input
type="url"
placeholder="https://esempio.com/immagine.jpg"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
disabled={isFetchingFromUrl}
/>
<button onClick={handleFetchFromUrl} disabled={!imageUrl || isFetchingFromUrl} aria-busy={isFetchingFromUrl}>
{isFetchingFromUrl ? '...' : 'Aggiungi'}
</button>
</div>
</div>
</div>
</details>
<details>
<summary><h3>Dati Anagrafici</h3></summary>
<div className="profile-grid">
<EditableField label="Data di nascita" name="birth_date" value={celebrity.birth_date} type="date" onSave={handleFieldSave} />
<EditableField label="Luogo di nascita" name="birth_place" value={celebrity.birth_place} onSave={handleFieldSave} />
<EditableField label="Nazionalità" name="nationality" value={celebrity.nationality} onSave={handleFieldSave} />
<EditableField label="Nazionalità" name="nationality" value={celebrity.nationality} onSave={handleFieldSave} />
<EditableField label="Genere" name="gender" value={celebrity.gender} type="select" options={genderOptions} onSave={handleFieldSave} />
<EditableField label="Sessualità" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} />
<EditableField label="Sessualità" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} />
</div>
<EditableField label="Sito Ufficiale" name="official_website" value={celebrity.official_website} type="url" onSave={handleFieldSave} />
</details>

View File

@@ -3,7 +3,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
const handleResponse = async (response) => {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Si è verificato un errore');
throw new Error(errorData.detail || 'Si è verificato un errore');
}
return response.json();
};
@@ -46,3 +46,45 @@ export const deleteCelebrity = async (id) => {
}
return response.json(); // O un messaggio di successo
};
// --- NUOVE FUNZIONI PER LE IMMAGINI ---
export const uploadImage = async (celebrityId, file) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images`, {
method: 'POST',
body: formData, // Il browser imposta automaticamente l'header Content-Type corretto
});
return handleResponse(response);
};
export const setProfileImage = async (celebrityId, imageId) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/profile-image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
});
return handleResponse(response);
};
export const deleteImage = async (imageId) => {
const response = await fetch(`${API_BASE_URL}/celebrities/images/${imageId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Errore durante l'eliminazione dell'immagine");
}
return response.json();
};
export const addImageFromUrl = async (celebrityId, imageUrl) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images/from-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: imageUrl }),
});
return handleResponse(response);
};

View File

@@ -1,6 +1,9 @@
server {
listen 80;
# Aumenta la dimensione massima del corpo della richiesta a 10GBS
client_max_body_size 10g;
# Indirizza tutte le richieste che iniziano con /api al servizio backend
location /api {
proxy_pass http://backend:8000;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB