feat: Phase 6 — alarm system (auto-generate, list, acknowledge)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m17s

- Add Alarm model with tenant/part/machine relationships and 3 indexes
- Add alembic migration c5d6e7f8a9b0_add_alarms
- Add alarms API: list (filter by ack/severity), summary, acknowledge
- Auto-generate alarms on counter update (threshold warning, critical at 100%)
- Duplicate alarm prevention for same part+alarm_type
- Add alarms frontend page with active/acknowledged tabs, summary badges
- 9 new tests (67/67 total passing)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-02-10 14:39:03 +09:00
parent 035d62f0e0
commit 843f72e048
10 changed files with 889 additions and 1 deletions

View File

@@ -0,0 +1,76 @@
"""add_alarms
Revision ID: c5d6e7f8a9b0
Revises: b4c5d6e7f8a9
Create Date: 2026-02-10 14:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "c5d6e7f8a9b0"
down_revision: Union[str, None] = "b4c5d6e7f8a9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"alarms",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
sa.String(50),
sa.ForeignKey("tenants.id"),
nullable=False,
),
sa.Column(
"equipment_part_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("equipment_parts.id"),
nullable=False,
),
sa.Column(
"machine_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("machines.id"),
nullable=False,
),
sa.Column("alarm_type", sa.String(30), nullable=False),
sa.Column("severity", sa.String(20), nullable=False),
sa.Column("message", sa.String(500), nullable=False),
sa.Column("lifecycle_pct_at_trigger", sa.Float, nullable=False),
sa.Column("is_acknowledged", sa.Boolean, default=False),
sa.Column(
"acknowledged_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=True,
),
sa.Column(
"acknowledged_at",
postgresql.TIMESTAMP(timezone=True),
nullable=True,
),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.func.now(),
),
)
op.create_index("ix_alarms_tenant_ack", "alarms", ["tenant_id", "is_acknowledged"])
op.create_index("ix_alarms_tenant_severity", "alarms", ["tenant_id", "severity"])
op.create_index(
"ix_alarms_tenant_part", "alarms", ["tenant_id", "equipment_part_id"]
)
def downgrade() -> None:
op.drop_index("ix_alarms_tenant_part", table_name="alarms")
op.drop_index("ix_alarms_tenant_severity", table_name="alarms")
op.drop_index("ix_alarms_tenant_ack", table_name="alarms")
op.drop_table("alarms")

View File

@@ -0,0 +1,154 @@
'use client';
import { useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useAlarms, useAlarmSummary } from '@/lib/hooks';
import { useToast } from '@/lib/toast-context';
import { api } from '@/lib/api';
import type { Alarm } from '@/lib/types';
type TabType = 'active' | 'acknowledged';
export default function AlarmsPage() {
const params = useParams();
const tenantId = params?.tenant as string;
const { addToast } = useToast();
const [tab, setTab] = useState<TabType>('active');
const [acknowledging, setAcknowledging] = useState<string | null>(null);
const isAcknowledged = tab === 'acknowledged';
const { alarms, isLoading, mutate } = useAlarms(tenantId, isAcknowledged);
const { summary, mutate: mutateSummary } = useAlarmSummary(tenantId);
const handleAcknowledge = useCallback(async (alarm: Alarm) => {
setAcknowledging(alarm.id);
try {
await api.post<Alarm>(`/api/${tenantId}/alarms/${alarm.id}/acknowledge`, {});
addToast('알람이 확인 처리되었습니다.', 'success');
mutate();
mutateSummary();
} catch {
addToast('알람 확인 처리에 실패했습니다.', 'error');
} finally {
setAcknowledging(null);
}
}, [tenantId, mutate, mutateSummary, addToast]);
return (
<div className="page-container">
<div className="page-header">
<h2 className="page-title">
<span className="material-symbols-outlined">notifications</span>
</h2>
{summary && (
<div className="alarm-summary-badges">
{summary.critical_count > 0 && (
<span className="alarm-badge alarm-badge-critical">
{summary.critical_count}
</span>
)}
{summary.warning_count > 0 && (
<span className="alarm-badge alarm-badge-warning">
{summary.warning_count}
</span>
)}
{summary.active_count === 0 && (
<span className="alarm-badge alarm-badge-ok">
</span>
)}
</div>
)}
</div>
<div className="tab-bar">
<button
className={`tab-item ${tab === 'active' ? 'active' : ''}`}
onClick={() => setTab('active')}
>
<span className="material-symbols-outlined">warning</span>
{summary ? `(${summary.active_count})` : ''}
</button>
<button
className={`tab-item ${tab === 'acknowledged' ? 'active' : ''}`}
onClick={() => setTab('acknowledged')}
>
<span className="material-symbols-outlined">check_circle</span>
</button>
</div>
{isLoading ? (
<div className="loading">
<span className="material-symbols-outlined spinning">progress_activity</span>
</div>
) : alarms.length === 0 ? (
<div className="empty-state">
<span className="material-symbols-outlined">
{tab === 'active' ? 'notifications_off' : 'check_circle'}
</span>
<p>{tab === 'active' ? '미확인 알람이 없습니다.' : '확인된 알람이 없습니다.'}</p>
</div>
) : (
<div className="alarm-list">
{alarms.map((alarm) => (
<div
key={alarm.id}
className={`alarm-card alarm-card-${alarm.severity}`}
>
<div className="alarm-card-icon">
<span className="material-symbols-outlined">
{alarm.severity === 'critical' ? 'error' : 'warning'}
</span>
</div>
<div className="alarm-card-body">
<div className="alarm-card-header">
<span className={`alarm-severity-badge severity-${alarm.severity}`}>
{alarm.severity === 'critical' ? '긴급' : '주의'}
</span>
<span className="alarm-card-time">
{alarm.created_at ? new Date(alarm.created_at).toLocaleString('ko-KR') : ''}
</span>
</div>
<p className="alarm-card-message">{alarm.message}</p>
<div className="alarm-card-meta">
<span>
<span className="material-symbols-outlined">precision_manufacturing</span>
{alarm.machine_name || '-'}
</span>
<span>
<span className="material-symbols-outlined">settings</span>
{alarm.part_name || '-'}
</span>
</div>
</div>
<div className="alarm-card-actions">
{!alarm.is_acknowledged ? (
<button
className="btn-primary btn-sm"
onClick={() => handleAcknowledge(alarm)}
disabled={acknowledging === alarm.id}
>
{acknowledging === alarm.id ? (
<span className="material-symbols-outlined spinning">progress_activity</span>
) : (
<span className="material-symbols-outlined">check</span>
)}
</button>
) : (
<span className="alarm-ack-info">
<span className="material-symbols-outlined">check_circle</span>
{alarm.acknowledged_at ? new Date(alarm.acknowledged_at).toLocaleString('ko-KR') : ''}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1767,6 +1767,161 @@ a {
opacity: 0.5;
}
/* ===== Phase 6: Alarms ===== */
.alarm-summary-badges {
display: flex;
gap: 8px;
}
.alarm-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.alarm-badge-critical {
background: #fce8e6;
color: var(--md-error);
}
.alarm-badge-warning {
background: #fff3e0;
color: #e65100;
}
.alarm-badge-ok {
background: #e8f5e9;
color: #2e7d32;
}
.alarm-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.alarm-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px 20px;
border-radius: 12px;
border: 1px solid var(--md-outline-variant);
background: var(--md-surface);
transition: box-shadow 0.15s;
}
.alarm-card:hover {
box-shadow: var(--md-shadow-1);
}
.alarm-card-warning {
border-left: 4px solid #e65100;
}
.alarm-card-critical {
border-left: 4px solid var(--md-error);
background: #fff8f7;
}
.alarm-card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.alarm-card-warning .alarm-card-icon {
background: #fff3e0;
color: #e65100;
}
.alarm-card-critical .alarm-card-icon {
background: #fce8e6;
color: var(--md-error);
}
.alarm-card-body {
flex: 1;
min-width: 0;
}
.alarm-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.alarm-severity-badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.severity-warning {
background: #fff3e0;
color: #e65100;
}
.severity-critical {
background: #fce8e6;
color: var(--md-error);
}
.alarm-card-time {
font-size: 12px;
color: var(--md-on-surface-variant);
}
.alarm-card-message {
font-size: 14px;
font-weight: 500;
color: var(--md-on-surface);
margin-bottom: 8px;
}
.alarm-card-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--md-on-surface-variant);
}
.alarm-card-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.alarm-card-meta .material-symbols-outlined {
font-size: 16px;
}
.alarm-card-actions {
flex-shrink: 0;
display: flex;
align-items: center;
}
.alarm-ack-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #2e7d32;
}
.alarm-ack-info .material-symbols-outlined {
font-size: 18px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.topnav-center {

View File

@@ -1,6 +1,6 @@
import useSWR from 'swr';
import { fetcher, getTenantUrl } from './api';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog } from './types';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog, Alarm, AlarmSummary } from './types';
export function useTenants() {
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
@@ -125,6 +125,41 @@ export function useReplacements(tenantId?: string, partId?: string) {
};
}
export function useAlarms(tenantId?: string, isAcknowledged?: boolean) {
const params = new URLSearchParams();
if (isAcknowledged !== undefined) params.set('is_acknowledged', String(isAcknowledged));
const qs = params.toString();
const url = tenantId ? `/api/${tenantId}/alarms${qs ? `?${qs}` : ''}` : null;
const { data, error, isLoading, mutate } = useSWR<{ alarms: Alarm[] }>(
url,
fetcher,
{ refreshInterval: 10000, dedupingInterval: 2000 },
);
return {
alarms: data?.alarms || [],
error,
isLoading,
mutate,
};
}
export function useAlarmSummary(tenantId?: string) {
const url = tenantId ? `/api/${tenantId}/alarms/summary` : null;
const { data, error, isLoading, mutate } = useSWR<AlarmSummary>(
url,
fetcher,
{ refreshInterval: 10000, dedupingInterval: 2000 },
);
return {
summary: data ?? null,
error,
isLoading,
mutate,
};
}
export function useInspections(tenantId?: string, status?: string, templateId?: string) {
const params = new URLSearchParams();
if (status) params.set('status', status);

View File

@@ -143,6 +143,29 @@ export interface PartReplacementLog {
notes: string | null;
}
export interface Alarm {
id: string;
tenant_id: string;
equipment_part_id: string;
machine_id: string;
alarm_type: 'threshold' | 'critical';
severity: 'warning' | 'critical';
message: string;
lifecycle_pct_at_trigger: number;
is_acknowledged: boolean;
acknowledged_by: string | null;
acknowledged_at: string | null;
created_at: string | null;
machine_name: string | null;
part_name: string | null;
}
export interface AlarmSummary {
active_count: number;
critical_count: number;
warning_count: number;
}
export interface InspectionTemplate {
id: string;
tenant_id: string;

View File

@@ -23,6 +23,7 @@ from src.api.machines import router as machines_router
from src.api.equipment_parts import router as equipment_parts_router
from src.api.templates import router as templates_router
from src.api.inspections import router as inspections_router
from src.api.alarms import router as alarms_router
logger = logging.getLogger(__name__)
@@ -55,6 +56,7 @@ app.include_router(machines_router)
app.include_router(equipment_parts_router)
app.include_router(templates_router)
app.include_router(inspections_router)
app.include_router(alarms_router)
@app.get("/api/health")

184
src/api/alarms.py Normal file
View File

@@ -0,0 +1,184 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Path, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Alarm, EquipmentPart, PartCounter, Machine
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(tags=["alarms"])
def _format_ts(val) -> Optional[str]:
if val is None:
return None
if hasattr(val, "isoformat"):
return val.isoformat()
return str(val)
def _alarm_to_dict(alarm: Alarm) -> dict:
return {
"id": str(alarm.id),
"tenant_id": str(alarm.tenant_id),
"equipment_part_id": str(alarm.equipment_part_id),
"machine_id": str(alarm.machine_id),
"alarm_type": str(alarm.alarm_type),
"severity": str(alarm.severity),
"message": str(alarm.message),
"lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger),
"is_acknowledged": bool(alarm.is_acknowledged),
"acknowledged_by": str(alarm.acknowledged_by)
if alarm.acknowledged_by
else None,
"acknowledged_at": _format_ts(alarm.acknowledged_at),
"created_at": _format_ts(alarm.created_at),
"machine_name": alarm.machine.name if alarm.machine else None,
"part_name": alarm.equipment_part.name if alarm.equipment_part else None,
}
@router.get("/api/{tenant_id}/alarms")
async def list_alarms(
tenant_id: str = Path(...),
is_acknowledged: Optional[bool] = Query(None),
severity: Optional[str] = Query(None),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(Alarm)
.options(
selectinload(Alarm.equipment_part),
selectinload(Alarm.machine),
)
.where(Alarm.tenant_id == tenant_id)
)
if is_acknowledged is not None:
stmt = stmt.where(Alarm.is_acknowledged == is_acknowledged)
if severity:
stmt = stmt.where(Alarm.severity == severity)
stmt = stmt.order_by(Alarm.created_at.desc())
result = await db.execute(stmt)
alarms = result.scalars().all()
return {"alarms": [_alarm_to_dict(a) for a in alarms]}
@router.get("/api/{tenant_id}/alarms/summary")
async def alarm_summary(
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(
func.count().filter(Alarm.is_acknowledged == False).label("active_count"),
func.count()
.filter(Alarm.severity == "critical", Alarm.is_acknowledged == False)
.label("critical_count"),
func.count()
.filter(Alarm.severity == "warning", Alarm.is_acknowledged == False)
.label("warning_count"),
).where(Alarm.tenant_id == tenant_id)
result = await db.execute(stmt)
row = result.one()
return {
"active_count": row.active_count,
"critical_count": row.critical_count,
"warning_count": row.warning_count,
}
@router.post("/api/{tenant_id}/alarms/{alarm_id}/acknowledge")
async def acknowledge_alarm(
tenant_id: str = Path(...),
alarm_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(Alarm).where(Alarm.id == alarm_id, Alarm.tenant_id == tenant_id)
result = await db.execute(stmt)
alarm = result.scalar_one_or_none()
if not alarm:
raise HTTPException(status_code=404, detail="알람을 찾을 수 없습니다.")
if alarm.is_acknowledged:
raise HTTPException(status_code=400, detail="이미 확인된 알람입니다.")
alarm.is_acknowledged = True
alarm.acknowledged_by = UUID(current_user.user_id)
alarm.acknowledged_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(alarm)
db.expire_all()
stmt2 = (
select(Alarm)
.options(selectinload(Alarm.equipment_part), selectinload(Alarm.machine))
.where(Alarm.id == alarm_id)
)
alarm = (await db.execute(stmt2)).scalar_one()
return _alarm_to_dict(alarm)
async def generate_alarms_for_part(
db: AsyncSession,
tenant_id: str,
part: EquipmentPart,
counter: PartCounter,
):
pct = float(counter.lifecycle_pct or 0)
threshold = float(part.alarm_threshold or 90)
if pct < threshold:
return
if pct >= 100:
alarm_type = "critical"
severity = "critical"
message = f"{part.name} 수명 {pct:.1f}% — 즉시 교체 필요"
else:
alarm_type = "threshold"
severity = "warning"
message = f"{part.name} 수명 {pct:.1f}% — 교체 예정 (기준 {threshold:.0f}%)"
existing = await db.execute(
select(Alarm).where(
Alarm.tenant_id == tenant_id,
Alarm.equipment_part_id == part.id,
Alarm.alarm_type == alarm_type,
Alarm.is_acknowledged == False,
)
)
if existing.scalar_one_or_none():
return
alarm = Alarm(
tenant_id=tenant_id,
equipment_part_id=part.id,
machine_id=part.machine_id,
alarm_type=alarm_type,
severity=severity,
message=message,
lifecycle_pct_at_trigger=pct,
)
db.add(alarm)

View File

@@ -12,6 +12,7 @@ from src.database.config import get_db
from src.database.models import Machine, EquipmentPart, PartCounter, PartReplacementLog
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
from src.api.alarms import generate_alarms_for_part
router = APIRouter(tags=["equipment_parts"])
@@ -349,6 +350,9 @@ async def update_counter(
counter.last_updated_at = now
counter.version = (counter.version or 0) + 1
await db.flush()
await generate_alarms_for_part(db, tenant_id, part, counter)
await db.commit()
await db.refresh(counter)

View File

@@ -305,3 +305,33 @@ class InspectionRecord(Base):
),
Index("ix_records_session", "session_id"),
)
class Alarm(Base):
__tablename__ = "alarms"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
equipment_part_id = Column(
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=False
)
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False)
alarm_type = Column(String(30), nullable=False) # threshold | critical
severity = Column(String(20), nullable=False) # warning | critical
message = Column(String(500), nullable=False)
lifecycle_pct_at_trigger = Column(Float, nullable=False)
is_acknowledged = Column(Boolean, default=False)
acknowledged_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
acknowledged_at = Column(TIMESTAMP(timezone=True), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
tenant = relationship("Tenant")
equipment_part = relationship("EquipmentPart")
machine = relationship("Machine")
acknowledged_user = relationship("User")
__table_args__ = (
Index("ix_alarms_tenant_ack", "tenant_id", "is_acknowledged"),
Index("ix_alarms_tenant_severity", "tenant_id", "severity"),
Index("ix_alarms_tenant_part", "tenant_id", "equipment_part_id"),
)

225
tests/test_alarms.py Normal file
View File

@@ -0,0 +1,225 @@
import pytest
from httpx import AsyncClient
from tests.conftest import get_auth_headers
async def _create_machine(
client: AsyncClient, headers: dict, tenant_id: str = "test-co"
) -> str:
resp = await client.post(
f"/api/{tenant_id}/machines",
json={"name": "알람 테스트 설비", "equipment_code": "A-001"},
headers=headers,
)
return resp.json()["id"]
async def _create_part(
client: AsyncClient,
headers: dict,
machine_id: str,
tenant_id: str = "test-co",
alarm_threshold: float = 80.0,
) -> str:
resp = await client.post(
f"/api/{tenant_id}/machines/{machine_id}/parts",
json={
"name": "알람 부품",
"lifecycle_type": "hours",
"lifecycle_limit": 1000,
"alarm_threshold": alarm_threshold,
"counter_source": "manual",
},
headers=headers,
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_no_alarm_below_threshold(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 700},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
assert resp.status_code == 200
assert len(resp.json()["alarms"]) == 0
@pytest.mark.asyncio
async def test_warning_alarm_at_threshold(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 850},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
assert resp.status_code == 200
alarms = resp.json()["alarms"]
assert len(alarms) == 1
assert alarms[0]["severity"] == "warning"
assert alarms[0]["alarm_type"] == "threshold"
assert alarms[0]["is_acknowledged"] is False
assert "85.0%" in alarms[0]["message"]
@pytest.mark.asyncio
async def test_critical_alarm_at_100pct(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 1100},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
assert resp.status_code == 200
alarms = resp.json()["alarms"]
assert len(alarms) == 1
assert alarms[0]["severity"] == "critical"
assert alarms[0]["alarm_type"] == "critical"
@pytest.mark.asyncio
async def test_no_duplicate_alarm(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 900},
headers=headers,
)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 950},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
alarms = resp.json()["alarms"]
assert len(alarms) == 1
@pytest.mark.asyncio
async def test_acknowledge_alarm(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 850},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
alarm_id = resp.json()["alarms"][0]["id"]
ack_resp = await client.post(
f"/api/test-co/alarms/{alarm_id}/acknowledge",
headers=headers,
)
assert ack_resp.status_code == 200
assert ack_resp.json()["is_acknowledged"] is True
assert ack_resp.json()["acknowledged_by"] is not None
@pytest.mark.asyncio
async def test_acknowledge_already_acknowledged(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 850},
headers=headers,
)
resp = await client.get("/api/test-co/alarms", headers=headers)
alarm_id = resp.json()["alarms"][0]["id"]
await client.post(f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers)
ack2 = await client.post(
f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers
)
assert ack2.status_code == 400
@pytest.mark.asyncio
async def test_filter_unacknowledged(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 850},
headers=headers,
)
resp = await client.get(
"/api/test-co/alarms?is_acknowledged=false", headers=headers
)
assert len(resp.json()["alarms"]) == 1
alarm_id = resp.json()["alarms"][0]["id"]
await client.post(f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers)
resp2 = await client.get(
"/api/test-co/alarms?is_acknowledged=false", headers=headers
)
assert len(resp2.json()["alarms"]) == 0
@pytest.mark.asyncio
async def test_alarm_summary(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 850},
headers=headers,
)
resp = await client.get("/api/test-co/alarms/summary", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["active_count"] == 1
assert data["warning_count"] == 1
assert data["critical_count"] == 0
@pytest.mark.asyncio
async def test_tenant_isolation(client: AsyncClient, seeded_db):
headers = await get_auth_headers(client)
mid = await _create_machine(client, headers)
pid = await _create_part(client, headers, mid)
await client.put(
f"/api/test-co/parts/{pid}/counter",
json={"value": 900},
headers=headers,
)
resp_other = await client.get("/api/other-co/alarms", headers=headers)
assert resp_other.status_code == 200
assert len(resp_other.json()["alarms"]) == 0