Skip to content

photos

photos

Les piles de photos d'un projet Succès.

Une pile est une catégorie (« Python », « Ottawa ») ; une photo appartient à une pile, porte une légende et peut pointer vers une tâche du projet. Les octets vivent sur disque, jamais dans SQLite : une base de quelques Mo par photo aurait fini par ralentir chaque relève du téléphone, qui lit le même fichier.

Les photos ne passent PAS par le journal d'opérations. Le client Dart réimplémente l'enveloppe signée avec une liste de champs figée ; y glisser des images serait exactement le genre d'ajout que docs/succes-client-mobile.md interdit. Une photo est locale à ce Mac, comme le fichier qu'elle est.

Le serveur ne décode aucune image : il n'y a pas Pillow dans ce venv, et un décodeur d'images est une surface d'attaque qu'on n'ouvre pas pour une galerie personnelle. C'est le navigateur qui produit l'aperçu, la taille et la teinte ; le serveur vérifie la signature du fichier et le range.

Classes

SuccesPhotosStore

SuccesPhotosStore(db_path: str | Path | None = None)

Bases: SuccesFinancesStore

Piles et photos, rangées sous <données>/succes-photos/<projet>/.

Source code in src/diapason/succes/photos.py
def __init__(self, db_path: str | Path | None = None) -> None:
    super().__init__(db_path)
    self.photos_dir = self.db_path.parent / "succes-photos"
    with self._connect() as conn:
        conn.executescript(_PHOTOS_SCHEMA)
        self._ensure_photo_columns(conn)
        conn.commit()
Methods:
delete_photo_pile
delete_photo_pile(pile_id: str) -> int

Supprimer la pile ET ses photos, fichiers compris. Rend le nombre.

Source code in src/diapason/succes/photos.py
def delete_photo_pile(self, pile_id: str) -> int:
    """Supprimer la pile ET ses photos, fichiers compris. Rend le nombre."""
    stamp = now_ms()
    chemins: list[Path] = []
    with self._transaction() as conn:
        self._pile_row(conn, pile_id)
        rows = self._photos_de_pile(conn, pile_id)
        for row in rows:
            chemins.extend((Path(row["file_path"]), Path(row["thumb_path"])))
        conn.execute(
            "UPDATE succes_photos SET deleted_at_ms=?, updated_at_ms=? "
            "WHERE pile_id=? AND deleted_at_ms IS NULL",
            (stamp, stamp, pile_id),
        )
        conn.execute(
            "UPDATE succes_photo_piles SET deleted_at_ms=?, updated_at_ms=? "
            "WHERE id=?",
            (stamp, stamp, pile_id),
        )
    self._effacer(chemins)
    return len(rows)
reorder_photos
reorder_photos(
    pile_id: str, photo_ids: list[str]
) -> list[dict[str, Any]]

Ranger les photos de la pile dans l'ordre donné, complet ou non.

Les identifiants absents de la liste gardent leur ordre relatif et passent après. Un identifiant d'une autre pile est une erreur : on ne déplace pas une photo en la « rangeant ».

Source code in src/diapason/succes/photos.py
def reorder_photos(
    self, pile_id: str, photo_ids: list[str]
) -> list[dict[str, Any]]:
    """Ranger les photos de la pile dans l'ordre donné, complet ou non.

    Les identifiants absents de la liste gardent leur ordre relatif et
    passent après. Un identifiant d'une autre pile est une erreur : on
    ne déplace pas une photo en la « rangeant ».
    """
    stamp = now_ms()
    with self._transaction() as conn:
        self._pile_row(conn, pile_id)
        actuelles = [row["id"] for row in self._photos_de_pile(conn, pile_id)]
        connues = set(actuelles)
        vus: set[str] = set()
        ordre: list[str] = []
        for pid in photo_ids:
            pid = str(pid)
            if pid not in connues:
                raise SuccesError("Cette photo n'est pas dans la pile.")
            if pid in vus:
                continue
            vus.add(pid)
            ordre.append(pid)
        ordre.extend(pid for pid in actuelles if pid not in vus)
        for index, pid in enumerate(ordre):
            conn.execute(
                "UPDATE succes_photos SET position=?, updated_at_ms=? WHERE id=?",
                (index, stamp, pid),
            )
        conn.execute(
            "UPDATE succes_photo_piles SET updated_at_ms=? WHERE id=?",
            (stamp, pile_id),
        )
        return [
            self._photo_dict(row, thumb=False)
            for row in self._photos_de_pile(conn, pile_id)
        ]
