Spaces:
Runtime error
Runtime error
Commit ·
4b89d50
1
Parent(s): 200bb0a
ok
Browse files
backend/alembic/versions/add_notification_type_deployment.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
"""Add deployment value to notification_type enum.
|
| 2 |
-
|
| 3 |
-
Revision ID: add_notification_type_deployment
|
| 4 |
-
Revises: add_deployment_decisions_table
|
| 5 |
-
Create Date: 2026-05-29
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from alembic import op
|
| 9 |
-
|
| 10 |
-
revision = "add_notification_type_deployment"
|
| 11 |
-
down_revision = "add_deployment_decisions_table"
|
| 12 |
-
branch_labels = None
|
| 13 |
-
depends_on = None
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def upgrade() -> None:
|
| 17 |
-
op.execute(
|
| 18 |
-
"ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'deployment'"
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def downgrade() -> None:
|
| 23 |
-
# PostgreSQL does not support removing enum values safely.
|
| 24 |
-
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/api/v1/notifications.py
CHANGED
|
@@ -62,12 +62,8 @@ def create_notification(
|
|
| 62 |
related_entity_id=related_entity_id,
|
| 63 |
)
|
| 64 |
db.add(n)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
db.refresh(n)
|
| 68 |
-
except Exception:
|
| 69 |
-
db.rollback()
|
| 70 |
-
raise
|
| 71 |
|
| 72 |
# Send real-time notification update for the target user.
|
| 73 |
_run_async_now_or_schedule(
|
|
@@ -143,12 +139,8 @@ def create_role_notifications(
|
|
| 143 |
)
|
| 144 |
db.add(notif)
|
| 145 |
notifications.append(notif)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
db.commit()
|
| 149 |
-
except Exception:
|
| 150 |
-
db.rollback()
|
| 151 |
-
raise
|
| 152 |
|
| 153 |
# Send real-time notification updates for affected users.
|
| 154 |
for user in users:
|
|
|
|
| 62 |
related_entity_id=related_entity_id,
|
| 63 |
)
|
| 64 |
db.add(n)
|
| 65 |
+
db.commit()
|
| 66 |
+
db.refresh(n)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
# Send real-time notification update for the target user.
|
| 69 |
_run_async_now_or_schedule(
|
|
|
|
| 139 |
)
|
| 140 |
db.add(notif)
|
| 141 |
notifications.append(notif)
|
| 142 |
+
|
| 143 |
+
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
# Send real-time notification updates for affected users.
|
| 146 |
for user in users:
|
backend/app/core/hotspot_auto.py
CHANGED
|
@@ -289,9 +289,9 @@ def _st_dbscan(
|
|
| 289 |
nbs: List[int] = []
|
| 290 |
for j, q in enumerate(points):
|
| 291 |
if _haversine_meters(p["lat"], p["lon"], q["lat"], q["lon"]) > eps_meters:
|
| 292 |
-
|
| 293 |
if eps_seconds > 0 and abs(ts[i] - ts[j]) > eps_seconds:
|
| 294 |
-
|
| 295 |
nbs.append(j)
|
| 296 |
neighborhoods.append(nbs)
|
| 297 |
nb_weight = sum(weights[j] for j in nbs)
|
|
@@ -316,8 +316,8 @@ def _st_dbscan(
|
|
| 316 |
labels[j] = cluster_id
|
| 317 |
if is_core[j]:
|
| 318 |
for cand in neighborhoods[j]:
|
| 319 |
-
|
| 320 |
-
|
| 321 |
elif labels[j] == -1:
|
| 322 |
labels[j] = cluster_id
|
| 323 |
cluster_id += 1
|
|
@@ -1088,15 +1088,15 @@ def ensure_hotspots_materialized(
|
|
| 1088 |
)
|
| 1089 |
if created > 0:
|
| 1090 |
db.commit()
|
| 1091 |
-
|
| 1092 |
finally:
|
| 1093 |
db.execute(_text("SELECT pg_advisory_unlock(7654321)"))
|
| 1094 |
|
| 1095 |
|
| 1096 |
def _create_village_based_hotspots(
|
| 1097 |
-
db: Session,
|
| 1098 |
-
reports: List[Dict[str, Any]],
|
| 1099 |
-
min_incidents: int,
|
| 1100 |
time_window_hours: int,
|
| 1101 |
radius_meters: float = DEFAULT_RADIUS_METERS,
|
| 1102 |
) -> int:
|
|
@@ -1460,9 +1460,9 @@ def _merge_overlapping_hotspots(db: Session, eps_m: float) -> int:
|
|
| 1460 |
|
| 1461 |
def _create_geographic_hotspots(
|
| 1462 |
db: Session,
|
| 1463 |
-
reports: List[Dict[str, Any]],
|
| 1464 |
-
radius_meters: float,
|
| 1465 |
-
min_incidents: int,
|
| 1466 |
time_window_hours: int,
|
| 1467 |
*,
|
| 1468 |
enforce_time_span: bool = True,
|
|
@@ -1560,20 +1560,20 @@ def _create_geographic_hotspots(
|
|
| 1560 |
)
|
| 1561 |
|
| 1562 |
raw_clusters: Dict[int, List[Dict[str, Any]]] = {}
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
tagged = {**group_pts[idx], "is_core": is_core_flags[idx]}
|
| 1567 |
raw_clusters.setdefault(label, []).append(tagged)
|
| 1568 |
|
| 1569 |
for cluster_pts in raw_clusters.values():
|
| 1570 |
incident_count = len(cluster_pts)
|
| 1571 |
-
|
| 1572 |
# Under-sized DBSCAN cluster — try to attach each point to an
|
| 1573 |
# existing nearby hotspot; never create a new single-point hotspot.
|
| 1574 |
for p in cluster_pts:
|
| 1575 |
_try_attach_isolated(p)
|
| 1576 |
-
|
| 1577 |
|
| 1578 |
# Time-span guard
|
| 1579 |
cluster_pts_sorted = sorted(
|
|
@@ -1606,7 +1606,7 @@ def _create_geographic_hotspots(
|
|
| 1606 |
)
|
| 1607 |
for p in cluster_pts:
|
| 1608 |
_try_attach_isolated(p)
|
| 1609 |
-
|
| 1610 |
|
| 1611 |
corroboration = _corroboration_score(cluster_pts, MIN_UNIQUE_REPORTERS)
|
| 1612 |
|
|
@@ -1665,14 +1665,14 @@ def _create_geographic_hotspots(
|
|
| 1665 |
polygon_json = None
|
| 1666 |
|
| 1667 |
# Risk classification
|
| 1668 |
-
|
| 1669 |
-
|
| 1670 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
|
| 1675 |
-
|
| 1676 |
# Override risk level with incident-count-based criteria:
|
| 1677 |
# HIGH: >4 new incidents in last 7 days, OR 12+ in last 7 days, OR 15+ total
|
| 1678 |
# MEDIUM: everything else (cluster already has ≥4 incidents from min_pts)
|
|
@@ -1726,7 +1726,7 @@ def _create_geographic_hotspots(
|
|
| 1726 |
|
| 1727 |
if existing:
|
| 1728 |
prev_lifecycle = existing.lifecycle_state
|
| 1729 |
-
|
| 1730 |
hotspot.center_lat = Decimal(str(center_lat))
|
| 1731 |
hotspot.center_long = Decimal(str(center_long))
|
| 1732 |
hotspot.radius_meters = Decimal(str(eps_m))
|
|
@@ -1972,15 +1972,15 @@ def _create_geographic_hotspots(
|
|
| 1972 |
for pt in cluster_pts
|
| 1973 |
) / incident_count
|
| 1974 |
conf_n = _cluster_confidence(cluster_pts)
|
| 1975 |
-
|
| 1976 |
-
|
| 1977 |
-
|
| 1978 |
-
|
| 1979 |
-
|
| 1980 |
-
|
| 1981 |
-
|
| 1982 |
incident_type_id=noise_dominant_type_id,
|
| 1983 |
-
|
| 1984 |
lifecycle_state=classification_result["classification"],
|
| 1985 |
composition=json.dumps(noise_composition),
|
| 1986 |
temporal_intensity=Decimal(str(round(t_intensity_n, 4))),
|
|
@@ -1989,14 +1989,14 @@ def _create_geographic_hotspots(
|
|
| 1989 |
cluster_confidence=Decimal(str(conf_n)),
|
| 1990 |
polygon_points=polygon_json,
|
| 1991 |
crime_group=_get_crime_group(max(noise_composition, key=noise_composition.get) if noise_composition else ""),
|
| 1992 |
-
|
| 1993 |
-
|
| 1994 |
-
|
| 1995 |
-
|
| 1996 |
noise_clusters_created += 1
|
| 1997 |
for pt in cluster_pts:
|
| 1998 |
-
|
| 1999 |
-
|
| 2000 |
"INSERT INTO hotspot_reports (hotspot_id, report_id, is_core) "
|
| 2001 |
"VALUES (:hid, :rid, true) "
|
| 2002 |
"ON CONFLICT (hotspot_id, report_id) DO UPDATE SET is_core = true"
|
|
|
|
| 289 |
nbs: List[int] = []
|
| 290 |
for j, q in enumerate(points):
|
| 291 |
if _haversine_meters(p["lat"], p["lon"], q["lat"], q["lon"]) > eps_meters:
|
| 292 |
+
continue
|
| 293 |
if eps_seconds > 0 and abs(ts[i] - ts[j]) > eps_seconds:
|
| 294 |
+
continue
|
| 295 |
nbs.append(j)
|
| 296 |
neighborhoods.append(nbs)
|
| 297 |
nb_weight = sum(weights[j] for j in nbs)
|
|
|
|
| 316 |
labels[j] = cluster_id
|
| 317 |
if is_core[j]:
|
| 318 |
for cand in neighborhoods[j]:
|
| 319 |
+
if cand not in queue:
|
| 320 |
+
queue.append(cand)
|
| 321 |
elif labels[j] == -1:
|
| 322 |
labels[j] = cluster_id
|
| 323 |
cluster_id += 1
|
|
|
|
| 1088 |
)
|
| 1089 |
if created > 0:
|
| 1090 |
db.commit()
|
| 1091 |
+
return created
|
| 1092 |
finally:
|
| 1093 |
db.execute(_text("SELECT pg_advisory_unlock(7654321)"))
|
| 1094 |
|
| 1095 |
|
| 1096 |
def _create_village_based_hotspots(
|
| 1097 |
+
db: Session,
|
| 1098 |
+
reports: List[Dict[str, Any]],
|
| 1099 |
+
min_incidents: int,
|
| 1100 |
time_window_hours: int,
|
| 1101 |
radius_meters: float = DEFAULT_RADIUS_METERS,
|
| 1102 |
) -> int:
|
|
|
|
| 1460 |
|
| 1461 |
def _create_geographic_hotspots(
|
| 1462 |
db: Session,
|
| 1463 |
+
reports: List[Dict[str, Any]],
|
| 1464 |
+
radius_meters: float,
|
| 1465 |
+
min_incidents: int,
|
| 1466 |
time_window_hours: int,
|
| 1467 |
*,
|
| 1468 |
enforce_time_span: bool = True,
|
|
|
|
| 1560 |
)
|
| 1561 |
|
| 1562 |
raw_clusters: Dict[int, List[Dict[str, Any]]] = {}
|
| 1563 |
+
for idx, label in enumerate(labels):
|
| 1564 |
+
if label < 0:
|
| 1565 |
+
continue
|
| 1566 |
tagged = {**group_pts[idx], "is_core": is_core_flags[idx]}
|
| 1567 |
raw_clusters.setdefault(label, []).append(tagged)
|
| 1568 |
|
| 1569 |
for cluster_pts in raw_clusters.values():
|
| 1570 |
incident_count = len(cluster_pts)
|
| 1571 |
+
if incident_count < int(min_incidents):
|
| 1572 |
# Under-sized DBSCAN cluster — try to attach each point to an
|
| 1573 |
# existing nearby hotspot; never create a new single-point hotspot.
|
| 1574 |
for p in cluster_pts:
|
| 1575 |
_try_attach_isolated(p)
|
| 1576 |
+
continue
|
| 1577 |
|
| 1578 |
# Time-span guard
|
| 1579 |
cluster_pts_sorted = sorted(
|
|
|
|
| 1606 |
)
|
| 1607 |
for p in cluster_pts:
|
| 1608 |
_try_attach_isolated(p)
|
| 1609 |
+
continue
|
| 1610 |
|
| 1611 |
corroboration = _corroboration_score(cluster_pts, MIN_UNIQUE_REPORTERS)
|
| 1612 |
|
|
|
|
| 1665 |
polygon_json = None
|
| 1666 |
|
| 1667 |
# Risk classification
|
| 1668 |
+
area_sqkm = max(0.001, 3.14159 * (float(radius_meters) / 1000.0) ** 2)
|
| 1669 |
+
cluster_density = incident_count / area_sqkm
|
| 1670 |
+
classification_result = predict_cluster_classification(
|
| 1671 |
+
incident_count=incident_count,
|
| 1672 |
+
avg_trust=avg_trust,
|
| 1673 |
+
cluster_density=cluster_density,
|
| 1674 |
+
time_window_hours=time_window_hours,
|
| 1675 |
+
)
|
| 1676 |
# Override risk level with incident-count-based criteria:
|
| 1677 |
# HIGH: >4 new incidents in last 7 days, OR 12+ in last 7 days, OR 15+ total
|
| 1678 |
# MEDIUM: everything else (cluster already has ≥4 incidents from min_pts)
|
|
|
|
| 1726 |
|
| 1727 |
if existing:
|
| 1728 |
prev_lifecycle = existing.lifecycle_state
|
| 1729 |
+
hotspot = existing
|
| 1730 |
hotspot.center_lat = Decimal(str(center_lat))
|
| 1731 |
hotspot.center_long = Decimal(str(center_long))
|
| 1732 |
hotspot.radius_meters = Decimal(str(eps_m))
|
|
|
|
| 1972 |
for pt in cluster_pts
|
| 1973 |
) / incident_count
|
| 1974 |
conf_n = _cluster_confidence(cluster_pts)
|
| 1975 |
+
hotspot = Hotspot(
|
| 1976 |
+
center_lat=Decimal(str(center_lat)),
|
| 1977 |
+
center_long=Decimal(str(center_long)),
|
| 1978 |
+
radius_meters=Decimal(str(radius_meters)),
|
| 1979 |
+
incident_count=incident_count,
|
| 1980 |
+
risk_level=risk_level,
|
| 1981 |
+
time_window_hours=time_window_hours,
|
| 1982 |
incident_type_id=noise_dominant_type_id,
|
| 1983 |
+
detected_at=datetime.now(timezone.utc),
|
| 1984 |
lifecycle_state=classification_result["classification"],
|
| 1985 |
composition=json.dumps(noise_composition),
|
| 1986 |
temporal_intensity=Decimal(str(round(t_intensity_n, 4))),
|
|
|
|
| 1989 |
cluster_confidence=Decimal(str(conf_n)),
|
| 1990 |
polygon_points=polygon_json,
|
| 1991 |
crime_group=_get_crime_group(max(noise_composition, key=noise_composition.get) if noise_composition else ""),
|
| 1992 |
+
)
|
| 1993 |
+
db.add(hotspot)
|
| 1994 |
+
db.flush()
|
| 1995 |
+
created += 1
|
| 1996 |
noise_clusters_created += 1
|
| 1997 |
for pt in cluster_pts:
|
| 1998 |
+
db.execute(
|
| 1999 |
+
text(
|
| 2000 |
"INSERT INTO hotspot_reports (hotspot_id, report_id, is_core) "
|
| 2001 |
"VALUES (:hid, :rid, true) "
|
| 2002 |
"ON CONFLICT (hotspot_id, report_id) DO UPDATE SET is_core = true"
|
backend/app/core/hotspot_deployment.py
CHANGED
|
@@ -93,7 +93,6 @@ def _notify_deployment_stakeholders(
|
|
| 93 |
)
|
| 94 |
in_app_commander = 1
|
| 95 |
except Exception as exc:
|
| 96 |
-
db.rollback()
|
| 97 |
_log.warning("Commander in-app notification failed: %s", exc)
|
| 98 |
|
| 99 |
station_ids = _station_ids_for_reports(db, reports)
|
|
@@ -122,7 +121,6 @@ def _notify_deployment_stakeholders(
|
|
| 122 |
)
|
| 123 |
in_app_stations += len(notes)
|
| 124 |
except Exception as exc:
|
| 125 |
-
db.rollback()
|
| 126 |
_log.warning(
|
| 127 |
"Station deployment notification failed station_id=%s: %s",
|
| 128 |
station_id,
|
|
@@ -142,7 +140,6 @@ def _notify_deployment_stakeholders(
|
|
| 142 |
send_email=False,
|
| 143 |
)
|
| 144 |
except Exception as exc:
|
| 145 |
-
db.rollback()
|
| 146 |
_log.warning("Admin deployment notification failed: %s", exc)
|
| 147 |
|
| 148 |
return {
|
|
|
|
| 93 |
)
|
| 94 |
in_app_commander = 1
|
| 95 |
except Exception as exc:
|
|
|
|
| 96 |
_log.warning("Commander in-app notification failed: %s", exc)
|
| 97 |
|
| 98 |
station_ids = _station_ids_for_reports(db, reports)
|
|
|
|
| 121 |
)
|
| 122 |
in_app_stations += len(notes)
|
| 123 |
except Exception as exc:
|
|
|
|
| 124 |
_log.warning(
|
| 125 |
"Station deployment notification failed station_id=%s: %s",
|
| 126 |
station_id,
|
|
|
|
| 140 |
send_email=False,
|
| 141 |
)
|
| 142 |
except Exception as exc:
|
|
|
|
| 143 |
_log.warning("Admin deployment notification failed: %s", exc)
|
| 144 |
|
| 145 |
return {
|
backend/app/core/workflow_schema_extensions.py
CHANGED
|
@@ -145,12 +145,6 @@ DDL_STATEMENTS: tuple[str, ...] = (
|
|
| 145 |
ON deployment_decisions (report_id);
|
| 146 |
""",
|
| 147 |
"""
|
| 148 |
-
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'deployment';
|
| 149 |
-
""",
|
| 150 |
-
"""
|
| 151 |
-
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'deployment';
|
| 152 |
-
""",
|
| 153 |
-
"""
|
| 154 |
INSERT INTO special_assignment_units (unit_code, unit_name, description)
|
| 155 |
VALUES
|
| 156 |
('RIB', 'RIB — Investigation Bureau', 'Serious crime / investigation handover'),
|
|
|
|
| 145 |
ON deployment_decisions (report_id);
|
| 146 |
""",
|
| 147 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
INSERT INTO special_assignment_units (unit_code, unit_name, description)
|
| 149 |
VALUES
|
| 150 |
('RIB', 'RIB — Investigation Bureau', 'Serious crime / investigation handover'),
|
backend/app/models/notification.py
CHANGED
|
@@ -12,7 +12,7 @@ class Notification(Base):
|
|
| 12 |
police_user_id = Column(Integer, ForeignKey("police_users.police_user_id"), nullable=False)
|
| 13 |
title = Column(String(150), nullable=False)
|
| 14 |
message = Column(Text)
|
| 15 |
-
type = Column(String(20), nullable=False) # report, hotspot, assignment, system
|
| 16 |
related_entity_type = Column(String(50))
|
| 17 |
related_entity_id = Column(String(36))
|
| 18 |
is_read = Column(Boolean, default=False)
|
|
|
|
| 12 |
police_user_id = Column(Integer, ForeignKey("police_users.police_user_id"), nullable=False)
|
| 13 |
title = Column(String(150), nullable=False)
|
| 14 |
message = Column(Text)
|
| 15 |
+
type = Column(String(20), nullable=False) # report, hotspot, assignment, system
|
| 16 |
related_entity_type = Column(String(50))
|
| 17 |
related_entity_id = Column(String(36))
|
| 18 |
is_read = Column(Boolean, default=False)
|