samuelyitakubayo commited on
Commit
4b89d50
·
1 Parent(s): 200bb0a
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
- try:
66
- db.commit()
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
- try:
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
- 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,8 +316,8 @@ def _st_dbscan(
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,15 +1088,15 @@ def ensure_hotspots_materialized(
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,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
- 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,7 +1606,7 @@ def _create_geographic_hotspots(
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,14 +1665,14 @@ def _create_geographic_hotspots(
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,7 +1726,7 @@ def _create_geographic_hotspots(
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,15 +1972,15 @@ def _create_geographic_hotspots(
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,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
- 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"
 
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, alert, deployment, alert, deployment
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)