add homekeeper stack

This commit is contained in:
2026-07-06 19:25:38 +02:00
commit 58983e6855
90 changed files with 6816 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+67
View File
@@ -0,0 +1,67 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from passlib.context import CryptContext
from sqlalchemy import text
security = HTTPBasic()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)):
from app.database import engine
with engine.connect() as conn:
row = conn.execute(
text("SELECT hashed_password FROM auth.users WHERE username = :u"),
{"u": credentials.username},
).fetchone()
if row is None or not verify_password(credentials.password, row[0]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
def seed_users():
"""Seed initial users from INITIAL_USERS env var if users table is empty."""
from app.database import engine
initial_users_env = os.getenv("INITIAL_USERS", "")
if not initial_users_env:
return
with engine.connect() as conn:
count = conn.execute(text("SELECT COUNT(*) FROM auth.users")).scalar()
if count and count > 0:
return
for pair in initial_users_env.split(","):
pair = pair.strip()
if ":" not in pair:
continue
username, password = pair.split(":", 1)
username = username.strip()
password = password.strip()
if not username or not password:
continue
hashed = hash_password(password)
conn.execute(
text(
"INSERT INTO auth.users (username, hashed_password) "
"VALUES (:u, :h) ON CONFLICT (username) DO NOTHING"
),
{"u": username, "h": hashed},
)
conn.commit()
+243
View File
@@ -0,0 +1,243 @@
import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
_DB_HOST = os.getenv("DB_HOST", "localhost")
_DB_PORT = os.getenv("DB_PORT", "5432")
_DB_NAME = os.getenv("DB_NAME", "homestead")
_DB_USER = os.getenv("DB_USER", "homestead")
_DB_PASSWORD = os.getenv("DB_PASSWORD", "homestead")
DATABASE_URL = (
f"postgresql+psycopg2://{_DB_USER}:{_DB_PASSWORD}@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
def get_session():
with Session(engine) as session:
yield session
def init_db():
"""Create schemas and tables, then seed initial users."""
from app.auth import seed_users
with engine.connect() as conn:
# Create auth schema and users table
conn.execute(text("CREATE SCHEMA IF NOT EXISTS auth"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS auth.users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL
)
"""))
# Create lk schema and tables
conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS lk.lists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('shopping', 'todo', 'project')),
t_shirt_size TEXT CHECK (t_shirt_size IN ('XS', 'S', 'M', 'L', 'XL')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
closed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS lk.list_items (
id SERIAL PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES lk.lists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
checked BOOLEAN NOT NULL DEFAULT FALSE,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)
"""))
# Create public schema beekeeper tables
conn.execute(text("""
CREATE TABLE IF NOT EXISTS apiaries (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location_text TEXT,
lat REAL,
lng REAL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colonies (
id SERIAL PRIMARY KEY,
apiary_id INTEGER REFERENCES apiaries(id),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'wirtschaftsvolk',
parent_colony_id INTEGER REFERENCES colonies(id),
current_queen_id INTEGER,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colony_locations (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
apiary_id INTEGER NOT NULL REFERENCES apiaries(id),
moved_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS queens (
id SERIAL PRIMARY KEY,
colony_id INTEGER REFERENCES colonies(id),
year INTEGER,
breed TEXT,
origin_colony_id INTEGER REFERENCES colonies(id),
marked_color TEXT,
notes TEXT,
introduced_at TEXT,
superseded_at TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS inspections (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
inspector TEXT,
brood_frames INTEGER,
honey_frames INTEGER,
food_stores_kg REAL,
colony_strength INTEGER,
queen_seen INTEGER NOT NULL DEFAULT 0,
eggs_seen INTEGER NOT NULL DEFAULT 0,
queen_cells_count INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS inspection_traits (
id SERIAL PRIMARY KEY,
inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE,
trait TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score BETWEEN 1 AND 4)
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_counts (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date_from TEXT,
date TEXT NOT NULL,
time TEXT,
method TEXT NOT NULL,
count REAL NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_treatments (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
product TEXT NOT NULL,
method TEXT,
dosage TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_controls (
id SERIAL PRIMARY KEY,
treatment_id INTEGER NOT NULL REFERENCES varroa_treatments(id) ON DELETE CASCADE,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
result TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colony_actions (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
type TEXT NOT NULL,
notes TEXT,
related_colony_id INTEGER REFERENCES colonies(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS honey_harvests (
id SERIAL PRIMARY KEY,
colony_id INTEGER REFERENCES colonies(id),
apiary_id INTEGER REFERENCES apiaries(id),
date TEXT NOT NULL,
time TEXT,
weight_kg REAL NOT NULL,
type TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'colony',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklist_items (
id SERIAL PRIMARY KEY,
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklist_completions (
id SERIAL PRIMARY KEY,
checklist_id INTEGER NOT NULL REFERENCES checklists(id),
item_id INTEGER NOT NULL REFERENCES checklist_items(id),
scope_id INTEGER NOT NULL,
date TEXT NOT NULL,
completed_by TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
# Add columns that may be missing from older installs (idempotent)
for stmt in [
"ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS date_from TEXT",
"ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE varroa_treatments ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE varroa_controls ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE honey_harvests ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE colony_actions ADD COLUMN IF NOT EXISTS related_colony_id INTEGER REFERENCES colonies(id)",
]:
try:
conn.execute(text(stmt))
except Exception:
pass
conn.commit()
seed_users()
+45
View File
@@ -0,0 +1,45 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.routers import (
apiaries,
colonies,
inspections,
varroa,
harvests,
actions,
queens,
checklists,
lists,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.database import init_db
init_db()
yield
app = FastAPI(
title="Beekeeper API",
version="1.0.0",
root_path=os.getenv("ROOT_PATH", ""),
lifespan=lifespan,
)
app.include_router(apiaries.router, prefix="/v1/apiaries", tags=["apiaries"])
app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"])
app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"])
app.include_router(varroa.router, prefix="/v1/varroa", tags=["varroa"])
app.include_router(harvests.router, prefix="/v1/harvests", tags=["harvests"])
app.include_router(actions.router, prefix="/v1/actions", tags=["actions"])
app.include_router(queens.router, prefix="/v1/queens", tags=["queens"])
app.include_router(checklists.router, prefix="/v1/checklists", tags=["checklists"])
app.include_router(lists.router, prefix="/v1/lists", tags=["lists"])
@app.get("/health")
def health():
return {"status": "ok"}
View File
+10
View File
@@ -0,0 +1,10 @@
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
__tablename__ = "users"
__table_args__ = {"schema": "auth"}
id: int | None = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
hashed_password: str
View File
+94
View File
@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_actions(
colony_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM colony_actions WHERE colony_id = :colony_id
ORDER BY date::DATE DESC, created_at DESC
"""), {"colony_id": colony_id}).fetchall()
return [_row(r) for r in rows]
@router.post("", status_code=201)
def create_action(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO colony_actions (colony_id, date, time, type, notes, related_colony_id)
VALUES (:colony_id, :date, :time, :type, :notes, :related_colony_id)
RETURNING *
"""), {
"colony_id": body.get("colony_id"),
"date": body.get("date"),
"time": body.get("time"),
"type": body.get("type"),
"notes": body.get("notes"),
"related_colony_id": body.get("related_colony_id"),
}).fetchone()
session.commit()
return _row(row)
@router.get("/{id}")
def get_action(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM colony_actions WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/{id}")
def update_action(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE colony_actions SET date = :date, type = :type, notes = :notes
WHERE id = :id RETURNING *
"""), {
"id": id,
"date": body.get("date"),
"type": body.get("type"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_action(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM colony_actions WHERE id = :id"), {"id": id})
session.commit()
+99
View File
@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_apiaries(
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT a.*, COUNT(c.id) AS colony_count
FROM apiaries a
LEFT JOIN colonies c ON c.apiary_id = a.id AND c.closed_at IS NULL
GROUP BY a.id
ORDER BY a.name
""")).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}")
def get_apiary(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM apiaries WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.post("", status_code=201)
def create_apiary(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO apiaries (name, location_text, lat, lng, notes)
VALUES (:name, :location_text, :lat, :lng, :notes)
RETURNING *
"""), {
"name": body.get("name"),
"location_text": body.get("location_text"),
"lat": body.get("lat"),
"lng": body.get("lng"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
return _row(row)
@router.put("/{id}")
def update_apiary(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE apiaries
SET name = :name, location_text = :location_text, lat = :lat, lng = :lng, notes = :notes
WHERE id = :id
RETURNING *
"""), {
"id": id,
"name": body.get("name"),
"location_text": body.get("location_text"),
"lat": body.get("lat"),
"lng": body.get("lng"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_apiary(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM apiaries WHERE id = :id"), {"id": id})
session.commit()
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_checklists(
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("SELECT * FROM checklists ORDER BY name")).fetchall()
return [_row(r) for r in rows]
@router.post("", status_code=201)
def create_checklist(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO checklists (name, scope) VALUES (:name, :scope) RETURNING *
"""), {"name": body.get("name"), "scope": body.get("scope", "colony")}).fetchone()
session.commit()
return _row(row)
@router.delete("/{id}", status_code=204)
def delete_checklist(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM checklists WHERE id = :id"), {"id": id})
session.commit()
@router.get("/{id}/items")
def list_items(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM checklist_items WHERE checklist_id = :id ORDER BY position
"""), {"id": id}).fetchall()
return [_row(r) for r in rows]
@router.post("/{id}/items", status_code=201)
def create_item(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO checklist_items (checklist_id, text, position)
VALUES (:checklist_id, :text, :position) RETURNING *
"""), {
"checklist_id": id,
"text": body.get("text"),
"position": body.get("position", 0),
}).fetchone()
session.commit()
return _row(row)
@router.delete("/items/{item_id}", status_code=204)
def delete_item(
item_id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM checklist_items WHERE id = :id"), {"id": item_id})
session.commit()
@router.get("/{id}/completions")
def list_completions(
id: int,
scope_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT cc.*, ci.text, ci.position
FROM checklist_completions cc
JOIN checklist_items ci ON ci.id = cc.item_id
WHERE cc.checklist_id = :checklist_id AND cc.scope_id = :scope_id
ORDER BY ci.position
"""), {"checklist_id": id, "scope_id": scope_id}).fetchall()
return [_row(r) for r in rows]
@router.post("/{id}/completions/toggle")
def toggle_completion(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
item_id = body.get("item_id")
scope_id = body.get("scope_id")
date = body.get("date")
completed_by = body.get("completed_by")
notes = body.get("notes")
existing = session.execute(text("""
SELECT id FROM checklist_completions
WHERE checklist_id = :checklist_id AND item_id = :item_id AND scope_id = :scope_id
"""), {"checklist_id": id, "item_id": item_id, "scope_id": scope_id}).fetchone()
if existing is not None:
session.execute(
text("DELETE FROM checklist_completions WHERE id = :id"),
{"id": existing._mapping["id"]},
)
session.commit()
return {"toggled": False}
else:
session.execute(text("""
INSERT INTO checklist_completions (checklist_id, item_id, scope_id, date, completed_by, notes)
VALUES (:checklist_id, :item_id, :scope_id, :date, :completed_by, :notes)
"""), {
"checklist_id": id,
"item_id": item_id,
"scope_id": scope_id,
"date": date,
"completed_by": completed_by,
"notes": notes,
})
session.commit()
return {"toggled": True}
+294
View File
@@ -0,0 +1,294 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_colonies(
apiary_id: int | None = Query(default=None),
include_closed: bool = Query(default=False),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
where = "1=1" if include_closed else "c.closed_at IS NULL"
params: dict[str, Any] = {}
if apiary_id is not None:
where += " AND c.apiary_id = :apiary_id"
params["apiary_id"] = apiary_id
rows = session.execute(text(f"""
SELECT
c.*,
a.name AS apiary_name,
p.name AS parent_name,
q.year AS queen_year,
q.breed AS queen_breed,
(SELECT MAX(i.date::DATE)
FROM inspections i
WHERE i.colony_id = c.id) AS last_inspection_date
FROM colonies c
LEFT JOIN apiaries a ON a.id = c.apiary_id
LEFT JOIN colonies p ON p.id = c.parent_colony_id
LEFT JOIN queens q ON q.id = c.current_queen_id
WHERE {where}
ORDER BY a.name, c.name
"""), params).fetchall()
return [_row(r) for r in rows]
@router.get("/log-placeholder", include_in_schema=False)
def _log_placeholder():
pass # just to force ordering; actual log route is below
@router.get("/{id}/log")
def colony_log(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT date, type, summary, ref_id, created_at FROM (
SELECT date, 'durchsicht' AS type,
TRIM(BOTH FROM CONCAT_WS(' ',
CASE WHEN brood_frames IS NOT NULL THEN brood_frames::TEXT || ' BW' END,
CASE WHEN honey_frames IS NOT NULL THEN honey_frames::TEXT || ' HW' END,
CASE WHEN colony_strength IS NOT NULL THEN 'Stärke ' || colony_strength::TEXT END,
CASE WHEN queen_seen = 1 THEN '♛ gesehen' END
)) AS summary,
id AS ref_id, created_at
FROM inspections WHERE colony_id = :colony_id
UNION ALL
SELECT date, 'kontrolle' AS type,
count::TEXT || ' Milben' ||
CASE WHEN date_from IS NOT NULL
THEN ' · ' || (date::DATE - date_from::DATE)::TEXT || ' Tage' ELSE '' END AS summary,
id AS ref_id, created_at
FROM varroa_counts WHERE colony_id = :colony_id
UNION ALL
SELECT date, 'behandlung' AS type,
product || CASE WHEN dosage IS NOT NULL AND dosage <> ''
THEN ' · ' || dosage ELSE '' END AS summary,
id AS ref_id, created_at
FROM varroa_treatments WHERE colony_id = :colony_id
UNION ALL
SELECT vc.date, 'nachkontrolle' AS type,
vt.product || CASE WHEN vc.result IS NOT NULL AND vc.result <> ''
THEN ': ' || vc.result ELSE '' END AS summary,
vc.id AS ref_id, vc.created_at
FROM varroa_controls vc
JOIN varroa_treatments vt ON vt.id = vc.treatment_id
WHERE vc.colony_id = :colony_id
UNION ALL
SELECT h.date, 'ernte' AS type,
h.weight_kg::TEXT || ' kg' ||
CASE WHEN h.type IS NOT NULL THEN ' · ' || h.type ELSE '' END AS summary,
h.id AS ref_id, h.created_at
FROM honey_harvests h WHERE h.colony_id = :colony_id
UNION ALL
SELECT ca.date, 'massnahme' AS type,
CASE ca.type
WHEN 'ablegerbildung' THEN 'Ablegerbildung'
WHEN 'zwischenbodenableger' THEN 'Zwischenbödenableger'
WHEN 'brutentnahme' THEN 'Brutentnahme'
WHEN 'standwechsel' THEN 'Standwechsel'
WHEN 'futterung' THEN 'Fütterung'
WHEN 'vereinigung' THEN 'Völkervereinigung'
ELSE 'Maßnahme'
END ||
CASE WHEN ca.notes IS NOT NULL AND ca.notes <> ''
THEN ': ' || ca.notes ELSE '' END AS summary,
ca.id AS ref_id, ca.created_at
FROM colony_actions ca WHERE ca.colony_id = :colony_id
) log
ORDER BY date::DATE DESC, created_at DESC
"""), {"colony_id": id}).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}/lineage")
def colony_lineage(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
WITH RECURSIVE lineage(id, name, status, parent_colony_id, depth) AS (
SELECT id, name, status, parent_colony_id, 0
FROM colonies WHERE id = :colony_id
UNION ALL
SELECT c.id, c.name, c.status, c.parent_colony_id, l.depth + 1
FROM colonies c
JOIN lineage l ON c.id = l.parent_colony_id
)
SELECT * FROM lineage ORDER BY depth DESC
"""), {"colony_id": id}).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}/descendants")
def colony_descendants(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
WITH RECURSIVE descendants(id, name, status, parent_colony_id, depth) AS (
SELECT id, name, status, parent_colony_id, 0
FROM colonies WHERE id = :colony_id
UNION ALL
SELECT c.id, c.name, c.status, c.parent_colony_id, d.depth + 1
FROM colonies c
JOIN descendants d ON c.parent_colony_id = d.id
)
SELECT * FROM descendants WHERE depth > 0 ORDER BY depth, name
"""), {"colony_id": id}).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}/location-history")
def colony_location_history(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT cl.*, a.name AS apiary_name
FROM colony_locations cl
JOIN apiaries a ON a.id = cl.apiary_id
WHERE cl.colony_id = :colony_id
ORDER BY cl.moved_at DESC
"""), {"colony_id": id}).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}")
def get_colony(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
SELECT c.*, a.name AS apiary_name, p.name AS parent_name
FROM colonies c
LEFT JOIN apiaries a ON a.id = c.apiary_id
LEFT JOIN colonies p ON p.id = c.parent_colony_id
WHERE c.id = :id
"""), {"id": id}).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.post("", status_code=201)
def create_colony(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO colonies (name, apiary_id, status, parent_colony_id, notes)
VALUES (:name, :apiary_id, :status, :parent_colony_id, :notes)
RETURNING *
"""), {
"name": body.get("name"),
"apiary_id": body.get("apiary_id"),
"status": body.get("status", "wirtschaftsvolk"),
"parent_colony_id": body.get("parent_colony_id"),
"notes": body.get("notes"),
}).fetchone()
colony_id = row._mapping["id"]
session.execute(text("""
INSERT INTO colony_locations (colony_id, apiary_id) VALUES (:colony_id, :apiary_id)
"""), {"colony_id": colony_id, "apiary_id": body.get("apiary_id")})
session.commit()
return _row(row)
@router.put("/{id}")
def update_colony(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
# Check if apiary changed (to insert colony_location)
old = session.execute(
text("SELECT apiary_id FROM colonies WHERE id = :id"), {"id": id}
).fetchone()
result = session.execute(text("""
UPDATE colonies
SET name = :name, apiary_id = :apiary_id, status = :status, notes = :notes
WHERE id = :id
RETURNING *
"""), {
"id": id,
"name": body.get("name"),
"apiary_id": body.get("apiary_id"),
"status": body.get("status"),
"notes": body.get("notes"),
}).fetchone()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
new_apiary_id = body.get("apiary_id")
if old is not None and old._mapping["apiary_id"] != new_apiary_id:
session.execute(text("""
INSERT INTO colony_locations (colony_id, apiary_id) VALUES (:colony_id, :apiary_id)
"""), {"colony_id": id, "apiary_id": new_apiary_id})
session.commit()
return _row(result)
@router.post("/{id}/close")
def close_colony(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE colonies SET status = 'eingegangen', closed_at = NOW()
WHERE id = :id RETURNING *
"""), {"id": id}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.post("/{id}/set-queen")
def set_queen(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE colonies SET current_queen_id = :queen_id WHERE id = :id RETURNING *
"""), {"id": id, "queen_id": body.get("queen_id")}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
+136
View File
@@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("/summary")
def harvest_summary(
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT
EXTRACT(YEAR FROM date::date)::INTEGER AS year,
type,
SUM(weight_kg) AS total_kg,
COUNT(*) AS n_harvests
FROM honey_harvests
GROUP BY year, type
ORDER BY year DESC, total_kg DESC
""")).fetchall()
return [_row(r) for r in rows]
@router.get("")
def list_harvests(
colony_id: int | None = Query(default=None),
apiary_id: int | None = Query(default=None),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
if colony_id is not None:
rows = session.execute(text("""
SELECT h.*, c.name AS colony_name, a.name AS apiary_name
FROM honey_harvests h
LEFT JOIN colonies c ON c.id = h.colony_id
LEFT JOIN apiaries a ON a.id = h.apiary_id
WHERE h.colony_id = :colony_id
ORDER BY h.date DESC
"""), {"colony_id": colony_id}).fetchall()
elif apiary_id is not None:
rows = session.execute(text("""
SELECT h.*, c.name AS colony_name, a.name AS apiary_name
FROM honey_harvests h
LEFT JOIN colonies c ON c.id = h.colony_id
LEFT JOIN apiaries a ON a.id = h.apiary_id
WHERE h.apiary_id = :apiary_id
ORDER BY h.date DESC
"""), {"apiary_id": apiary_id}).fetchall()
else:
rows = session.execute(text("""
SELECT h.*, c.name AS colony_name, a.name AS apiary_name
FROM honey_harvests h
LEFT JOIN colonies c ON c.id = h.colony_id
LEFT JOIN apiaries a ON a.id = h.apiary_id
ORDER BY h.date DESC
""")).fetchall()
return [_row(r) for r in rows]
@router.post("", status_code=201)
def create_harvest(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO honey_harvests (date, weight_kg, colony_id, apiary_id, type, notes, time)
VALUES (:date, :weight_kg, :colony_id, :apiary_id, :type, :notes, :time)
RETURNING *
"""), {
"date": body.get("date"),
"weight_kg": body.get("weight_kg"),
"colony_id": body.get("colony_id"),
"apiary_id": body.get("apiary_id"),
"type": body.get("type"),
"notes": body.get("notes"),
"time": body.get("time"),
}).fetchone()
session.commit()
return _row(row)
@router.get("/{id}")
def get_harvest(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM honey_harvests WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/{id}")
def update_harvest(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE honey_harvests SET date = :date, weight_kg = :weight_kg, notes = :notes
WHERE id = :id RETURNING *
"""), {
"id": id,
"date": body.get("date"),
"weight_kg": body.get("weight_kg"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_harvest(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM honey_harvests WHERE id = :id"), {"id": id})
session.commit()
+164
View File
@@ -0,0 +1,164 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_inspections(
colony_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM inspections WHERE colony_id = :colony_id
ORDER BY date::DATE DESC, id DESC
"""), {"colony_id": colony_id}).fetchall()
return [_row(r) for r in rows]
@router.get("/last")
def last_inspection(
colony_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
row = session.execute(text("""
SELECT * FROM inspections WHERE colony_id = :colony_id
ORDER BY date::DATE DESC, id DESC LIMIT 1
"""), {"colony_id": colony_id}).fetchone()
if row is None:
return None
return _row(row)
@router.get("/{id}/traits")
def inspection_traits(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM inspection_traits WHERE inspection_id = :id
"""), {"id": id}).fetchall()
return [_row(r) for r in rows]
@router.get("/{id}")
def get_inspection(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM inspections WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.post("", status_code=201)
def create_inspection(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO inspections
(colony_id, date, inspector, brood_frames, honey_frames, food_stores_kg,
colony_strength, queen_seen, eggs_seen, queen_cells_count, notes)
VALUES
(:colony_id, :date, :inspector, :brood_frames, :honey_frames, :food_stores_kg,
:colony_strength, :queen_seen, :eggs_seen, :queen_cells_count, :notes)
RETURNING *
"""), {
"colony_id": body.get("colony_id"),
"date": body.get("date"),
"inspector": body.get("inspector"),
"brood_frames": body.get("brood_frames"),
"honey_frames": body.get("honey_frames"),
"food_stores_kg": body.get("food_stores_kg"),
"colony_strength": body.get("colony_strength"),
"queen_seen": int(body.get("queen_seen", 0)),
"eggs_seen": int(body.get("eggs_seen", 0)),
"queen_cells_count": int(body.get("queen_cells_count", 0)),
"notes": body.get("notes"),
}).fetchone()
inspection_id = row._mapping["id"]
traits = body.get("traits", {})
for trait, score in (traits or {}).items():
if score is not None:
session.execute(text("""
INSERT INTO inspection_traits (inspection_id, trait, score)
VALUES (:inspection_id, :trait, :score)
"""), {"inspection_id": inspection_id, "trait": trait, "score": int(score)})
session.commit()
return _row(row)
@router.put("/{id}")
def update_inspection(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE inspections SET
date = :date, inspector = :inspector, brood_frames = :brood_frames,
honey_frames = :honey_frames, food_stores_kg = :food_stores_kg,
colony_strength = :colony_strength, queen_seen = :queen_seen,
eggs_seen = :eggs_seen, queen_cells_count = :queen_cells_count, notes = :notes
WHERE id = :id
RETURNING *
"""), {
"id": id,
"date": body.get("date"),
"inspector": body.get("inspector"),
"brood_frames": body.get("brood_frames"),
"honey_frames": body.get("honey_frames"),
"food_stores_kg": body.get("food_stores_kg"),
"colony_strength": body.get("colony_strength"),
"queen_seen": int(body.get("queen_seen", 0)),
"eggs_seen": int(body.get("eggs_seen", 0)),
"queen_cells_count": int(body.get("queen_cells_count", 0)),
"notes": body.get("notes"),
}).fetchone()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
session.execute(
text("DELETE FROM inspection_traits WHERE inspection_id = :id"), {"id": id}
)
traits = body.get("traits", {})
for trait, score in (traits or {}).items():
if score is not None:
session.execute(text("""
INSERT INTO inspection_traits (inspection_id, trait, score)
VALUES (:inspection_id, :trait, :score)
"""), {"inspection_id": id, "trait": trait, "score": int(score)})
session.commit()
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_inspection(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM inspections WHERE id = :id"), {"id": id})
session.commit()
+149
View File
@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_lists(
include_closed: bool = Query(default=False),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
where = "" if include_closed else "WHERE l.closed_at IS NULL"
order = "ORDER BY l.closed_at IS NOT NULL, l.created_at DESC" if include_closed else "ORDER BY l.created_at DESC"
rows = session.execute(text(f"""
SELECT
l.*,
COUNT(i.id) AS item_count,
COUNT(i.id) FILTER (WHERE i.checked) AS checked_count
FROM lk.lists l
LEFT JOIN lk.list_items i ON i.list_id = l.id
{where}
GROUP BY l.id
{order}
""")).fetchall()
return [_row(r) for r in rows]
@router.post("", status_code=201)
def create_list(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO lk.lists (name, type, t_shirt_size, notes)
VALUES (:name, :type, :t_shirt_size, :notes)
RETURNING *
"""), {
"name": body.get("name"),
"type": body.get("type"),
"t_shirt_size": body.get("t_shirt_size"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
return _row(row)
@router.post("/{id}/close")
def close_list(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE lk.lists SET status = 'closed', closed_at = now()
WHERE id = :id RETURNING *
"""), {"id": id}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.post("/{id}/reopen")
def reopen_list(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE lk.lists SET status = 'open', closed_at = NULL
WHERE id = :id RETURNING *
"""), {"id": id}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_list(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM lk.lists WHERE id = :id"), {"id": id})
session.commit()
@router.get("/{id}/items")
def list_items(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM lk.list_items WHERE list_id = :list_id ORDER BY position, created_at
"""), {"list_id": id}).fetchall()
return [_row(r) for r in rows]
@router.post("/{id}/items", status_code=201)
def create_item(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO lk.list_items (list_id, text) VALUES (:list_id, :text) RETURNING *
"""), {"list_id": id, "text": body.get("text")}).fetchone()
session.commit()
return _row(row)
@router.patch("/items/{id}")
def update_item_checked(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE lk.list_items SET checked = :checked WHERE id = :id RETURNING *
"""), {"id": id, "checked": bool(body.get("checked", False))}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/items/{id}", status_code=204)
def delete_item(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM lk.list_items WHERE id = :id"), {"id": id})
session.commit()
+141
View File
@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
@router.get("")
def list_queens(
colony_id: int | None = Query(default=None),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
params: dict[str, Any] = {}
where = ""
if colony_id is not None:
where = "WHERE q.colony_id = :colony_id"
params["colony_id"] = colony_id
rows = session.execute(text(f"""
SELECT q.*, c.name AS colony_name, o.name AS origin_colony_name
FROM queens q
LEFT JOIN colonies c ON c.id = q.colony_id
LEFT JOIN colonies o ON o.id = q.origin_colony_id
{where}
ORDER BY q.introduced_at DESC
"""), params).fetchall()
return [_row(r) for r in rows]
@router.post("", status_code=201)
def create_queen(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO queens (colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at)
VALUES (:colony_id, :year, :breed, :origin_colony_id, :marked_color, :notes, :introduced_at)
RETURNING *
"""), {
"colony_id": body.get("colony_id"),
"year": body.get("year"),
"breed": body.get("breed"),
"origin_colony_id": body.get("origin_colony_id"),
"marked_color": body.get("marked_color"),
"notes": body.get("notes"),
"introduced_at": body.get("introduced_at"),
}).fetchone()
queen_id = row._mapping["id"]
colony_id = body.get("colony_id")
if colony_id is not None:
session.execute(text("""
UPDATE colonies SET current_queen_id = :queen_id WHERE id = :colony_id
"""), {"queen_id": queen_id, "colony_id": colony_id})
session.commit()
return _row(row)
@router.get("/{id}")
def get_queen(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM queens WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/{id}")
def update_queen(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE queens SET
colony_id = :colony_id, year = :year, breed = :breed,
origin_colony_id = :origin_colony_id, marked_color = :marked_color,
notes = :notes, introduced_at = :introduced_at, superseded_at = :superseded_at
WHERE id = :id
RETURNING *
"""), {
"id": id,
"colony_id": body.get("colony_id"),
"year": body.get("year"),
"breed": body.get("breed"),
"origin_colony_id": body.get("origin_colony_id"),
"marked_color": body.get("marked_color"),
"notes": body.get("notes"),
"introduced_at": body.get("introduced_at"),
"superseded_at": body.get("superseded_at"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.post("/{id}/supersede")
def supersede_queen(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE queens SET superseded_at = CURRENT_DATE WHERE id = :id RETURNING *
"""), {"id": id}).fetchone()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
colony_id = result._mapping.get("colony_id")
if colony_id is not None:
session.execute(text("""
UPDATE colonies SET current_queen_id = NULL
WHERE id = :colony_id AND current_queen_id = :queen_id
"""), {"colony_id": colony_id, "queen_id": id})
session.commit()
return _row(result)
@router.delete("/{id}", status_code=204)
def delete_queen(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM queens WHERE id = :id"), {"id": id})
session.commit()
+284
View File
@@ -0,0 +1,284 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Any
from app.database import get_session
from app.auth import get_current_user
router = APIRouter()
def _row(r) -> dict:
return dict(r._mapping)
# ---- Counts ----
@router.get("/counts")
def list_counts(
colony_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM varroa_counts WHERE colony_id = :colony_id ORDER BY date DESC
"""), {"colony_id": colony_id}).fetchall()
return [_row(r) for r in rows]
@router.post("/counts", status_code=201)
def create_count(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO varroa_counts (colony_id, date_from, date, method, count, notes, time)
VALUES (:colony_id, :date_from, :date, :method, :count, :notes, :time)
RETURNING *
"""), {
"colony_id": body.get("colony_id"),
"date_from": body.get("date_from"),
"date": body.get("date"),
"method": body.get("method"),
"count": body.get("count"),
"notes": body.get("notes"),
"time": body.get("time"),
}).fetchone()
session.commit()
return _row(row)
@router.get("/counts/{id}")
def get_count(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM varroa_counts WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/counts/{id}")
def update_count(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE varroa_counts
SET date_from = :date_from, date = :date, method = :method, count = :count, notes = :notes
WHERE id = :id
RETURNING *
"""), {
"id": id,
"date_from": body.get("date_from"),
"date": body.get("date"),
"method": body.get("method"),
"count": body.get("count"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/counts/{id}", status_code=204)
def delete_count(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM varroa_counts WHERE id = :id"), {"id": id})
session.commit()
# ---- Treatments ----
@router.get("/treatments")
def list_treatments(
colony_id: int = Query(...),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
rows = session.execute(text("""
SELECT * FROM varroa_treatments WHERE colony_id = :colony_id ORDER BY date DESC
"""), {"colony_id": colony_id}).fetchall()
return [_row(r) for r in rows]
@router.post("/treatments", status_code=201)
def create_treatment(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO varroa_treatments (colony_id, date, product, method, dosage, notes, time)
VALUES (:colony_id, :date, :product, :method, :dosage, :notes, :time)
RETURNING *
"""), {
"colony_id": body.get("colony_id"),
"date": body.get("date"),
"product": body.get("product"),
"method": body.get("method"),
"dosage": body.get("dosage"),
"notes": body.get("notes"),
"time": body.get("time"),
}).fetchone()
session.commit()
return _row(row)
@router.get("/treatments/{id}")
def get_treatment(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM varroa_treatments WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/treatments/{id}")
def update_treatment(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE varroa_treatments
SET date = :date, product = :product, method = :method, dosage = :dosage, notes = :notes
WHERE id = :id
RETURNING *
"""), {
"id": id,
"date": body.get("date"),
"product": body.get("product"),
"method": body.get("method"),
"dosage": body.get("dosage"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/treatments/{id}", status_code=204)
def delete_treatment(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM varroa_treatments WHERE id = :id"), {"id": id})
session.commit()
# ---- Controls ----
@router.get("/controls")
def list_controls(
colony_id: int | None = Query(default=None),
treatment_id: int | None = Query(default=None),
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> list[dict]:
if treatment_id is not None:
rows = session.execute(text("""
SELECT vc.*, vt.product, vt.date AS treatment_date
FROM varroa_controls vc
JOIN varroa_treatments vt ON vt.id = vc.treatment_id
WHERE vc.treatment_id = :treatment_id
ORDER BY vc.date DESC
"""), {"treatment_id": treatment_id}).fetchall()
elif colony_id is not None:
rows = session.execute(text("""
SELECT vc.*, vt.product, vt.date AS treatment_date
FROM varroa_controls vc
JOIN varroa_treatments vt ON vt.id = vc.treatment_id
WHERE vc.colony_id = :colony_id
ORDER BY vc.date DESC
"""), {"colony_id": colony_id}).fetchall()
else:
return []
return [_row(r) for r in rows]
@router.post("/controls", status_code=201)
def create_control(
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(text("""
INSERT INTO varroa_controls (treatment_id, colony_id, date, result, notes)
VALUES (:treatment_id, :colony_id, :date, :result, :notes)
RETURNING *
"""), {
"treatment_id": body.get("treatment_id"),
"colony_id": body.get("colony_id"),
"date": body.get("date"),
"result": body.get("result"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
return _row(row)
@router.get("/controls/{id}")
def get_control(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
row = session.execute(
text("SELECT * FROM varroa_controls WHERE id = :id"), {"id": id}
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(row)
@router.put("/controls/{id}")
def update_control(
id: int,
body: dict[str, Any],
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
) -> dict:
result = session.execute(text("""
UPDATE varroa_controls SET date = :date, result = :result, notes = :notes
WHERE id = :id RETURNING *
"""), {
"id": id,
"date": body.get("date"),
"result": body.get("result"),
"notes": body.get("notes"),
}).fetchone()
session.commit()
if result is None:
raise HTTPException(status_code=404, detail="Not found")
return _row(result)
@router.delete("/controls/{id}", status_code=204)
def delete_control(
id: int,
session: Session = Depends(get_session),
_: str = Depends(get_current_user),
):
session.execute(text("DELETE FROM varroa_controls WHERE id = :id"), {"id": id})
session.commit()
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.30
sqlmodel>=0.0.21
psycopg2-binary>=2.9
passlib[bcrypt]>=1.7
bcrypt>=4.0,<5.0
python-multipart>=0.0.9