-
Notifications
You must be signed in to change notification settings - Fork 4
feat(transfers): add permissions transfer functionality and update configuration #546
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import pandas as pd | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from sqlalchemy import select, func | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from db import Deployment, Sensor, Thing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from db import Deployment, PermissionHistory, Sensor, Thing, ThingContactAssociation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from db.engine import session_ctx | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from transfers.sensor_transfer import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EQUIPMENT_TO_SENSOR_TYPE_MAP, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -165,6 +165,76 @@ def _equipment_destination_series(session) -> pd.Series: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return pointid + "|" + serial + "|" + installed + "|" + removed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _permissions_source_series(session) -> pd.Series: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| wdf = replace_nans(wdf) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "PointID" not in wdf.columns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return pd.Series([], dtype=object) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eligible_rows = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| session.query(Thing.name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join(ThingContactAssociation, ThingContactAssociation.thing_id == Thing.id) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Thing.thing_type == "water well") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Thing.name.is_not(None)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .distinct() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eligible_pointids = {name for (name,) in eligible_rows if name} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not eligible_pointids: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return pd.Series([], dtype=object) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rows: list[str] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for row in wdf.itertuples(index=False): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pointid = getattr(row, "PointID", None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if pointid not in eligible_pointids: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sample_ok = getattr(row, "SampleOK", None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if sample_ok is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rows.append( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"{_normalize_key(pointid)}|Water Chemistry Sample|{bool(sample_ok)}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Casting CSV values with Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+186
to
+197
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| monitor_ok = getattr(row, "MonitorOK", None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if monitor_ok is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rows.append( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"{_normalize_key(pointid)}|Water Level Sample|{bool(monitor_ok)}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+182
to
+201
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eligible_pointids = {name for (name,) in eligible_rows if name} | |
| if not eligible_pointids: | |
| return pd.Series([], dtype=object) | |
| rows: list[str] = [] | |
| for row in wdf.itertuples(index=False): | |
| pointid = getattr(row, "PointID", None) | |
| if pointid not in eligible_pointids: | |
| continue | |
| sample_ok = getattr(row, "SampleOK", None) | |
| if sample_ok is not None: | |
| rows.append( | |
| f"{_normalize_key(pointid)}|Water Chemistry Sample|{bool(sample_ok)}" | |
| ) | |
| monitor_ok = getattr(row, "MonitorOK", None) | |
| if monitor_ok is not None: | |
| rows.append( | |
| f"{_normalize_key(pointid)}|Water Level Sample|{bool(monitor_ok)}" | |
| eligible_pointids = { | |
| _normalize_key(name) for (name,) in eligible_rows if _normalize_key(name) | |
| } | |
| if not eligible_pointids: | |
| return pd.Series([], dtype=object) | |
| rows: list[str] = [] | |
| for row in wdf.itertuples(index=False): | |
| pointid = getattr(row, "PointID", None) | |
| normalized_pointid = _normalize_key(pointid) | |
| if not normalized_pointid or normalized_pointid not in eligible_pointids: | |
| continue | |
| sample_ok = getattr(row, "SampleOK", None) | |
| if sample_ok is not None: | |
| rows.append( | |
| f"{normalized_pointid}|Water Chemistry Sample|{bool(sample_ok)}" | |
| ) | |
| monitor_ok = getattr(row, "MonitorOK", None) | |
| if monitor_ok is not None: | |
| rows.append( | |
| f"{normalized_pointid}|Water Level Sample|{bool(monitor_ok)}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope permission destination query to water wells
The permissions source side is explicitly limited to Thing.thing_type == "water well", but the destination query only filters by target_table and permission_type. If the database has PermissionHistory rows for non-well things using the same permission types, this audit will report false extras and inflate destination counts, so source and destination are no longer compared over the same population.
Useful? React with 👍 / 👎.
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_permissions_destination_series pulls all PermissionHistory rows for the permission types, including historical/ended records. This can create false matches (a well may match a historical value even if the current active permission differs) and inflate extra counts over time. Consider restricting to the active/current records (e.g., end_date IS NULL) and, if multiples exist, selecting the latest start_date per (target_id, permission_type) to match how the API surfaces permissions.
| sql = ( | |
| select( | |
| Thing.name.label("point_id"), | |
| PermissionHistory.permission_type.label("permission_type"), | |
| PermissionHistory.permission_allowed.label("permission_allowed"), | |
| ) | |
| .select_from(PermissionHistory) | |
| .join(Thing, Thing.id == PermissionHistory.target_id) | |
| .where(PermissionHistory.target_table == "thing") | |
| .where( | |
| PermissionHistory.permission_type.in_( | |
| ("Water Chemistry Sample", "Water Level Sample") | |
| ) | |
| ) | |
| # Select only active permission records (end_date IS NULL) and, for each | |
| # (target_id, permission_type), keep the record with the latest start_date. | |
| latest_active_subq = ( | |
| select( | |
| PermissionHistory.target_id.label("target_id"), | |
| PermissionHistory.permission_type.label("permission_type"), | |
| func.max(PermissionHistory.start_date).label("max_start_date"), | |
| ) | |
| .where(PermissionHistory.target_table == "thing") | |
| .where( | |
| PermissionHistory.permission_type.in_( | |
| ("Water Chemistry Sample", "Water Level Sample") | |
| ) | |
| ) | |
| .where(PermissionHistory.end_date.is_(None)) | |
| .group_by( | |
| PermissionHistory.target_id, | |
| PermissionHistory.permission_type, | |
| ) | |
| .subquery() | |
| ) | |
| sql = ( | |
| select( | |
| Thing.name.label("point_id"), | |
| PermissionHistory.permission_type.label("permission_type"), | |
| PermissionHistory.permission_allowed.label("permission_allowed"), | |
| ) | |
| .select_from(PermissionHistory) | |
| .join( | |
| latest_active_subq, | |
| ( | |
| (PermissionHistory.target_id == latest_active_subq.c.target_id) | |
| & ( | |
| PermissionHistory.permission_type | |
| == latest_active_subq.c.permission_type | |
| ) | |
| & ( | |
| PermissionHistory.start_date | |
| == latest_active_subq.c.max_start_date | |
| ) | |
| ), | |
| ) | |
| .join(Thing, Thing.id == PermissionHistory.target_id) |
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_build_permissions reads WellData into source_df to count rows, but _permissions_source_series re-reads WellData internally. This duplicates I/O and parsing work for large CSVs. Consider passing the already-loaded source_df (or a pre-cleaned version) into _permissions_source_series so the file is only read once.
| with session_ctx() as session: | |
| source_series = ( | |
| _permissions_source_series(session) | |
| if enabled | |
| else pd.Series([], dtype=object) | |
| ) | |
| source_keys = set(source_series.unique().tolist()) | |
| source_keyed_row_count = int(source_series.shape[0]) | |
| source_duplicate_key_row_count = source_keyed_row_count - len(source_keys) | |
| agreed_transfer_row_count = source_keyed_row_count | |
| if enabled: | |
| # Derive the source key series directly from the already-loaded CSV | |
| if spec.source_key_column in source_df.columns: | |
| source_series = source_df[spec.source_key_column].astype(str) | |
| else: | |
| source_series = pd.Series([], dtype=object) | |
| else: | |
| source_series = pd.Series([], dtype=object) | |
| source_keys = set(source_series.unique().tolist()) | |
| source_keyed_row_count = int(source_series.shape[0]) | |
| source_duplicate_key_row_count = source_keyed_row_count - len(source_keys) | |
| agreed_transfer_row_count = source_keyed_row_count | |
| with session_ctx() as session: |
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
destination_row_count for permissions is computed without the same constraints used to build destination_series (inner join to Thing, Thing.name non-null). This can make the reported destination row count inconsistent with the key series (e.g., orphaned PermissionHistory rows or unnamed Things). Align the count query with _permissions_destination_series filters (and any additional filters like end_date is NULL).
| destination_row_count = int( | |
| session.execute( | |
| select(func.count()) | |
| .select_from(PermissionHistory) | |
| .where(PermissionHistory.target_table == "thing") | |
| .where( | |
| PermissionHistory.permission_type.in_( | |
| ("Water Chemistry Sample", "Water Level Sample") | |
| ) | |
| ) | |
| ).scalar_one() | |
| ) | |
| destination_row_count = int(destination_series.shape[0]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sequential permissions transfer failure log only includes
str(e)and drops the stack trace, while other parallel-transfer error paths logtraceback.format_exc(). Logging the full traceback here would make diagnosing transfer failures significantly easier.