search_photos
search_photos(
    project_id: str, query: str
) -> list[dict[str, Any]]

Les photos dont la légende, le nom ou le texte lu contient query.

Source code in src/diapason/succes/photos.py
def search_photos(self, project_id: str, query: str) -> list[dict[str, Any]]:
    """Les photos dont la légende, le nom ou le texte lu contient `query`."""
    mots = [m for m in " ".join(str(query or "").split()).lower().split(" ") if m]
    if not mots:
        return []
    with self._connect() as conn:
        self._exiger_projet(conn, project_id)
        # Chaque mot doit apparaître quelque part ; l'ordre n'importe pas.
        conditions = " AND ".join(
            "(instr(lower(caption), ?) > 0 OR instr(lower(file_name), ?) > 0 "
            "OR instr(lower(ocr_text), ?) > 0)"
            for _ in mots
        )
        params: list[Any] = []
        for mot in mots:
            params.extend((mot, mot, mot))
        rows = conn.execute(
            "SELECT ph.*, pi.name AS pile_name FROM succes_photos ph "
            "JOIN succes_photo_piles pi ON pi.id = ph.pile_id "
            "WHERE ph.project_id=? AND ph.deleted_at_ms IS NULL "
            f"AND pi.deleted_at_ms IS NULL AND {conditions} "
            "ORDER BY pi.order_index, ph.position, ph.created_at_ms DESC "
            f"LIMIT {RECHERCHE_MAX}",
            (project_id, *params),
        ).fetchall()
        return [
            {**self._photo_dict(row, thumb=True), "pileName": row["pile_name"]}
            for row in rows
        ]
write_export staticmethod
write_export(
    chemin: Any, data_base64: str
) -> dict[str, Any]

Écrire un export (PDF) là où l'utilisateur l'a demandé.

Le chemin vient du dialogue « Enregistrer sous » de l'app ; on refuse tout de même ce qui sort du dossier personnel, et tout ce qui n'est pas un .pdf : une route qui écrit n'importe où est une route qui écrira un jour dans ~/Library.

Source code in src/diapason/succes/photos.py
@staticmethod
def write_export(chemin: Any, data_base64: str) -> dict[str, Any]:
    """Écrire un export (PDF) là où l'utilisateur l'a demandé.

    Le chemin vient du dialogue « Enregistrer sous » de l'app ; on refuse
    tout de même ce qui sort du dossier personnel, et tout ce qui n'est
    pas un `.pdf` : une route qui écrit n'importe où est une route qui
    écrira un jour dans `~/Library`.
    """
    cible = Path(str(chemin or "")).expanduser()
    if not cible.is_absolute() or cible.suffix.lower() != ".pdf":
        raise SuccesError("L'export doit être un fichier .pdf à un chemin complet.")
    maison = Path.home().resolve()
    try:
        resolu = cible.parent.resolve(strict=True)
    except OSError as exc:
        raise SuccesError("Ce dossier n'existe pas.") from exc
    if maison != resolu and maison not in resolu.parents:
        raise SuccesError("L'export ne s'écrit que dans ton dossier personnel.")
    data = decoder_base64(
        data_base64, champ="Le fichier", maximum=EXPORT_OCTETS_MAX
    )
    if data[:5] != b"%PDF-":
        raise SuccesError("Ce fichier n'est pas un PDF.")
    (resolu / cible.name).write_bytes(data)
    return {"path": str(resolu / cible.name), "bytes": len(data)}

Functions:

mime_depuis_signature

mime_depuis_signature(data: bytes) -> str

Le type réel du fichier, lu dans ses premiers octets.

Le type annoncé par le client est une opinion ; la signature est un fait. Un .jpg qui commence par <html n'est pas une image et ne sera pas servi comme telle.

Source code in src/diapason/succes/photos.py
def mime_depuis_signature(data: bytes) -> str:
    """Le type réel du fichier, lu dans ses premiers octets.

    Le type annoncé par le client est une opinion ; la signature est un fait.
    Un `.jpg` qui commence par `<html` n'est pas une image et ne sera pas
    servi comme telle.
    """
    if data[:3] == b"\xff\xd8\xff":
        return "image/jpeg"
    if data[:8] == b"\x89PNG\r\n\x1a\n":
        return "image/png"
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return "image/webp"
    if data[:6] in (b"GIF87a", b"GIF89a"):
        return "image/gif"
    return ""

decoder_base64

decoder_base64(
    value: str, *, champ: str, maximum: int
) -> bytes

Décoder strictement : un caractère hors alphabet est une erreur.

