| """Dataset stats, transactional purge, embed-all, and match-all (admin data management). |
| |
| Pictures link to dogs by convention (subject_type/subject_id, no FK), and cases/matches link to |
| dogs/users by FK, so a clean purge deletes children before parents in FK-dependency order, all |
| inside one transaction (everything or nothing). |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| from datetime import date |
|
|
| from sqlalchemy import delete, func, select |
| from sqlalchemy.orm import Session |
|
|
| from ..models import ( |
| BreedPrediction, |
| Case, |
| Dataset, |
| Embedding, |
| KnownDog, |
| Match, |
| Notification, |
| Picture, |
| UnknownDog, |
| User, |
| ) |
| from ..models.base import SubjectType |
| from ..storage import get_storage |
| from .images import embed_and_breed_picture |
| from .matching import run_matching_for_case |
|
|
| logger = logging.getLogger("pawtrace.datasets") |
|
|
|
|
| |
|
|
|
|
| def _known_ids(db: Session, dataset_id: int) -> list[int]: |
| return list( |
| db.execute(select(KnownDog.id).where(KnownDog.dataset_id == dataset_id)).scalars() |
| ) |
|
|
|
|
| def _unknown_ids(db: Session, dataset_id: int) -> list[int]: |
| return list( |
| db.execute(select(UnknownDog.id).where(UnknownDog.dataset_id == dataset_id)).scalars() |
| ) |
|
|
|
|
| def _user_ids(db: Session, dataset_id: int) -> list[int]: |
| return list(db.execute(select(User.id).where(User.dataset_id == dataset_id)).scalars()) |
|
|
|
|
| def _case_ids(db: Session, known_ids, unknown_ids, user_ids) -> list[int]: |
| conds = [] |
| if known_ids: |
| conds.append(Case.known_dog_id.in_(known_ids)) |
| if unknown_ids: |
| conds.append(Case.unknown_dog_id.in_(unknown_ids)) |
| if user_ids: |
| conds.append(Case.person_id.in_(user_ids)) |
| if not conds: |
| return [] |
| from sqlalchemy import or_ |
|
|
| return list(db.execute(select(Case.id).where(or_(*conds))).scalars()) |
|
|
|
|
| def _picture_ids(db: Session, known_ids, unknown_ids) -> list[int]: |
| from sqlalchemy import and_, or_ |
|
|
| conds = [] |
| if known_ids: |
| conds.append(and_(Picture.subject_type == SubjectType.known, Picture.subject_id.in_(known_ids))) |
| if unknown_ids: |
| conds.append(and_(Picture.subject_type == SubjectType.unknown, Picture.subject_id.in_(unknown_ids))) |
| if not conds: |
| return [] |
| return list(db.execute(select(Picture.id).where(or_(*conds))).scalars()) |
|
|
|
|
| |
|
|
|
|
| def dataset_stats(db: Session, dataset: Dataset) -> dict: |
| known_ids = _known_ids(db, dataset.id) |
| unknown_ids = _unknown_ids(db, dataset.id) |
| user_ids = _user_ids(db, dataset.id) |
| case_ids = _case_ids(db, known_ids, unknown_ids, user_ids) |
| pic_ids = _picture_ids(db, known_ids, unknown_ids) |
| match_count = 0 |
| if case_ids: |
| match_count = db.execute( |
| select(func.count()).select_from(Match).where(Match.case_id.in_(case_ids)) |
| ).scalar_one() |
| embedded = 0 |
| if pic_ids: |
| embedded = db.execute( |
| select(func.count(func.distinct(Embedding.picture_id))).where( |
| Embedding.picture_id.in_(pic_ids) |
| ) |
| ).scalar_one() |
| return { |
| "known_dog_count": len(known_ids), |
| "unknown_dog_count": len(unknown_ids), |
| "dog_count": len(known_ids) + len(unknown_ids), |
| "user_count": len(user_ids), |
| "case_count": len(case_ids), |
| "match_count": match_count, |
| "picture_count": len(pic_ids), |
| "embedded_picture_count": embedded, |
| |
| "error_count": 0, |
| } |
|
|
|
|
| def list_dogs(db: Session, dataset: Dataset, limit: int, offset: int) -> dict: |
| """Paginated list of the dataset's dogs (known + unknown) for the detail view.""" |
| rows: list[dict] = [] |
| for dog in db.execute( |
| select(KnownDog).where(KnownDog.dataset_id == dataset.id).order_by(KnownDog.id) |
| ).scalars(): |
| rows.append( |
| {"kind": "known", "id": dog.id, "name": dog.name, "status": dog.status.value, |
| "zip": dog.last_known_zip, "thumb_url": _thumb_url(db, SubjectType.known, dog.id)} |
| ) |
| for dog in db.execute( |
| select(UnknownDog).where(UnknownDog.dataset_id == dataset.id).order_by(UnknownDog.id) |
| ).scalars(): |
| rows.append( |
| {"kind": "unknown", "id": dog.id, "name": dog.description or "Found dog", |
| "status": dog.status.value, "zip": dog.current_zip, |
| "thumb_url": _thumb_url(db, SubjectType.unknown, dog.id)} |
| ) |
| total = len(rows) |
| return {"items": rows[offset : offset + limit], "total": total, "limit": limit, "offset": offset} |
|
|
|
|
| def _picture_count(db: Session, subject_type: SubjectType, subject_id: int) -> int: |
| return db.execute( |
| select(func.count()).select_from(Picture).where( |
| Picture.subject_type == subject_type, Picture.subject_id == subject_id |
| ) |
| ).scalar_one() |
|
|
|
|
| |
|
|
|
|
| def active_breed_model_in_db(db: Session) -> tuple[str, str] | None: |
| """The breed model to read predictions from: the most-used one, preferring a non-mock model. |
| |
| Avoids loading the HF model just to read its name; everything here is pure DB. |
| """ |
| rows = db.execute( |
| select(BreedPrediction.model_name, BreedPrediction.model_version, func.count()) |
| .group_by(BreedPrediction.model_name, BreedPrediction.model_version) |
| .order_by(func.count().desc()) |
| ).all() |
| if not rows: |
| return None |
| non_mock = [r for r in rows if not str(r[0]).startswith("mock")] |
| chosen = non_mock[0] if non_mock else rows[0] |
| return chosen[0], chosen[1] |
|
|
|
|
| def active_embed_model_in_db(db: Session) -> tuple[str, str] | None: |
| """The embedding model to compare against: the most-used one, preferring a non-mock model. |
| |
| Lets matching pick the right precomputed vectors WITHOUT loading the (HF) embedder — matching |
| only needs stored embeddings, so it must never depend on the model being loadable/online. |
| """ |
| rows = db.execute( |
| select(Embedding.model_name, Embedding.model_version, func.count()) |
| .group_by(Embedding.model_name, Embedding.model_version) |
| .order_by(func.count().desc()) |
| ).all() |
| if not rows: |
| return None |
| non_mock = [r for r in rows if not str(r[0]).startswith("mock")] |
| chosen = non_mock[0] if non_mock else rows[0] |
| return chosen[0], chosen[1] |
|
|
|
|
| def aggregated_breeds(db, subject_type, subject_id, model, limit=3) -> list[str]: |
| """A dog's estimated breeds aggregated across ALL its photos, blending frequency and confidence. |
| |
| Each breed is scored by the SUM of its confidence over every photo it appears in — so a breed |
| earns weight both from showing up often (frequency) and from the model being sure (confidence). |
| A breed seen weakly in many photos can beat one seen strongly in a single photo, and vice-versa. |
| Ties break by how many photos ranked it #1 (top-1 votes), then by best rank. For a single-photo |
| dog this reduces to plain rank order. Display only — matching's breed gate uses a separate union |
| set and is unaffected. |
| """ |
| if not model: |
| return [] |
| name, version = model |
| rows = db.execute( |
| select(BreedPrediction.label, BreedPrediction.rank, BreedPrediction.score) |
| .join(Picture, BreedPrediction.picture_id == Picture.id) |
| .where( |
| Picture.subject_type == subject_type, Picture.subject_id == subject_id, |
| BreedPrediction.model_name == name, BreedPrediction.model_version == version, |
| ) |
| ).all() |
| if not rows: |
| return [] |
| score_sum: dict[str, float] = {} |
| votes: dict[str, int] = {} |
| best_rank: dict[str, int] = {} |
| for label, rank, score in rows: |
| score_sum[label] = score_sum.get(label, 0.0) + score |
| if rank == 0: |
| votes[label] = votes.get(label, 0) + 1 |
| best_rank[label] = min(best_rank.get(label, rank), rank) |
| labels = list(score_sum) |
| labels.sort( |
| key=lambda l: (score_sum[l], votes.get(l, 0), -best_rank[l]), |
| reverse=True, |
| ) |
| return labels[:limit] |
|
|
|
|
|
|
|
|
| def _breed_match_ids(db, subject_type, breed, k, model) -> set[int]: |
| """Dog ids whose pictures predict ``breed`` within their top-``k`` (rank < k) for ``model``.""" |
| if not model or not breed: |
| return set() |
| name, version = model |
| rows = db.execute( |
| select(Picture.subject_id) |
| .join(BreedPrediction, BreedPrediction.picture_id == Picture.id) |
| .where( |
| Picture.subject_type == subject_type, |
| BreedPrediction.model_name == name, BreedPrediction.model_version == version, |
| BreedPrediction.label == breed, BreedPrediction.rank < k, |
| ) |
| .distinct() |
| ).scalars().all() |
| return set(rows) |
|
|
|
|
| def list_breeds(db: Session) -> list[dict]: |
| """Distinct predicted breed labels (active model) + how many predictions each has.""" |
| model = active_breed_model_in_db(db) |
| if not model: |
| return [] |
| name, version = model |
| rows = db.execute( |
| select(BreedPrediction.label, func.count()) |
| .where(BreedPrediction.model_name == name, BreedPrediction.model_version == version) |
| .group_by(BreedPrediction.label) |
| .order_by(BreedPrediction.label) |
| ).all() |
| return [{"label": r[0], "count": r[1]} for r in rows] |
|
|
|
|
| def _known_profile(db, dog, ds_names, breed_model=None) -> dict: |
| return { |
| "kind": "known", "id": dog.id, "name": dog.name, "breed": dog.breed, "color": dog.color, |
| "size": dog.size.value if dog.size else None, "status": dog.status.value, |
| "zip": dog.last_known_zip, "dataset_id": dog.dataset_id, |
| "dataset_name": ds_names.get(dog.dataset_id) if dog.dataset_id else None, |
| "picture_count": _picture_count(db, SubjectType.known, dog.id), |
| "thumb_url": _thumb_url(db, SubjectType.known, dog.id), |
| "predicted_breeds": aggregated_breeds(db, SubjectType.known, dog.id, breed_model), |
| } |
|
|
|
|
| def _unknown_profile(db, dog, ds_names, breed_model=None) -> dict: |
| return { |
| "kind": "unknown", "id": dog.id, "name": dog.description or "Found dog", |
| "breed": dog.est_breed, "color": dog.color, |
| "size": dog.size.value if dog.size else None, "status": dog.status.value, |
| "zip": dog.current_zip, "dataset_id": dog.dataset_id, |
| "dataset_name": ds_names.get(dog.dataset_id) if dog.dataset_id else None, |
| "picture_count": _picture_count(db, SubjectType.unknown, dog.id), |
| "thumb_url": _thumb_url(db, SubjectType.unknown, dog.id), |
| "predicted_breeds": aggregated_breeds(db, SubjectType.unknown, dog.id, breed_model), |
| } |
|
|
|
|
| def dog_profile(db: Session, subject_type: SubjectType, dog_id: int) -> dict | None: |
| """Single dog profile dict (same shape as the browse list), or None if missing.""" |
| ds_names = {row[0]: row[1] for row in db.execute(select(Dataset.id, Dataset.name)).all()} |
| breed_model = active_breed_model_in_db(db) |
| if subject_type == SubjectType.known: |
| dog = db.get(KnownDog, dog_id) |
| return _known_profile(db, dog, ds_names, breed_model) if dog else None |
| dog = db.get(UnknownDog, dog_id) |
| return _unknown_profile(db, dog, ds_names, breed_model) if dog else None |
|
|
|
|
| def list_all_dogs( |
| db: Session, kind: str, limit: int, offset: int, |
| breed: str | None = None, breed_k: int = 10, |
| sort: str = "newest", zip_prefix: str | None = None, |
| added_from: date | None = None, added_to: date | None = None, |
| ) -> dict: |
| """Paginated browse of every dog, optionally filtered by breed / ZIP / added-date and sorted. |
| |
| ``kind`` is ``known`` | ``unknown`` | ``all``. For ``all`` the two pools are merged and sorted |
| together so ordering is global. ``sort`` is ``newest`` (default) or ``oldest`` by added date. |
| ``zip_prefix`` matches the dog's ZIP by prefix (e.g. ``770`` = all Houston). ``added_from`` / |
| ``added_to`` bound the created-at date (inclusive). When ``breed`` is given, only dogs whose |
| pictures predict that breed within their top-``breed_k`` are included (breed_k=1 == top match). |
| |
| Tab counts (``known`` / ``unknown``) reflect every active filter, so they match what's listed. |
| """ |
| ds_names = {row[0]: row[1] for row in db.execute(select(Dataset.id, Dataset.name)).all()} |
| breed_model = active_breed_model_in_db(db) |
|
|
| known_match = unknown_match = None |
| if breed: |
| known_match = _breed_match_ids(db, SubjectType.known, breed, breed_k, breed_model) |
| unknown_match = _breed_match_ids(db, SubjectType.unknown, breed, breed_k, breed_model) |
|
|
| def _refs(model, subject_type, zip_col, match_set) -> list[tuple]: |
| q = select(model.id, model.created_at) |
| if match_set is not None: |
| q = q.where(model.id.in_(match_set)) |
| if zip_prefix: |
| q = q.where(zip_col.like(f"{zip_prefix}%")) |
| if added_from is not None: |
| q = q.where(func.date(model.created_at) >= added_from) |
| if added_to is not None: |
| q = q.where(func.date(model.created_at) <= added_to) |
| return [(ca, i, subject_type) for i, ca in db.execute(q).all()] |
|
|
| known_refs = _refs(KnownDog, SubjectType.known, KnownDog.last_known_zip, known_match) |
| unknown_refs = _refs(UnknownDog, SubjectType.unknown, UnknownDog.current_zip, unknown_match) |
| known_count, unknown_count = len(known_refs), len(unknown_refs) |
|
|
| if kind == "known": |
| refs = known_refs |
| elif kind == "unknown": |
| refs = unknown_refs |
| else: |
| refs = known_refs + unknown_refs |
| |
| refs.sort(key=lambda r: (r[0], r[1]), reverse=(sort != "oldest")) |
|
|
| total = len(refs) |
| items: list[dict] = [] |
| for _created_at, dog_id, subject_type in refs[offset : offset + limit]: |
| if subject_type == SubjectType.known: |
| dog = db.get(KnownDog, dog_id) |
| if dog: |
| items.append(_known_profile(db, dog, ds_names, breed_model)) |
| else: |
| dog = db.get(UnknownDog, dog_id) |
| if dog: |
| items.append(_unknown_profile(db, dog, ds_names, breed_model)) |
|
|
| return { |
| "items": items, "total": total, "limit": limit, "offset": offset, |
| "counts": {"known": known_count, "unknown": unknown_count}, |
| } |
|
|
|
|
| def _case_dog(db: Session, case: Case) -> dict | None: |
| """Compact view of the dog a case is about (for the admin all-cases list).""" |
| if case.known_dog_id: |
| d = db.get(KnownDog, case.known_dog_id) |
| if d: |
| return {"kind": "known", "id": d.id, "name": d.name, "status": d.status.value, |
| "zip": d.last_known_zip, "thumb_url": _thumb_url(db, SubjectType.known, d.id)} |
| if case.unknown_dog_id: |
| d = db.get(UnknownDog, case.unknown_dog_id) |
| if d: |
| return {"kind": "unknown", "id": d.id, "name": d.description or "Found dog", |
| "status": d.status.value, "zip": d.current_zip, |
| "thumb_url": _thumb_url(db, SubjectType.unknown, d.id)} |
| return None |
|
|
|
|
| def _case_row(db: Session, case: Case) -> dict: |
| person = None |
| if case.person_id: |
| u = db.get(User, case.person_id) |
| if u: |
| person = {"id": u.id, "name": u.name, "email": u.email} |
| match_count = db.execute( |
| select(func.count()).select_from(Match).where(Match.case_id == case.id) |
| ).scalar_one() |
| return { |
| "id": case.id, "type": case.type.value, "status": case.status.value, |
| "event_zip": case.event_zip, "event_date": case.event_date.isoformat(), |
| "created_at": case.created_at.isoformat(), |
| "dog": _case_dog(db, case), "person": person, |
| "finder_name": case.finder_name, "finder_email": case.finder_email, |
| "match_count": match_count, |
| } |
|
|
|
|
| def list_all_cases( |
| db: Session, *, case_type: str | None = None, status: str | None = None, |
| limit: int = 30, offset: int = 0, |
| ) -> dict: |
| """Paginated view of ALL cases (admin), newest first, optionally filtered by type/status.""" |
| from ..models.base import CaseStatus, CaseType |
|
|
| conds = [] |
| if case_type in ("lost", "found"): |
| conds.append(Case.type == CaseType(case_type)) |
| if status in ("open", "matched", "resolved", "closed"): |
| conds.append(Case.status == CaseStatus(status)) |
| total = db.execute(select(func.count()).select_from(Case).where(*conds)).scalar_one() |
| rows = db.execute( |
| select(Case).where(*conds).order_by(Case.created_at.desc()).limit(limit).offset(offset) |
| ).scalars().all() |
| lost = db.execute(select(func.count()).select_from(Case).where(Case.type == CaseType.lost)).scalar_one() |
| found = db.execute(select(func.count()).select_from(Case).where(Case.type == CaseType.found)).scalar_one() |
| return { |
| "items": [_case_row(db, c) for c in rows], "total": total, "limit": limit, "offset": offset, |
| "counts": {"lost": lost, "found": found}, |
| } |
|
|
|
|
| def list_owners(db: Session, *, q: str | None = None, limit: int = 30, offset: int = 0) -> dict: |
| """Paginated owners (newest first) + the dogs they own; optional name/email search (admin).""" |
| from sqlalchemy import or_ |
|
|
| from ..models.base import UserRole |
|
|
| conds = [User.role == UserRole.owner] |
| if q and q.strip(): |
| like = f"%{q.strip()}%" |
| conds.append(or_(User.name.ilike(like), User.email.ilike(like))) |
| total = db.execute(select(func.count()).select_from(User).where(*conds)).scalar_one() |
| owners = db.execute( |
| select(User).where(*conds) |
| .order_by(User.created_at.desc(), User.id.desc()) |
| .limit(limit).offset(offset) |
| ).scalars().all() |
| items = [] |
| for o in owners: |
| dogs = db.execute( |
| select(KnownDog).where(KnownDog.owner_id == o.id).order_by(KnownDog.id) |
| ).scalars().all() |
| items.append({ |
| "id": o.id, "name": o.name, "email": o.email, "zip": o.zip, |
| "dog_count": len(dogs), |
| "dogs": [{"id": d.id, "name": d.name, "status": d.status.value, |
| "zip": d.last_known_zip, "thumb_url": _thumb_url(db, SubjectType.known, d.id)} |
| for d in dogs], |
| }) |
| return {"items": items, "total": total, "limit": limit, "offset": offset} |
|
|
|
|
| def owner_detail(db: Session, user_id: int) -> dict | None: |
| """One owner's profile plus ALL their dogs (full AdminDog shape) and cases they reported. |
| |
| Returns None if the user doesn't exist or isn't an owner (the admin Owners view is owner-only). |
| """ |
| from ..models.base import UserRole |
|
|
| o = db.get(User, user_id) |
| if not o or o.role != UserRole.owner: |
| return None |
| ds_names = {row[0]: row[1] for row in db.execute(select(Dataset.id, Dataset.name)).all()} |
| breed_model = active_breed_model_in_db(db) |
| dogs = db.execute( |
| select(KnownDog) |
| .where(KnownDog.owner_id == o.id) |
| .order_by(KnownDog.created_at.desc(), KnownDog.id.desc()) |
| ).scalars().all() |
| cases = db.execute( |
| select(Case).where(Case.person_id == o.id).order_by(Case.created_at.desc()) |
| ).scalars().all() |
| return { |
| "owner": { |
| "id": o.id, "name": o.name, "email": o.email, "zip": o.zip, |
| "phone": o.phone, "dog_count": len(dogs), |
| }, |
| "dogs": [_known_profile(db, d, ds_names, breed_model) for d in dogs], |
| "cases": [_case_row(db, c) for c in cases], |
| } |
|
|
|
|
| def _thumb_url(db: Session, subject_type: SubjectType, subject_id: int) -> str | None: |
| pic = db.execute( |
| select(Picture) |
| .where(Picture.subject_type == subject_type, Picture.subject_id == subject_id) |
| .order_by(Picture.is_primary.desc(), Picture.id) |
| ).scalars().first() |
| if not pic: |
| return None |
| key = pic.thumb_path or pic.file_path |
| return f"/media/{key}" if key else None |
|
|
|
|
| def case_count(db: Session, dataset: Dataset) -> int: |
| known_ids = _known_ids(db, dataset.id) |
| unknown_ids = _unknown_ids(db, dataset.id) |
| user_ids = _user_ids(db, dataset.id) |
| return len(_case_ids(db, known_ids, unknown_ids, user_ids)) |
|
|
|
|
| |
|
|
|
|
| def purge_dataset(db: Session, dataset: Dataset) -> dict[str, int]: |
| """Delete every record linked to ``dataset`` then the dataset itself, in one transaction. |
| |
| Returns per-type deleted counts. Children are removed before parents so FK constraints hold. |
| """ |
| from sqlalchemy import or_ |
|
|
| known_ids = _known_ids(db, dataset.id) |
| unknown_ids = _unknown_ids(db, dataset.id) |
| user_ids = _user_ids(db, dataset.id) |
| case_ids = _case_ids(db, known_ids, unknown_ids, user_ids) |
| pic_ids = _picture_ids(db, known_ids, unknown_ids) |
|
|
| |
| |
| media_keys: list[str] = [] |
| if pic_ids: |
| for file_path, thumb_path in db.execute( |
| select(Picture.file_path, Picture.thumb_path).where(Picture.id.in_(pic_ids)) |
| ).all(): |
| if file_path: |
| media_keys.append(file_path) |
| if thumb_path: |
| media_keys.append(thumb_path) |
|
|
| |
| |
| |
| from sqlalchemy import and_ |
|
|
| match_conds = [] |
| if case_ids: |
| match_conds.append(Match.case_id.in_(case_ids)) |
| match_conds.append(Match.candidate_case_id.in_(case_ids)) |
| if known_ids: |
| match_conds.append( |
| and_(Match.candidate_type == SubjectType.known, Match.candidate_id.in_(known_ids)) |
| ) |
| if unknown_ids: |
| match_conds.append( |
| and_(Match.candidate_type == SubjectType.unknown, Match.candidate_id.in_(unknown_ids)) |
| ) |
| match_ids: list[int] = ( |
| list(db.execute(select(Match.id).where(or_(*match_conds))).scalars()) if match_conds else [] |
| ) |
|
|
| counts: dict[str, int] = {} |
|
|
| def _del(stmt) -> int: |
| return db.execute(stmt).rowcount or 0 |
|
|
| try: |
| |
| notif_conds = [] |
| if user_ids: |
| notif_conds.append(Notification.user_id.in_(user_ids)) |
| if case_ids: |
| notif_conds.append(Notification.case_id.in_(case_ids)) |
| if match_ids: |
| notif_conds.append(Notification.match_id.in_(match_ids)) |
| counts["notifications"] = _del(delete(Notification).where(or_(*notif_conds))) if notif_conds else 0 |
|
|
| |
| if pic_ids: |
| counts["breed_predictions"] = _del( |
| delete(BreedPrediction).where(BreedPrediction.picture_id.in_(pic_ids)) |
| ) |
| counts["embeddings"] = _del(delete(Embedding).where(Embedding.picture_id.in_(pic_ids))) |
| else: |
| counts["breed_predictions"] = 0 |
| counts["embeddings"] = 0 |
|
|
| |
| counts["matches"] = _del(delete(Match).where(Match.id.in_(match_ids))) if match_ids else 0 |
| |
| if user_ids: |
| db.execute( |
| Match.__table__.update() |
| .where(Match.reviewed_by.in_(user_ids)) |
| .values(reviewed_by=None) |
| ) |
|
|
| counts["pictures"] = _del(delete(Picture).where(Picture.id.in_(pic_ids))) if pic_ids else 0 |
| counts["cases"] = _del(delete(Case).where(Case.id.in_(case_ids))) if case_ids else 0 |
| counts["unknown_dogs"] = ( |
| _del(delete(UnknownDog).where(UnknownDog.id.in_(unknown_ids))) if unknown_ids else 0 |
| ) |
| counts["known_dogs"] = ( |
| _del(delete(KnownDog).where(KnownDog.id.in_(known_ids))) if known_ids else 0 |
| ) |
| |
| |
| |
| |
| retained: set[int] = set() |
| if user_ids: |
| owners = db.execute( |
| select(KnownDog.owner_id).where(KnownDog.owner_id.in_(user_ids)).distinct() |
| ).scalars() |
| persons = db.execute( |
| select(Case.person_id).where(Case.person_id.in_(user_ids)).distinct() |
| ).scalars() |
| retained = {u for u in owners} | {u for u in persons if u is not None} |
| safe_users = [u for u in user_ids if u not in retained] |
| counts["users"] = _del(delete(User).where(User.id.in_(safe_users))) if safe_users else 0 |
| if retained: |
| db.execute( |
| User.__table__.update().where(User.id.in_(retained)).values(dataset_id=None) |
| ) |
| counts["users_detached"] = len(retained) |
| counts["datasets"] = _del(delete(Dataset).where(Dataset.id == dataset.id)) |
| db.commit() |
| except Exception: |
| db.rollback() |
| raise |
|
|
| |
| |
| counts["media_files"] = _delete_media_keys(media_keys) |
| return counts |
|
|
|
|
| def _delete_media_keys(keys: list[str]) -> int: |
| |
| unique = list(dict.fromkeys(keys)) |
| if not unique: |
| return 0 |
| storage = get_storage() |
| removed = 0 |
| for key in unique: |
| try: |
| storage.delete(key) |
| removed += 1 |
| except Exception: |
| logger.warning("Could not delete media %s during purge", key, exc_info=True) |
| return removed |
|
|
|
|
| |
|
|
|
|
| def delete_case(db: Session, case_id: int) -> dict[str, int]: |
| """Delete one case + its matches + related notifications (leaves the dog itself).""" |
| from sqlalchemy import or_ |
|
|
| match_ids = list( |
| db.execute( |
| select(Match.id).where(or_(Match.case_id == case_id, Match.candidate_case_id == case_id)) |
| ).scalars() |
| ) |
| counts: dict[str, int] = {} |
| ncond = [Notification.case_id == case_id] |
| if match_ids: |
| ncond.append(Notification.match_id.in_(match_ids)) |
| counts["notifications"] = db.execute(delete(Notification).where(or_(*ncond))).rowcount or 0 |
| counts["matches"] = ( |
| db.execute(delete(Match).where(Match.id.in_(match_ids))).rowcount or 0 if match_ids else 0 |
| ) |
| counts["cases"] = db.execute(delete(Case).where(Case.id == case_id)).rowcount or 0 |
| db.commit() |
| return counts |
|
|
|
|
| def delete_dog(db: Session, subject_type: SubjectType, dog_id: int) -> dict[str, int]: |
| """Delete one dog and everything attached: pictures (+ media), embeddings, breed predictions, |
| its cases, any matches referencing it, and related notifications. Owners are left intact.""" |
| from sqlalchemy import and_, or_ |
|
|
| pic_rows = db.execute( |
| select(Picture.id, Picture.file_path, Picture.thumb_path).where( |
| Picture.subject_type == subject_type, Picture.subject_id == dog_id |
| ) |
| ).all() |
| pic_ids = [r[0] for r in pic_rows] |
| media_keys = [k for r in pic_rows for k in (r[1], r[2]) if k] |
|
|
| if subject_type == SubjectType.known: |
| case_ids = list(db.execute(select(Case.id).where(Case.known_dog_id == dog_id)).scalars()) |
| else: |
| case_ids = list(db.execute(select(Case.id).where(Case.unknown_dog_id == dog_id)).scalars()) |
|
|
| mconds = [and_(Match.candidate_type == subject_type, Match.candidate_id == dog_id)] |
| if case_ids: |
| mconds += [Match.case_id.in_(case_ids), Match.candidate_case_id.in_(case_ids)] |
| match_ids = list(db.execute(select(Match.id).where(or_(*mconds))).scalars()) |
|
|
| counts: dict[str, int] = {} |
| ncond = [] |
| if case_ids: |
| ncond.append(Notification.case_id.in_(case_ids)) |
| if match_ids: |
| ncond.append(Notification.match_id.in_(match_ids)) |
| counts["notifications"] = db.execute(delete(Notification).where(or_(*ncond))).rowcount or 0 if ncond else 0 |
| if pic_ids: |
| counts["breed_predictions"] = db.execute( |
| delete(BreedPrediction).where(BreedPrediction.picture_id.in_(pic_ids)) |
| ).rowcount or 0 |
| counts["embeddings"] = db.execute( |
| delete(Embedding).where(Embedding.picture_id.in_(pic_ids)) |
| ).rowcount or 0 |
| counts["matches"] = ( |
| db.execute(delete(Match).where(Match.id.in_(match_ids))).rowcount or 0 if match_ids else 0 |
| ) |
| counts["pictures"] = ( |
| db.execute(delete(Picture).where(Picture.id.in_(pic_ids))).rowcount or 0 if pic_ids else 0 |
| ) |
| counts["cases"] = ( |
| db.execute(delete(Case).where(Case.id.in_(case_ids))).rowcount or 0 if case_ids else 0 |
| ) |
| subj = KnownDog if subject_type == SubjectType.known else UnknownDog |
| counts["dogs"] = db.execute(delete(subj).where(subj.id == dog_id)).rowcount or 0 |
| db.commit() |
| counts["media_files"] = _delete_media_keys(media_keys) |
| return counts |
|
|
|
|
| def delete_person(db: Session, user_id: int) -> dict[str, int]: |
| """Delete a person (owner) and everything of theirs: the dogs they own (+ pictures/media/ |
| embeddings/breeds), the cases they filed, and matches on those. Admins are refused elsewhere.""" |
| from sqlalchemy import and_, or_ |
|
|
| known_ids = list(db.execute(select(KnownDog.id).where(KnownDog.owner_id == user_id)).scalars()) |
| pic_rows = ( |
| db.execute( |
| select(Picture.id, Picture.file_path, Picture.thumb_path).where( |
| Picture.subject_type == SubjectType.known, Picture.subject_id.in_(known_ids) |
| ) |
| ).all() |
| if known_ids else [] |
| ) |
| pic_ids = [r[0] for r in pic_rows] |
| media_keys = [k for r in pic_rows for k in (r[1], r[2]) if k] |
|
|
| cconds = [Case.person_id == user_id] |
| if known_ids: |
| cconds.append(Case.known_dog_id.in_(known_ids)) |
| case_ids = list(db.execute(select(Case.id).where(or_(*cconds))).scalars()) |
|
|
| mconds = [] |
| if case_ids: |
| mconds += [Match.case_id.in_(case_ids), Match.candidate_case_id.in_(case_ids)] |
| if known_ids: |
| mconds.append(and_(Match.candidate_type == SubjectType.known, Match.candidate_id.in_(known_ids))) |
| match_ids = list(db.execute(select(Match.id).where(or_(*mconds))).scalars()) if mconds else [] |
|
|
| counts: dict[str, int] = {} |
| nconds = [Notification.user_id == user_id] |
| if case_ids: |
| nconds.append(Notification.case_id.in_(case_ids)) |
| if match_ids: |
| nconds.append(Notification.match_id.in_(match_ids)) |
| counts["notifications"] = db.execute(delete(Notification).where(or_(*nconds))).rowcount or 0 |
| if pic_ids: |
| counts["breed_predictions"] = db.execute( |
| delete(BreedPrediction).where(BreedPrediction.picture_id.in_(pic_ids)) |
| ).rowcount or 0 |
| counts["embeddings"] = db.execute( |
| delete(Embedding).where(Embedding.picture_id.in_(pic_ids)) |
| ).rowcount or 0 |
| counts["matches"] = ( |
| db.execute(delete(Match).where(Match.id.in_(match_ids))).rowcount or 0 if match_ids else 0 |
| ) |
| db.execute(Match.__table__.update().where(Match.reviewed_by == user_id).values(reviewed_by=None)) |
| counts["pictures"] = ( |
| db.execute(delete(Picture).where(Picture.id.in_(pic_ids))).rowcount or 0 if pic_ids else 0 |
| ) |
| counts["cases"] = ( |
| db.execute(delete(Case).where(Case.id.in_(case_ids))).rowcount or 0 if case_ids else 0 |
| ) |
| counts["known_dogs"] = ( |
| db.execute(delete(KnownDog).where(KnownDog.id.in_(known_ids))).rowcount or 0 if known_ids else 0 |
| ) |
| counts["users"] = db.execute(delete(User).where(User.id == user_id)).rowcount or 0 |
| db.commit() |
| counts["media_files"] = _delete_media_keys(media_keys) |
| return counts |
|
|
|
|
| |
|
|
|
|
| def embed_all(db: Session, dataset: Dataset, *, progress=None, commit_every: int = 25) -> dict: |
| """Generate embeddings AND breed predictions for every dataset picture lacking them. |
| |
| Runs the model ONCE per image when the embedder and breed classifier share an HF model (spec |
| §9.1, "same model, two roles"); otherwise uses the two models independently. Idempotent / |
| best-effort. ``embedded`` and ``breeds`` count newly-written rows; ``skipped`` counts pictures |
| that already had both. |
| |
| Commits (and reports ``progress``) every ``commit_every`` pictures instead of once at the end, so |
| a large encode run streams live progress to the UI job poller and doesn't hold one giant |
| transaction / unbounded session. Safe because encoding is additive and idempotent — a crash mid |
| run just leaves the already-committed embeddings (re-running skips them). ``progress`` is called |
| with ``{"processed", "total", "dataset_id"}``. |
| """ |
| dataset_id = dataset.id |
| known_ids = _known_ids(db, dataset_id) |
| unknown_ids = _unknown_ids(db, dataset_id) |
| pic_ids = _picture_ids(db, known_ids, unknown_ids) |
| total = len(pic_ids) |
| embedded = breeds = skipped = errors = 0 |
|
|
| def _emit(done: int) -> None: |
| if progress: |
| progress({"processed": done, "total": total, "dataset_id": dataset_id}) |
|
|
| _emit(0) |
| for i, pid in enumerate(pic_ids, start=1): |
| picture = db.get(Picture, pid) |
| if picture is not None: |
| try: |
| emb, breed = embed_and_breed_picture(db, picture, skip_if_exists=True) |
| if emb: |
| embedded += 1 |
| if breed: |
| breeds += 1 |
| if not emb and not breed: |
| skipped += 1 |
| except Exception: |
| errors += 1 |
| logger.warning("Embed+breed failed for picture %s", pid, exc_info=True) |
| if i % commit_every == 0: |
| db.commit() |
| db.expunge_all() |
| _emit(i) |
| db.commit() |
| _emit(total) |
| return { |
| "total_pictures": total, |
| "embedded": embedded, |
| "breeds": breeds, |
| "skipped": skipped, |
| "errors": errors, |
| } |
|
|
|
|
| |
|
|
|
|
| def match_dataset(db: Session, dataset: Dataset, candidate_dataset_id: int | None = None) -> dict: |
| """Run matching for every case belonging to ``dataset`` (optionally scoped to a 2nd dataset).""" |
| known_ids = _known_ids(db, dataset.id) |
| unknown_ids = _unknown_ids(db, dataset.id) |
| case_ids = _case_ids(db, known_ids, unknown_ids, []) |
| cases_matched = 0 |
| matches_created = 0 |
| for cid in case_ids: |
| case = db.get(Case, cid) |
| if case is None: |
| continue |
| matches = run_matching_for_case(db, case, candidate_dataset_id=candidate_dataset_id) |
| cases_matched += 1 |
| matches_created += len(matches) |
| db.commit() |
| return { |
| "cases_processed": cases_matched, |
| "matches_created": matches_created, |
| "candidate_dataset_id": candidate_dataset_id, |
| } |
|
|