Source code in src/diapason/succes/photos.py
def decoder_base64(value: str, *, champ: str, maximum: int) -> bytes:
    """Décoder strictement : un caractère hors alphabet est une erreur."""
    brut = value.strip()
    if brut.startswith("data:"):
        # Un client peut envoyer l'URL de données telle quelle ; on ne garde
        # que la charge utile, le type vient de la signature de toute façon.
        brut = brut.partition(",")[2]
    # 4 caractères base64 → 3 octets : borner AVANT de décoder, sinon un
    # corps de 200 Mo est décodé en entier pour être refusé ensuite.
    if len(brut) > maximum * 4 // 3 + 4:
        raise SuccesError(f"{champ} dépasse {maximum // (1024 * 1024) or 1} Mo.")
    try:
        data = base64.b64decode(brut, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise SuccesError(f"{champ} n'est pas un base64 valide.") from exc
    if not data:
        raise SuccesError(f"{champ} est vide.")
    if len(data) > maximum:
        raise SuccesError(f"{champ} dépasse {maximum // (1024 * 1024) or 1} Mo.")
    return data

valider_cadre

valider_cadre(value: Any) -> str

Le cadre de recadrage, en fractions de l'image : {x, y, w, h}.

Vide (None) = pas de recadrage. Un cadre plus petit que 2 % de l'image est une erreur de manipulation, pas une intention.

Source code in src/diapason/succes/photos.py
def valider_cadre(value: Any) -> str:
    """Le cadre de recadrage, en fractions de l'image : `{x, y, w, h}`.

    Vide (``None``) = pas de recadrage. Un cadre plus petit que 2 % de
    l'image est une erreur de manipulation, pas une intention.
    """
    if value is None or value == "":
        return ""
    if not isinstance(value, Mapping):
        raise SuccesError("Le cadre doit être un objet {x, y, w, h}.")
    x = _nombre_unitaire(value.get("x"), "x")
    y = _nombre_unitaire(value.get("y"), "y")
    w = _nombre_unitaire(value.get("w"), "w")
    h = _nombre_unitaire(value.get("h"), "h")
    if w < 0.02 or h < 0.02 or x + w > 1.00001 or y + h > 1.00001:
        raise SuccesError("Le cadre doit rester dans l'image et faire au moins 2 %.")
    return json.dumps({"x": x, "y": y, "w": w, "h": h})

valider_annotations

valider_annotations(value: Any) -> str

Le calque d'annotations : une liste de formes aux coordonnées en fractions.

Source code in src/diapason/succes/photos.py
def valider_annotations(value: Any) -> str:
    """Le calque d'annotations : une liste de formes aux coordonnées en fractions."""
    if value is None:
        return ""
    if not isinstance(value, list):
        raise SuccesError("Les annotations doivent être une liste.")
    if len(value) > ANNOTATIONS_MAX:
        raise SuccesError(f"Au plus {ANNOTATIONS_MAX} annotations par photo.")
    propres: list[dict[str, Any]] = []
    for forme in value:
        if not isinstance(forme, Mapping):
            raise SuccesError("Chaque annotation doit être un objet.")
        genre = str(forme.get("type", ""))
        if genre not in TYPES_ANNOTATION:
            raise SuccesError(f"Type d'annotation inconnu : {genre or '(vide)'}.")
        propre: dict[str, Any] = {
            "id": str(forme.get("id") or uuid.uuid4())[:40],
            "type": genre,
            "color": _teinte(forme.get("color")) or "#ff3b30",
        }
        points = forme.get("points")
        if not isinstance(points, list) or len(points) < 1 or len(points) > 2000:
            raise SuccesError("Une annotation a besoin de points.")
        propre["points"] = [
            [_nombre_unitaire(p[0], "x"), _nombre_unitaire(p[1], "y")]
            for p in points
            if isinstance(p, (list, tuple)) and len(p) == 2
        ]
        if not propre["points"]:
            raise SuccesError("Une annotation a besoin de points.")
        if genre == "text":
            propre["text"] = str(forme.get("text") or "")[:300]
        largeur = forme.get("width", 3)
        try:
            propre["width"] = max(1, min(24, int(largeur)))
        except (TypeError, ValueError):
            propre["width"] = 3
        propres.append(propre)
    if not propres:
        return ""
    texte = json.dumps(propres, ensure_ascii=False, separators=(",", ":"))
    if len(texte.encode()) > ANNOTATIONS_OCTETS_MAX:
        raise SuccesError("Le calque d'annotations est trop lourd.")
    return texte